blob: a19fecfd2576c707de4b9e2562538b0ac7110d0b [file] [log] [blame]
gio0eaf2712024-04-14 13:08:46 +04001package welcome
2
3import (
gio94904702024-07-26 16:58:34 +04004 "bytes"
gio81246f02024-07-10 12:02:15 +04005 "context"
gio23bdc1b2024-07-11 16:07:47 +04006 "embed"
gio0eaf2712024-04-14 13:08:46 +04007 "encoding/json"
gio9d66f322024-07-06 13:45:10 +04008 "errors"
gio0eaf2712024-04-14 13:08:46 +04009 "fmt"
gio23bdc1b2024-07-11 16:07:47 +040010 "html/template"
gio0eaf2712024-04-14 13:08:46 +040011 "io"
gio9d66f322024-07-06 13:45:10 +040012 "io/fs"
gio0eaf2712024-04-14 13:08:46 +040013 "net/http"
gio23bdc1b2024-07-11 16:07:47 +040014 "slices"
gio0eaf2712024-04-14 13:08:46 +040015 "strings"
gio9d66f322024-07-06 13:45:10 +040016 "sync"
giocafd4e62024-07-31 10:53:40 +040017 "time"
gio0eaf2712024-04-14 13:08:46 +040018
Davit Tabidzea5ea5092024-08-01 15:28:09 +040019 "golang.org/x/crypto/bcrypt"
20 "golang.org/x/exp/rand"
21
gio0eaf2712024-04-14 13:08:46 +040022 "github.com/giolekva/pcloud/core/installer"
23 "github.com/giolekva/pcloud/core/installer/soft"
gio43b0f422024-08-21 10:40:13 +040024 "github.com/giolekva/pcloud/core/installer/tasks"
gio33059762024-07-05 13:19:07 +040025
26 "github.com/gorilla/mux"
gio81246f02024-07-10 12:02:15 +040027 "github.com/gorilla/securecookie"
gio0eaf2712024-04-14 13:08:46 +040028)
29
gio23bdc1b2024-07-11 16:07:47 +040030//go:embed dodo-app-tmpl/*
31var dodoAppTmplFS embed.FS
32
gio5e49bb62024-07-20 10:43:19 +040033//go:embed all:app-tmpl
34var appTmplsFS embed.FS
35
gio9d66f322024-07-06 13:45:10 +040036const (
gioa60f0de2024-07-08 10:49:48 +040037 ConfigRepoName = "config"
giod8ab4f52024-07-26 16:58:34 +040038 appConfigsFile = "/apps.json"
gio81246f02024-07-10 12:02:15 +040039 loginPath = "/login"
40 logoutPath = "/logout"
gio1bf00802024-08-17 12:31:41 +040041 staticPath = "/stat/"
gio8fae3af2024-07-25 13:43:31 +040042 apiPublicData = "/api/public-data"
43 apiCreateApp = "/api/apps"
gio81246f02024-07-10 12:02:15 +040044 sessionCookie = "dodo-app-session"
45 userCtx = "user"
giob4a3a192024-08-19 09:55:47 +040046 initCommitMsg = "init"
gio9d66f322024-07-06 13:45:10 +040047)
48
gio23bdc1b2024-07-11 16:07:47 +040049type dodoAppTmplts struct {
giob4a3a192024-08-19 09:55:47 +040050 index *template.Template
51 appStatus *template.Template
52 commitStatus *template.Template
gio183e8342024-08-20 06:01:24 +040053 logs *template.Template
gio23bdc1b2024-07-11 16:07:47 +040054}
55
56func parseTemplatesDodoApp(fs embed.FS) (dodoAppTmplts, error) {
gio5e49bb62024-07-20 10:43:19 +040057 base, err := template.ParseFS(fs, "dodo-app-tmpl/base.html")
gio23bdc1b2024-07-11 16:07:47 +040058 if err != nil {
59 return dodoAppTmplts{}, err
60 }
gio5e49bb62024-07-20 10:43:19 +040061 parse := func(path string) (*template.Template, error) {
62 if b, err := base.Clone(); err != nil {
63 return nil, err
64 } else {
65 return b.ParseFS(fs, path)
66 }
67 }
68 index, err := parse("dodo-app-tmpl/index.html")
69 if err != nil {
70 return dodoAppTmplts{}, err
71 }
72 appStatus, err := parse("dodo-app-tmpl/app_status.html")
73 if err != nil {
74 return dodoAppTmplts{}, err
75 }
giob4a3a192024-08-19 09:55:47 +040076 commitStatus, err := parse("dodo-app-tmpl/commit_status.html")
77 if err != nil {
78 return dodoAppTmplts{}, err
79 }
gio183e8342024-08-20 06:01:24 +040080 logs, err := parse("dodo-app-tmpl/logs.html")
81 if err != nil {
82 return dodoAppTmplts{}, err
83 }
84 return dodoAppTmplts{index, appStatus, commitStatus, logs}, nil
gio23bdc1b2024-07-11 16:07:47 +040085}
86
gio0eaf2712024-04-14 13:08:46 +040087type DodoAppServer struct {
giocb34ad22024-07-11 08:01:13 +040088 l sync.Locker
89 st Store
gio11617ac2024-07-15 16:09:04 +040090 nf NetworkFilter
91 ug UserGetter
giocb34ad22024-07-11 08:01:13 +040092 port int
93 apiPort int
94 self string
gio11617ac2024-07-15 16:09:04 +040095 repoPublicAddr string
giocb34ad22024-07-11 08:01:13 +040096 sshKey string
97 gitRepoPublicKey string
98 client soft.Client
99 namespace string
100 envAppManagerAddr string
101 env installer.EnvConfig
102 nsc installer.NamespaceCreator
103 jc installer.JobCreator
104 workers map[string]map[string]struct{}
giod8ab4f52024-07-26 16:58:34 +0400105 appConfigs map[string]appConfig
gio23bdc1b2024-07-11 16:07:47 +0400106 tmplts dodoAppTmplts
gio5e49bb62024-07-20 10:43:19 +0400107 appTmpls AppTmplStore
giocafd4e62024-07-31 10:53:40 +0400108 external bool
109 fetchUsersAddr string
gio43b0f422024-08-21 10:40:13 +0400110 reconciler tasks.Reconciler
gio183e8342024-08-20 06:01:24 +0400111 logs map[string]string
giod8ab4f52024-07-26 16:58:34 +0400112}
113
114type appConfig struct {
115 Namespace string `json:"namespace"`
116 Network string `json:"network"`
gio0eaf2712024-04-14 13:08:46 +0400117}
118
gio33059762024-07-05 13:19:07 +0400119// TODO(gio): Initialize appNs on startup
gio0eaf2712024-04-14 13:08:46 +0400120func NewDodoAppServer(
gioa60f0de2024-07-08 10:49:48 +0400121 st Store,
gio11617ac2024-07-15 16:09:04 +0400122 nf NetworkFilter,
123 ug UserGetter,
gio0eaf2712024-04-14 13:08:46 +0400124 port int,
gioa60f0de2024-07-08 10:49:48 +0400125 apiPort int,
gio33059762024-07-05 13:19:07 +0400126 self string,
gio11617ac2024-07-15 16:09:04 +0400127 repoPublicAddr string,
gio0eaf2712024-04-14 13:08:46 +0400128 sshKey string,
gio33059762024-07-05 13:19:07 +0400129 gitRepoPublicKey string,
gio0eaf2712024-04-14 13:08:46 +0400130 client soft.Client,
131 namespace string,
giocb34ad22024-07-11 08:01:13 +0400132 envAppManagerAddr string,
gio33059762024-07-05 13:19:07 +0400133 nsc installer.NamespaceCreator,
giof8843412024-05-22 16:38:05 +0400134 jc installer.JobCreator,
gio0eaf2712024-04-14 13:08:46 +0400135 env installer.EnvConfig,
giocafd4e62024-07-31 10:53:40 +0400136 external bool,
137 fetchUsersAddr string,
gio43b0f422024-08-21 10:40:13 +0400138 reconciler tasks.Reconciler,
gio9d66f322024-07-06 13:45:10 +0400139) (*DodoAppServer, error) {
gio23bdc1b2024-07-11 16:07:47 +0400140 tmplts, err := parseTemplatesDodoApp(dodoAppTmplFS)
141 if err != nil {
142 return nil, err
143 }
gio5e49bb62024-07-20 10:43:19 +0400144 apps, err := fs.Sub(appTmplsFS, "app-tmpl")
145 if err != nil {
146 return nil, err
147 }
148 appTmpls, err := NewAppTmplStoreFS(apps)
149 if err != nil {
150 return nil, err
151 }
gio9d66f322024-07-06 13:45:10 +0400152 s := &DodoAppServer{
153 &sync.Mutex{},
gioa60f0de2024-07-08 10:49:48 +0400154 st,
gio11617ac2024-07-15 16:09:04 +0400155 nf,
156 ug,
gio0eaf2712024-04-14 13:08:46 +0400157 port,
gioa60f0de2024-07-08 10:49:48 +0400158 apiPort,
gio33059762024-07-05 13:19:07 +0400159 self,
gio11617ac2024-07-15 16:09:04 +0400160 repoPublicAddr,
gio0eaf2712024-04-14 13:08:46 +0400161 sshKey,
gio33059762024-07-05 13:19:07 +0400162 gitRepoPublicKey,
gio0eaf2712024-04-14 13:08:46 +0400163 client,
164 namespace,
giocb34ad22024-07-11 08:01:13 +0400165 envAppManagerAddr,
gio0eaf2712024-04-14 13:08:46 +0400166 env,
gio33059762024-07-05 13:19:07 +0400167 nsc,
giof8843412024-05-22 16:38:05 +0400168 jc,
gio266c04f2024-07-03 14:18:45 +0400169 map[string]map[string]struct{}{},
giod8ab4f52024-07-26 16:58:34 +0400170 map[string]appConfig{},
gio23bdc1b2024-07-11 16:07:47 +0400171 tmplts,
gio5e49bb62024-07-20 10:43:19 +0400172 appTmpls,
giocafd4e62024-07-31 10:53:40 +0400173 external,
174 fetchUsersAddr,
gio43b0f422024-08-21 10:40:13 +0400175 reconciler,
gio183e8342024-08-20 06:01:24 +0400176 map[string]string{},
gio0eaf2712024-04-14 13:08:46 +0400177 }
gioa60f0de2024-07-08 10:49:48 +0400178 config, err := client.GetRepo(ConfigRepoName)
gio9d66f322024-07-06 13:45:10 +0400179 if err != nil {
180 return nil, err
181 }
giod8ab4f52024-07-26 16:58:34 +0400182 r, err := config.Reader(appConfigsFile)
gio9d66f322024-07-06 13:45:10 +0400183 if err == nil {
184 defer r.Close()
giod8ab4f52024-07-26 16:58:34 +0400185 if err := json.NewDecoder(r).Decode(&s.appConfigs); err != nil {
gio9d66f322024-07-06 13:45:10 +0400186 return nil, err
187 }
188 } else if !errors.Is(err, fs.ErrNotExist) {
189 return nil, err
190 }
191 return s, nil
gio0eaf2712024-04-14 13:08:46 +0400192}
193
194func (s *DodoAppServer) Start() error {
gioa60f0de2024-07-08 10:49:48 +0400195 e := make(chan error)
196 go func() {
197 r := mux.NewRouter()
gio81246f02024-07-10 12:02:15 +0400198 r.Use(s.mwAuth)
gio1bf00802024-08-17 12:31:41 +0400199 r.PathPrefix(staticPath).Handler(cachingHandler{http.FileServer(http.FS(statAssets))})
gio81246f02024-07-10 12:02:15 +0400200 r.HandleFunc(logoutPath, s.handleLogout).Methods(http.MethodGet)
gio8fae3af2024-07-25 13:43:31 +0400201 r.HandleFunc(apiPublicData, s.handleAPIPublicData)
202 r.HandleFunc(apiCreateApp, s.handleAPICreateApp).Methods(http.MethodPost)
gio81246f02024-07-10 12:02:15 +0400203 r.HandleFunc("/{app-name}"+loginPath, s.handleLoginForm).Methods(http.MethodGet)
204 r.HandleFunc("/{app-name}"+loginPath, s.handleLogin).Methods(http.MethodPost)
gio183e8342024-08-20 06:01:24 +0400205 r.HandleFunc("/{app-name}/logs", s.handleAppLogs).Methods(http.MethodGet)
giob4a3a192024-08-19 09:55:47 +0400206 r.HandleFunc("/{app-name}/{hash}", s.handleAppCommit).Methods(http.MethodGet)
gio81246f02024-07-10 12:02:15 +0400207 r.HandleFunc("/{app-name}", s.handleAppStatus).Methods(http.MethodGet)
208 r.HandleFunc("/", s.handleStatus).Methods(http.MethodGet)
gio11617ac2024-07-15 16:09:04 +0400209 r.HandleFunc("/", s.handleCreateApp).Methods(http.MethodPost)
gioa60f0de2024-07-08 10:49:48 +0400210 e <- http.ListenAndServe(fmt.Sprintf(":%d", s.port), r)
211 }()
212 go func() {
213 r := mux.NewRouter()
gio8fae3af2024-07-25 13:43:31 +0400214 r.HandleFunc("/update", s.handleAPIUpdate)
215 r.HandleFunc("/api/apps/{app-name}/workers", s.handleAPIRegisterWorker).Methods(http.MethodPost)
216 r.HandleFunc("/api/add-admin-key", s.handleAPIAddAdminKey).Methods(http.MethodPost)
giocafd4e62024-07-31 10:53:40 +0400217 if !s.external {
218 r.HandleFunc("/api/sync-users", s.handleAPISyncUsers).Methods(http.MethodGet)
219 }
gioa60f0de2024-07-08 10:49:48 +0400220 e <- http.ListenAndServe(fmt.Sprintf(":%d", s.apiPort), r)
221 }()
giocafd4e62024-07-31 10:53:40 +0400222 if !s.external {
223 go func() {
Davit Tabidzea5ea5092024-08-01 15:28:09 +0400224 rand.Seed(uint64(time.Now().UnixNano()))
giocafd4e62024-07-31 10:53:40 +0400225 s.syncUsers()
226 // TODO(dtabidze): every sync delay should be randomized to avoid all client
227 // applications hitting memberships service at the same time.
228 // For every next sync new delay should be randomly generated from scratch.
229 // We can choose random delay from 1 to 2 minutes.
Davit Tabidzea5ea5092024-08-01 15:28:09 +0400230 // for range time.Tick(1 * time.Minute) {
231 // s.syncUsers()
232 // }
233 for {
234 delay := time.Duration(rand.Intn(60)+60) * time.Second
235 time.Sleep(delay)
giocafd4e62024-07-31 10:53:40 +0400236 s.syncUsers()
237 }
238 }()
239 }
gioa60f0de2024-07-08 10:49:48 +0400240 return <-e
241}
242
gio11617ac2024-07-15 16:09:04 +0400243type UserGetter interface {
244 Get(r *http.Request) string
gio8fae3af2024-07-25 13:43:31 +0400245 Encode(w http.ResponseWriter, user string) error
gio11617ac2024-07-15 16:09:04 +0400246}
247
248type externalUserGetter struct {
249 sc *securecookie.SecureCookie
250}
251
252func NewExternalUserGetter() UserGetter {
gio8fae3af2024-07-25 13:43:31 +0400253 return &externalUserGetter{securecookie.New(
254 securecookie.GenerateRandomKey(64),
255 securecookie.GenerateRandomKey(32),
256 )}
gio11617ac2024-07-15 16:09:04 +0400257}
258
259func (ug *externalUserGetter) Get(r *http.Request) string {
260 cookie, err := r.Cookie(sessionCookie)
261 if err != nil {
262 return ""
263 }
264 var user string
265 if err := ug.sc.Decode(sessionCookie, cookie.Value, &user); err != nil {
266 return ""
267 }
268 return user
269}
270
gio8fae3af2024-07-25 13:43:31 +0400271func (ug *externalUserGetter) Encode(w http.ResponseWriter, user string) error {
272 if encoded, err := ug.sc.Encode(sessionCookie, user); err == nil {
273 cookie := &http.Cookie{
274 Name: sessionCookie,
275 Value: encoded,
276 Path: "/",
277 Secure: true,
278 HttpOnly: true,
279 }
280 http.SetCookie(w, cookie)
281 return nil
282 } else {
283 return err
284 }
285}
286
gio11617ac2024-07-15 16:09:04 +0400287type internalUserGetter struct{}
288
289func NewInternalUserGetter() UserGetter {
290 return internalUserGetter{}
291}
292
293func (ug internalUserGetter) Get(r *http.Request) string {
294 return r.Header.Get("X-User")
295}
296
gio8fae3af2024-07-25 13:43:31 +0400297func (ug internalUserGetter) Encode(w http.ResponseWriter, user string) error {
298 return nil
299}
300
gio81246f02024-07-10 12:02:15 +0400301func (s *DodoAppServer) mwAuth(next http.Handler) http.Handler {
302 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gio8fae3af2024-07-25 13:43:31 +0400303 if strings.HasSuffix(r.URL.Path, loginPath) ||
304 strings.HasPrefix(r.URL.Path, logoutPath) ||
305 strings.HasPrefix(r.URL.Path, staticPath) ||
306 strings.HasPrefix(r.URL.Path, apiPublicData) ||
307 strings.HasPrefix(r.URL.Path, apiCreateApp) {
gio81246f02024-07-10 12:02:15 +0400308 next.ServeHTTP(w, r)
309 return
310 }
gio11617ac2024-07-15 16:09:04 +0400311 user := s.ug.Get(r)
312 if user == "" {
gio81246f02024-07-10 12:02:15 +0400313 vars := mux.Vars(r)
314 appName, ok := vars["app-name"]
315 if !ok || appName == "" {
316 http.Error(w, "missing app-name", http.StatusBadRequest)
317 return
318 }
319 http.Redirect(w, r, fmt.Sprintf("/%s%s", appName, loginPath), http.StatusSeeOther)
320 return
321 }
gio81246f02024-07-10 12:02:15 +0400322 next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), userCtx, user)))
323 })
324}
325
326func (s *DodoAppServer) handleLogout(w http.ResponseWriter, r *http.Request) {
gio8fae3af2024-07-25 13:43:31 +0400327 // TODO(gio): move to UserGetter
gio81246f02024-07-10 12:02:15 +0400328 http.SetCookie(w, &http.Cookie{
329 Name: sessionCookie,
330 Value: "",
331 Path: "/",
332 HttpOnly: true,
333 Secure: true,
334 })
335 http.Redirect(w, r, "/", http.StatusSeeOther)
336}
337
338func (s *DodoAppServer) handleLoginForm(w http.ResponseWriter, r *http.Request) {
339 vars := mux.Vars(r)
340 appName, ok := vars["app-name"]
341 if !ok || appName == "" {
342 http.Error(w, "missing app-name", http.StatusBadRequest)
343 return
344 }
345 fmt.Fprint(w, `
346<!DOCTYPE html>
347<html lang='en'>
348 <head>
349 <title>dodo: app - login</title>
350 <meta charset='utf-8'>
351 </head>
352 <body>
353 <form action="" method="POST">
354 <input type="password" placeholder="Password" name="password" required />
355 <button type="submit">Login</button>
356 </form>
357 </body>
358</html>
359`)
360}
361
362func (s *DodoAppServer) handleLogin(w http.ResponseWriter, r *http.Request) {
363 vars := mux.Vars(r)
364 appName, ok := vars["app-name"]
365 if !ok || appName == "" {
366 http.Error(w, "missing app-name", http.StatusBadRequest)
367 return
368 }
369 password := r.FormValue("password")
370 if password == "" {
371 http.Error(w, "missing password", http.StatusBadRequest)
372 return
373 }
374 user, err := s.st.GetAppOwner(appName)
375 if err != nil {
376 http.Error(w, err.Error(), http.StatusInternalServerError)
377 return
378 }
379 hashed, err := s.st.GetUserPassword(user)
380 if err != nil {
381 http.Error(w, err.Error(), http.StatusInternalServerError)
382 return
383 }
384 if err := bcrypt.CompareHashAndPassword(hashed, []byte(password)); err != nil {
385 http.Redirect(w, r, r.URL.Path, http.StatusSeeOther)
386 return
387 }
gio8fae3af2024-07-25 13:43:31 +0400388 if err := s.ug.Encode(w, user); err != nil {
389 http.Error(w, err.Error(), http.StatusInternalServerError)
390 return
gio81246f02024-07-10 12:02:15 +0400391 }
392 http.Redirect(w, r, fmt.Sprintf("/%s", appName), http.StatusSeeOther)
393}
394
giob4a3a192024-08-19 09:55:47 +0400395type navItem struct {
396 Name string
397 Address string
398}
399
gio23bdc1b2024-07-11 16:07:47 +0400400type statusData struct {
giob4a3a192024-08-19 09:55:47 +0400401 Navigation []navItem
402 Apps []string
403 Networks []installer.Network
404 Types []string
gio23bdc1b2024-07-11 16:07:47 +0400405}
406
gioa60f0de2024-07-08 10:49:48 +0400407func (s *DodoAppServer) handleStatus(w http.ResponseWriter, r *http.Request) {
gio81246f02024-07-10 12:02:15 +0400408 user := r.Context().Value(userCtx)
409 if user == nil {
410 http.Error(w, "unauthorized", http.StatusUnauthorized)
411 return
412 }
413 apps, err := s.st.GetUserApps(user.(string))
gioa60f0de2024-07-08 10:49:48 +0400414 if err != nil {
415 http.Error(w, err.Error(), http.StatusInternalServerError)
416 return
417 }
gio11617ac2024-07-15 16:09:04 +0400418 networks, err := s.getNetworks(user.(string))
419 if err != nil {
420 http.Error(w, err.Error(), http.StatusInternalServerError)
421 return
422 }
giob54db242024-07-30 18:49:33 +0400423 var types []string
424 for _, t := range s.appTmpls.Types() {
425 types = append(types, strings.Replace(t, "-", ":", 1))
426 }
giob4a3a192024-08-19 09:55:47 +0400427 n := []navItem{navItem{"Home", "/"}}
428 data := statusData{n, apps, networks, types}
gio23bdc1b2024-07-11 16:07:47 +0400429 if err := s.tmplts.index.Execute(w, data); err != nil {
430 http.Error(w, err.Error(), http.StatusInternalServerError)
431 return
gioa60f0de2024-07-08 10:49:48 +0400432 }
433}
434
gio5e49bb62024-07-20 10:43:19 +0400435type appStatusData struct {
giob4a3a192024-08-19 09:55:47 +0400436 Navigation []navItem
gio5e49bb62024-07-20 10:43:19 +0400437 Name string
438 GitCloneCommand string
giob4a3a192024-08-19 09:55:47 +0400439 Commits []CommitMeta
gio183e8342024-08-20 06:01:24 +0400440 LastCommit resourceData
gio5e49bb62024-07-20 10:43:19 +0400441}
442
gioa60f0de2024-07-08 10:49:48 +0400443func (s *DodoAppServer) handleAppStatus(w http.ResponseWriter, r *http.Request) {
444 vars := mux.Vars(r)
445 appName, ok := vars["app-name"]
446 if !ok || appName == "" {
447 http.Error(w, "missing app-name", http.StatusBadRequest)
448 return
449 }
gio94904702024-07-26 16:58:34 +0400450 u := r.Context().Value(userCtx)
451 if u == nil {
452 http.Error(w, "unauthorized", http.StatusUnauthorized)
453 return
454 }
455 user, ok := u.(string)
456 if !ok {
457 http.Error(w, "could not get user", http.StatusInternalServerError)
458 return
459 }
460 owner, err := s.st.GetAppOwner(appName)
461 if err != nil {
462 http.Error(w, err.Error(), http.StatusInternalServerError)
463 return
464 }
465 if owner != user {
466 http.Error(w, "unauthorized", http.StatusUnauthorized)
467 return
468 }
gioa60f0de2024-07-08 10:49:48 +0400469 commits, err := s.st.GetCommitHistory(appName)
470 if err != nil {
471 http.Error(w, err.Error(), http.StatusInternalServerError)
472 return
473 }
gio183e8342024-08-20 06:01:24 +0400474 var lastCommitResources resourceData
475 if len(commits) > 0 {
476 lastCommit, err := s.st.GetCommit(commits[len(commits)-1].Hash)
477 if err != nil {
478 http.Error(w, err.Error(), http.StatusInternalServerError)
479 return
480 }
481 r, err := extractResourceData(lastCommit.Resources.Helm)
482 if err != nil {
483 http.Error(w, err.Error(), http.StatusInternalServerError)
484 return
485 }
486 lastCommitResources = r
487 }
gio5e49bb62024-07-20 10:43:19 +0400488 data := appStatusData{
giob4a3a192024-08-19 09:55:47 +0400489 Navigation: []navItem{
490 navItem{"Home", "/"},
491 navItem{appName, "/" + appName},
492 },
gio5e49bb62024-07-20 10:43:19 +0400493 Name: appName,
494 GitCloneCommand: fmt.Sprintf("git clone %s/%s\n\n\n", s.repoPublicAddr, appName),
495 Commits: commits,
gio183e8342024-08-20 06:01:24 +0400496 LastCommit: lastCommitResources,
gio5e49bb62024-07-20 10:43:19 +0400497 }
498 if err := s.tmplts.appStatus.Execute(w, data); err != nil {
499 http.Error(w, err.Error(), http.StatusInternalServerError)
500 return
gioa60f0de2024-07-08 10:49:48 +0400501 }
gio0eaf2712024-04-14 13:08:46 +0400502}
503
giob4a3a192024-08-19 09:55:47 +0400504type volume struct {
505 Name string
506 Size string
507}
508
509type postgresql struct {
510 Name string
511 Version string
512 Volume string
513}
514
515type ingress struct {
516 Host string
517}
518
519type resourceData struct {
520 Volume []volume
521 PostgreSQL []postgresql
522 Ingress []ingress
523}
524
525type commitStatusData struct {
526 Navigation []navItem
527 AppName string
528 Commit Commit
529 Resources resourceData
530}
531
532func (s *DodoAppServer) handleAppCommit(w http.ResponseWriter, r *http.Request) {
533 vars := mux.Vars(r)
534 appName, ok := vars["app-name"]
535 if !ok || appName == "" {
536 http.Error(w, "missing app-name", http.StatusBadRequest)
537 return
538 }
539 hash, ok := vars["hash"]
540 if !ok || appName == "" {
541 http.Error(w, "missing app-name", http.StatusBadRequest)
542 return
543 }
544 u := r.Context().Value(userCtx)
545 if u == nil {
546 http.Error(w, "unauthorized", http.StatusUnauthorized)
547 return
548 }
549 user, ok := u.(string)
550 if !ok {
551 http.Error(w, "could not get user", http.StatusInternalServerError)
552 return
553 }
554 owner, err := s.st.GetAppOwner(appName)
555 if err != nil {
556 http.Error(w, err.Error(), http.StatusInternalServerError)
557 return
558 }
559 if owner != user {
560 http.Error(w, "unauthorized", http.StatusUnauthorized)
561 return
562 }
563 commit, err := s.st.GetCommit(hash)
564 if err != nil {
565 // TODO(gio): not-found ?
566 http.Error(w, err.Error(), http.StatusInternalServerError)
567 return
568 }
569 var res strings.Builder
570 if err := json.NewEncoder(&res).Encode(commit.Resources.Helm); err != nil {
571 http.Error(w, err.Error(), http.StatusInternalServerError)
572 return
573 }
574 resData, err := extractResourceData(commit.Resources.Helm)
575 if err != nil {
576 http.Error(w, err.Error(), http.StatusInternalServerError)
577 return
578 }
579 data := commitStatusData{
580 Navigation: []navItem{
581 navItem{"Home", "/"},
582 navItem{appName, "/" + appName},
583 navItem{hash, "/" + appName + "/" + hash},
584 },
585 AppName: appName,
586 Commit: commit,
587 Resources: resData,
588 }
589 if err := s.tmplts.commitStatus.Execute(w, data); err != nil {
590 http.Error(w, err.Error(), http.StatusInternalServerError)
591 return
592 }
593}
594
gio183e8342024-08-20 06:01:24 +0400595type logData struct {
596 Navigation []navItem
597 AppName string
598 Logs template.HTML
599}
600
601func (s *DodoAppServer) handleAppLogs(w http.ResponseWriter, r *http.Request) {
602 vars := mux.Vars(r)
603 appName, ok := vars["app-name"]
604 if !ok || appName == "" {
605 http.Error(w, "missing app-name", http.StatusBadRequest)
606 return
607 }
608 u := r.Context().Value(userCtx)
609 if u == nil {
610 http.Error(w, "unauthorized", http.StatusUnauthorized)
611 return
612 }
613 user, ok := u.(string)
614 if !ok {
615 http.Error(w, "could not get user", http.StatusInternalServerError)
616 return
617 }
618 owner, err := s.st.GetAppOwner(appName)
619 if err != nil {
620 http.Error(w, err.Error(), http.StatusInternalServerError)
621 return
622 }
623 if owner != user {
624 http.Error(w, "unauthorized", http.StatusUnauthorized)
625 return
626 }
627 data := logData{
628 Navigation: []navItem{
629 navItem{"Home", "/"},
630 navItem{appName, "/" + appName},
631 navItem{"Logs", "/" + appName + "/logs"},
632 },
633 AppName: appName,
634 Logs: template.HTML(strings.ReplaceAll(s.logs[appName], "\n", "<br/>")),
635 }
636 if err := s.tmplts.logs.Execute(w, data); err != nil {
637 fmt.Println(err)
638 http.Error(w, err.Error(), http.StatusInternalServerError)
639 return
640 }
641}
642
gio81246f02024-07-10 12:02:15 +0400643type apiUpdateReq struct {
gio266c04f2024-07-03 14:18:45 +0400644 Ref string `json:"ref"`
645 Repository struct {
646 Name string `json:"name"`
647 } `json:"repository"`
gioe2e31e12024-08-18 08:20:56 +0400648 After string `json:"after"`
649 Commits []struct {
650 Id string `json:"id"`
651 Message string `json:"message"`
652 } `json:"commits"`
gio0eaf2712024-04-14 13:08:46 +0400653}
654
gio8fae3af2024-07-25 13:43:31 +0400655func (s *DodoAppServer) handleAPIUpdate(w http.ResponseWriter, r *http.Request) {
gio0eaf2712024-04-14 13:08:46 +0400656 fmt.Println("update")
gio81246f02024-07-10 12:02:15 +0400657 var req apiUpdateReq
gio0eaf2712024-04-14 13:08:46 +0400658 var contents strings.Builder
659 io.Copy(&contents, r.Body)
660 c := contents.String()
661 fmt.Println(c)
662 if err := json.NewDecoder(strings.NewReader(c)).Decode(&req); err != nil {
gio23bdc1b2024-07-11 16:07:47 +0400663 http.Error(w, err.Error(), http.StatusBadRequest)
gio0eaf2712024-04-14 13:08:46 +0400664 return
665 }
gioa60f0de2024-07-08 10:49:48 +0400666 if req.Ref != "refs/heads/master" || req.Repository.Name == ConfigRepoName {
gio0eaf2712024-04-14 13:08:46 +0400667 return
668 }
gioa60f0de2024-07-08 10:49:48 +0400669 // TODO(gio): Create commit record on app init as well
gio0eaf2712024-04-14 13:08:46 +0400670 go func() {
gio11617ac2024-07-15 16:09:04 +0400671 owner, err := s.st.GetAppOwner(req.Repository.Name)
672 if err != nil {
673 return
674 }
675 networks, err := s.getNetworks(owner)
giocb34ad22024-07-11 08:01:13 +0400676 if err != nil {
677 return
678 }
gio94904702024-07-26 16:58:34 +0400679 apps := installer.NewInMemoryAppRepository(installer.CreateAllApps())
680 instanceAppStatus, err := installer.FindEnvApp(apps, "dodo-app-instance-status")
681 if err != nil {
682 return
683 }
gioe2e31e12024-08-18 08:20:56 +0400684 found := false
685 commitMsg := ""
686 for _, c := range req.Commits {
687 if c.Id == req.After {
688 found = true
689 commitMsg = c.Message
690 break
gioa60f0de2024-07-08 10:49:48 +0400691 }
692 }
gioe2e31e12024-08-18 08:20:56 +0400693 if !found {
694 fmt.Printf("Error: could not find commit message")
695 return
696 }
giob4a3a192024-08-19 09:55:47 +0400697 resources, err := s.updateDodoApp(instanceAppStatus, req.Repository.Name, s.appConfigs[req.Repository.Name].Namespace, networks)
698 if err = s.createCommit(req.Repository.Name, req.After, commitMsg, err, resources); err != nil {
gio12e887d2024-08-18 16:09:47 +0400699 fmt.Printf("Error: %s\n", err.Error())
gioe2e31e12024-08-18 08:20:56 +0400700 return
701 }
gioa60f0de2024-07-08 10:49:48 +0400702 for addr, _ := range s.workers[req.Repository.Name] {
703 go func() {
704 // TODO(gio): make port configurable
705 http.Get(fmt.Sprintf("http://%s/update", addr))
706 }()
gio0eaf2712024-04-14 13:08:46 +0400707 }
708 }()
gio0eaf2712024-04-14 13:08:46 +0400709}
710
gio81246f02024-07-10 12:02:15 +0400711type apiRegisterWorkerReq struct {
gio0eaf2712024-04-14 13:08:46 +0400712 Address string `json:"address"`
gio183e8342024-08-20 06:01:24 +0400713 Logs string `json:"logs"`
gio0eaf2712024-04-14 13:08:46 +0400714}
715
gio8fae3af2024-07-25 13:43:31 +0400716func (s *DodoAppServer) handleAPIRegisterWorker(w http.ResponseWriter, r *http.Request) {
gioa60f0de2024-07-08 10:49:48 +0400717 vars := mux.Vars(r)
718 appName, ok := vars["app-name"]
719 if !ok || appName == "" {
720 http.Error(w, "missing app-name", http.StatusBadRequest)
721 return
722 }
gio81246f02024-07-10 12:02:15 +0400723 var req apiRegisterWorkerReq
gio0eaf2712024-04-14 13:08:46 +0400724 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
725 http.Error(w, err.Error(), http.StatusInternalServerError)
726 return
727 }
gioa60f0de2024-07-08 10:49:48 +0400728 if _, ok := s.workers[appName]; !ok {
729 s.workers[appName] = map[string]struct{}{}
gio266c04f2024-07-03 14:18:45 +0400730 }
gioa60f0de2024-07-08 10:49:48 +0400731 s.workers[appName][req.Address] = struct{}{}
gio183e8342024-08-20 06:01:24 +0400732 s.logs[appName] = req.Logs
gio0eaf2712024-04-14 13:08:46 +0400733}
734
gio11617ac2024-07-15 16:09:04 +0400735func (s *DodoAppServer) handleCreateApp(w http.ResponseWriter, r *http.Request) {
736 u := r.Context().Value(userCtx)
737 if u == nil {
738 http.Error(w, "unauthorized", http.StatusUnauthorized)
739 return
740 }
741 user, ok := u.(string)
742 if !ok {
743 http.Error(w, "could not get user", http.StatusInternalServerError)
744 return
745 }
746 network := r.FormValue("network")
747 if network == "" {
748 http.Error(w, "missing network", http.StatusBadRequest)
749 return
750 }
gio5e49bb62024-07-20 10:43:19 +0400751 subdomain := r.FormValue("subdomain")
752 if subdomain == "" {
753 http.Error(w, "missing subdomain", http.StatusBadRequest)
754 return
755 }
756 appType := r.FormValue("type")
757 if appType == "" {
758 http.Error(w, "missing type", http.StatusBadRequest)
759 return
760 }
gio11617ac2024-07-15 16:09:04 +0400761 g := installer.NewFixedLengthRandomNameGenerator(3)
762 appName, err := g.Generate()
763 if err != nil {
764 http.Error(w, err.Error(), http.StatusInternalServerError)
765 return
766 }
767 if ok, err := s.client.UserExists(user); err != nil {
768 http.Error(w, err.Error(), http.StatusInternalServerError)
769 return
770 } else if !ok {
giocafd4e62024-07-31 10:53:40 +0400771 http.Error(w, "user sync has not finished, please try again in few minutes", http.StatusFailedDependency)
772 return
gio11617ac2024-07-15 16:09:04 +0400773 }
giocafd4e62024-07-31 10:53:40 +0400774 if err := s.st.CreateUser(user, nil, network); err != nil && !errors.Is(err, ErrorAlreadyExists) {
gio11617ac2024-07-15 16:09:04 +0400775 http.Error(w, err.Error(), http.StatusInternalServerError)
776 return
777 }
778 if err := s.st.CreateApp(appName, user); err != nil {
779 http.Error(w, err.Error(), http.StatusInternalServerError)
780 return
781 }
giod8ab4f52024-07-26 16:58:34 +0400782 if err := s.createApp(user, appName, appType, network, subdomain); err != nil {
gio11617ac2024-07-15 16:09:04 +0400783 http.Error(w, err.Error(), http.StatusInternalServerError)
784 return
785 }
786 http.Redirect(w, r, fmt.Sprintf("/%s", appName), http.StatusSeeOther)
787}
788
gio81246f02024-07-10 12:02:15 +0400789type apiCreateAppReq struct {
gio5e49bb62024-07-20 10:43:19 +0400790 AppType string `json:"type"`
gio33059762024-07-05 13:19:07 +0400791 AdminPublicKey string `json:"adminPublicKey"`
gio11617ac2024-07-15 16:09:04 +0400792 Network string `json:"network"`
gio5e49bb62024-07-20 10:43:19 +0400793 Subdomain string `json:"subdomain"`
gio33059762024-07-05 13:19:07 +0400794}
795
gio81246f02024-07-10 12:02:15 +0400796type apiCreateAppResp struct {
797 AppName string `json:"appName"`
798 Password string `json:"password"`
gio33059762024-07-05 13:19:07 +0400799}
800
gio8fae3af2024-07-25 13:43:31 +0400801func (s *DodoAppServer) handleAPICreateApp(w http.ResponseWriter, r *http.Request) {
giod8ab4f52024-07-26 16:58:34 +0400802 w.Header().Set("Access-Control-Allow-Origin", "*")
gio81246f02024-07-10 12:02:15 +0400803 var req apiCreateAppReq
gio33059762024-07-05 13:19:07 +0400804 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
805 http.Error(w, err.Error(), http.StatusBadRequest)
806 return
807 }
808 g := installer.NewFixedLengthRandomNameGenerator(3)
809 appName, err := g.Generate()
810 if err != nil {
811 http.Error(w, err.Error(), http.StatusInternalServerError)
812 return
813 }
gio11617ac2024-07-15 16:09:04 +0400814 user, err := s.client.FindUser(req.AdminPublicKey)
gio81246f02024-07-10 12:02:15 +0400815 if err != nil {
gio33059762024-07-05 13:19:07 +0400816 http.Error(w, err.Error(), http.StatusInternalServerError)
817 return
818 }
gio11617ac2024-07-15 16:09:04 +0400819 if user != "" {
820 http.Error(w, "public key already registered", http.StatusBadRequest)
821 return
822 }
823 user = appName
824 if err := s.client.AddUser(user, req.AdminPublicKey); err != nil {
825 http.Error(w, err.Error(), http.StatusInternalServerError)
826 return
827 }
828 password := generatePassword()
829 hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
830 if err != nil {
831 http.Error(w, err.Error(), http.StatusInternalServerError)
832 return
833 }
giocafd4e62024-07-31 10:53:40 +0400834 if err := s.st.CreateUser(user, hashed, req.Network); err != nil {
gio11617ac2024-07-15 16:09:04 +0400835 http.Error(w, err.Error(), http.StatusInternalServerError)
836 return
837 }
838 if err := s.st.CreateApp(appName, user); err != nil {
839 http.Error(w, err.Error(), http.StatusInternalServerError)
840 return
841 }
giod8ab4f52024-07-26 16:58:34 +0400842 if err := s.createApp(user, appName, req.AppType, req.Network, req.Subdomain); err != nil {
gio11617ac2024-07-15 16:09:04 +0400843 http.Error(w, err.Error(), http.StatusInternalServerError)
844 return
845 }
gio81246f02024-07-10 12:02:15 +0400846 resp := apiCreateAppResp{
847 AppName: appName,
848 Password: password,
849 }
gio33059762024-07-05 13:19:07 +0400850 if err := json.NewEncoder(w).Encode(resp); err != nil {
851 http.Error(w, err.Error(), http.StatusInternalServerError)
852 return
853 }
854}
855
giod8ab4f52024-07-26 16:58:34 +0400856func (s *DodoAppServer) isNetworkUseAllowed(network string) bool {
giocafd4e62024-07-31 10:53:40 +0400857 if !s.external {
giod8ab4f52024-07-26 16:58:34 +0400858 return true
859 }
860 for _, cfg := range s.appConfigs {
861 if strings.ToLower(cfg.Network) == network {
862 return false
863 }
864 }
865 return true
866}
867
868func (s *DodoAppServer) createApp(user, appName, appType, network, subdomain string) error {
gio9d66f322024-07-06 13:45:10 +0400869 s.l.Lock()
870 defer s.l.Unlock()
gio33059762024-07-05 13:19:07 +0400871 fmt.Printf("Creating app: %s\n", appName)
giod8ab4f52024-07-26 16:58:34 +0400872 network = strings.ToLower(network)
873 if !s.isNetworkUseAllowed(network) {
874 return fmt.Errorf("network already used: %s", network)
875 }
gio33059762024-07-05 13:19:07 +0400876 if ok, err := s.client.RepoExists(appName); err != nil {
gio11617ac2024-07-15 16:09:04 +0400877 return err
gio33059762024-07-05 13:19:07 +0400878 } else if ok {
gio11617ac2024-07-15 16:09:04 +0400879 return nil
gioa60f0de2024-07-08 10:49:48 +0400880 }
gio5e49bb62024-07-20 10:43:19 +0400881 networks, err := s.getNetworks(user)
882 if err != nil {
883 return err
884 }
giod8ab4f52024-07-26 16:58:34 +0400885 n, ok := installer.NetworkMap(networks)[network]
gio5e49bb62024-07-20 10:43:19 +0400886 if !ok {
887 return fmt.Errorf("network not found: %s\n", network)
888 }
gio33059762024-07-05 13:19:07 +0400889 if err := s.client.AddRepository(appName); err != nil {
gio11617ac2024-07-15 16:09:04 +0400890 return err
gio33059762024-07-05 13:19:07 +0400891 }
892 appRepo, err := s.client.GetRepo(appName)
893 if err != nil {
gio11617ac2024-07-15 16:09:04 +0400894 return err
gio33059762024-07-05 13:19:07 +0400895 }
giob4a3a192024-08-19 09:55:47 +0400896 commit, err := s.initRepo(appRepo, appType, n, subdomain)
897 if err != nil {
gio11617ac2024-07-15 16:09:04 +0400898 return err
gio33059762024-07-05 13:19:07 +0400899 }
900 apps := installer.NewInMemoryAppRepository(installer.CreateAllApps())
gio94904702024-07-26 16:58:34 +0400901 instanceApp, err := installer.FindEnvApp(apps, "dodo-app-instance")
902 if err != nil {
903 return err
904 }
905 instanceAppStatus, err := installer.FindEnvApp(apps, "dodo-app-instance-status")
gio33059762024-07-05 13:19:07 +0400906 if err != nil {
gio11617ac2024-07-15 16:09:04 +0400907 return err
gio33059762024-07-05 13:19:07 +0400908 }
909 suffixGen := installer.NewFixedLengthRandomSuffixGenerator(3)
910 suffix, err := suffixGen.Generate()
911 if err != nil {
gio11617ac2024-07-15 16:09:04 +0400912 return err
gio33059762024-07-05 13:19:07 +0400913 }
gio94904702024-07-26 16:58:34 +0400914 namespace := fmt.Sprintf("%s%s%s", s.env.NamespacePrefix, instanceApp.Namespace(), suffix)
giod8ab4f52024-07-26 16:58:34 +0400915 s.appConfigs[appName] = appConfig{namespace, network}
giob4a3a192024-08-19 09:55:47 +0400916 resources, err := s.updateDodoApp(instanceAppStatus, appName, namespace, networks)
917 if err != nil {
918 return err
919 }
920 if err = s.createCommit(appName, commit, initCommitMsg, err, resources); err != nil {
921 fmt.Printf("Error: %s\n", err.Error())
gio11617ac2024-07-15 16:09:04 +0400922 return err
gio33059762024-07-05 13:19:07 +0400923 }
giod8ab4f52024-07-26 16:58:34 +0400924 configRepo, err := s.client.GetRepo(ConfigRepoName)
gio33059762024-07-05 13:19:07 +0400925 if err != nil {
gio11617ac2024-07-15 16:09:04 +0400926 return err
gio33059762024-07-05 13:19:07 +0400927 }
928 hf := installer.NewGitHelmFetcher()
giod8ab4f52024-07-26 16:58:34 +0400929 m, err := installer.NewAppManager(configRepo, s.nsc, s.jc, hf, "/")
gio33059762024-07-05 13:19:07 +0400930 if err != nil {
gio11617ac2024-07-15 16:09:04 +0400931 return err
gio33059762024-07-05 13:19:07 +0400932 }
giob4a3a192024-08-19 09:55:47 +0400933 _, err = configRepo.Do(func(fs soft.RepoFS) (string, error) {
giod8ab4f52024-07-26 16:58:34 +0400934 w, err := fs.Writer(appConfigsFile)
gio9d66f322024-07-06 13:45:10 +0400935 if err != nil {
936 return "", err
937 }
938 defer w.Close()
giod8ab4f52024-07-26 16:58:34 +0400939 if err := json.NewEncoder(w).Encode(s.appConfigs); err != nil {
gio9d66f322024-07-06 13:45:10 +0400940 return "", err
941 }
942 if _, err := m.Install(
gio94904702024-07-26 16:58:34 +0400943 instanceApp,
gio9d66f322024-07-06 13:45:10 +0400944 appName,
945 "/"+appName,
946 namespace,
947 map[string]any{
948 "repoAddr": s.client.GetRepoAddress(appName),
949 "repoHost": strings.Split(s.client.Address(), ":")[0],
950 "gitRepoPublicKey": s.gitRepoPublicKey,
951 },
952 installer.WithConfig(&s.env),
gio23bdc1b2024-07-11 16:07:47 +0400953 installer.WithNoNetworks(),
gio9d66f322024-07-06 13:45:10 +0400954 installer.WithNoPublish(),
955 installer.WithNoLock(),
956 ); err != nil {
957 return "", err
958 }
959 return fmt.Sprintf("Installed app: %s", appName), nil
giob4a3a192024-08-19 09:55:47 +0400960 })
961 if err != nil {
gio11617ac2024-07-15 16:09:04 +0400962 return err
gio33059762024-07-05 13:19:07 +0400963 }
964 cfg, err := m.FindInstance(appName)
965 if err != nil {
gio11617ac2024-07-15 16:09:04 +0400966 return err
gio33059762024-07-05 13:19:07 +0400967 }
968 fluxKeys, ok := cfg.Input["fluxKeys"]
969 if !ok {
gio11617ac2024-07-15 16:09:04 +0400970 return fmt.Errorf("Fluxcd keys not found")
gio33059762024-07-05 13:19:07 +0400971 }
972 fluxPublicKey, ok := fluxKeys.(map[string]any)["public"]
973 if !ok {
gio11617ac2024-07-15 16:09:04 +0400974 return fmt.Errorf("Fluxcd keys not found")
gio33059762024-07-05 13:19:07 +0400975 }
976 if ok, err := s.client.UserExists("fluxcd"); err != nil {
gio11617ac2024-07-15 16:09:04 +0400977 return err
gio33059762024-07-05 13:19:07 +0400978 } else if ok {
979 if err := s.client.AddPublicKey("fluxcd", fluxPublicKey.(string)); err != nil {
gio11617ac2024-07-15 16:09:04 +0400980 return err
gio33059762024-07-05 13:19:07 +0400981 }
982 } else {
983 if err := s.client.AddUser("fluxcd", fluxPublicKey.(string)); err != nil {
gio11617ac2024-07-15 16:09:04 +0400984 return err
gio33059762024-07-05 13:19:07 +0400985 }
986 }
987 if err := s.client.AddReadOnlyCollaborator(appName, "fluxcd"); err != nil {
gio11617ac2024-07-15 16:09:04 +0400988 return err
gio33059762024-07-05 13:19:07 +0400989 }
990 if err := s.client.AddWebhook(appName, fmt.Sprintf("http://%s/update", s.self), "--active=true", "--events=push", "--content-type=json"); err != nil {
gio11617ac2024-07-15 16:09:04 +0400991 return err
gio33059762024-07-05 13:19:07 +0400992 }
gio81246f02024-07-10 12:02:15 +0400993 if err := s.client.AddReadWriteCollaborator(appName, user); err != nil {
gio11617ac2024-07-15 16:09:04 +0400994 return err
gio33059762024-07-05 13:19:07 +0400995 }
gio2ccb6e32024-08-15 12:01:33 +0400996 if !s.external {
997 go func() {
998 users, err := s.client.GetAllUsers()
999 if err != nil {
1000 fmt.Println(err)
1001 return
1002 }
1003 for _, user := range users {
1004 // TODO(gio): fluxcd should have only read access
1005 if err := s.client.AddReadWriteCollaborator(appName, user); err != nil {
1006 fmt.Println(err)
1007 }
1008 }
1009 }()
1010 }
gio43b0f422024-08-21 10:40:13 +04001011 ctx, _ := context.WithTimeout(context.Background(), 2*time.Minute)
1012 go s.reconciler.Reconcile(ctx, s.namespace, "config")
gio11617ac2024-07-15 16:09:04 +04001013 return nil
gio33059762024-07-05 13:19:07 +04001014}
1015
gio81246f02024-07-10 12:02:15 +04001016type apiAddAdminKeyReq struct {
gio70be3e52024-06-26 18:27:19 +04001017 Public string `json:"public"`
1018}
1019
gio8fae3af2024-07-25 13:43:31 +04001020func (s *DodoAppServer) handleAPIAddAdminKey(w http.ResponseWriter, r *http.Request) {
gio81246f02024-07-10 12:02:15 +04001021 var req apiAddAdminKeyReq
gio70be3e52024-06-26 18:27:19 +04001022 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
1023 http.Error(w, err.Error(), http.StatusBadRequest)
1024 return
1025 }
1026 if err := s.client.AddPublicKey("admin", req.Public); err != nil {
1027 http.Error(w, err.Error(), http.StatusInternalServerError)
1028 return
1029 }
1030}
1031
gio94904702024-07-26 16:58:34 +04001032type dodoAppRendered struct {
1033 App struct {
1034 Ingress struct {
1035 Network string `json:"network"`
1036 Subdomain string `json:"subdomain"`
1037 } `json:"ingress"`
1038 } `json:"app"`
1039 Input struct {
1040 AppId string `json:"appId"`
1041 } `json:"input"`
1042}
1043
gio43b0f422024-08-21 10:40:13 +04001044func (s *DodoAppServer) updateDodoApp(
1045 appStatus installer.EnvApp,
1046 name, namespace string,
1047 networks []installer.Network,
1048) (installer.ReleaseResources, error) {
gio33059762024-07-05 13:19:07 +04001049 repo, err := s.client.GetRepo(name)
gio0eaf2712024-04-14 13:08:46 +04001050 if err != nil {
giob4a3a192024-08-19 09:55:47 +04001051 return installer.ReleaseResources{}, err
gio0eaf2712024-04-14 13:08:46 +04001052 }
giof8843412024-05-22 16:38:05 +04001053 hf := installer.NewGitHelmFetcher()
gio33059762024-07-05 13:19:07 +04001054 m, err := installer.NewAppManager(repo, s.nsc, s.jc, hf, "/.dodo")
gio0eaf2712024-04-14 13:08:46 +04001055 if err != nil {
giob4a3a192024-08-19 09:55:47 +04001056 return installer.ReleaseResources{}, err
gio0eaf2712024-04-14 13:08:46 +04001057 }
1058 appCfg, err := soft.ReadFile(repo, "app.cue")
gio0eaf2712024-04-14 13:08:46 +04001059 if err != nil {
giob4a3a192024-08-19 09:55:47 +04001060 return installer.ReleaseResources{}, err
gio0eaf2712024-04-14 13:08:46 +04001061 }
1062 app, err := installer.NewDodoApp(appCfg)
1063 if err != nil {
giob4a3a192024-08-19 09:55:47 +04001064 return installer.ReleaseResources{}, err
gio0eaf2712024-04-14 13:08:46 +04001065 }
giof8843412024-05-22 16:38:05 +04001066 lg := installer.GitRepositoryLocalChartGenerator{"app", namespace}
giob4a3a192024-08-19 09:55:47 +04001067 var ret installer.ReleaseResources
1068 if _, err := repo.Do(func(r soft.RepoFS) (string, error) {
1069 ret, err = m.Install(
gio94904702024-07-26 16:58:34 +04001070 app,
1071 "app",
1072 "/.dodo/app",
1073 namespace,
1074 map[string]any{
1075 "repoAddr": repo.FullAddress(),
1076 "managerAddr": fmt.Sprintf("http://%s", s.self),
1077 "appId": name,
1078 "sshPrivateKey": s.sshKey,
1079 },
1080 installer.WithNoPull(),
1081 installer.WithNoPublish(),
1082 installer.WithConfig(&s.env),
1083 installer.WithNetworks(networks),
1084 installer.WithLocalChartGenerator(lg),
1085 installer.WithNoLock(),
1086 )
1087 if err != nil {
1088 return "", err
1089 }
1090 var rendered dodoAppRendered
giob4a3a192024-08-19 09:55:47 +04001091 if err := json.NewDecoder(bytes.NewReader(ret.RenderedRaw)).Decode(&rendered); err != nil {
gio94904702024-07-26 16:58:34 +04001092 return "", nil
1093 }
1094 if _, err := m.Install(
1095 appStatus,
1096 "status",
1097 "/.dodo/status",
1098 s.namespace,
1099 map[string]any{
1100 "appName": rendered.Input.AppId,
1101 "network": rendered.App.Ingress.Network,
1102 "appSubdomain": rendered.App.Ingress.Subdomain,
1103 },
1104 installer.WithNoPull(),
1105 installer.WithNoPublish(),
1106 installer.WithConfig(&s.env),
1107 installer.WithNetworks(networks),
1108 installer.WithLocalChartGenerator(lg),
1109 installer.WithNoLock(),
1110 ); err != nil {
1111 return "", err
1112 }
1113 return "install app", nil
1114 },
1115 soft.WithCommitToBranch("dodo"),
1116 soft.WithForce(),
giob4a3a192024-08-19 09:55:47 +04001117 ); err != nil {
1118 return installer.ReleaseResources{}, err
1119 }
gio43b0f422024-08-21 10:40:13 +04001120 ctx, _ := context.WithTimeout(context.Background(), 2*time.Minute)
1121 go s.reconciler.Reconcile(ctx, namespace, "app")
giob4a3a192024-08-19 09:55:47 +04001122 return ret, nil
gio0eaf2712024-04-14 13:08:46 +04001123}
gio33059762024-07-05 13:19:07 +04001124
giob4a3a192024-08-19 09:55:47 +04001125func (s *DodoAppServer) initRepo(repo soft.RepoIO, appType string, network installer.Network, subdomain string) (string, error) {
giob54db242024-07-30 18:49:33 +04001126 appType = strings.Replace(appType, ":", "-", 1)
gio5e49bb62024-07-20 10:43:19 +04001127 appTmpl, err := s.appTmpls.Find(appType)
1128 if err != nil {
giob4a3a192024-08-19 09:55:47 +04001129 return "", err
gio33059762024-07-05 13:19:07 +04001130 }
gio33059762024-07-05 13:19:07 +04001131 return repo.Do(func(fs soft.RepoFS) (string, error) {
gio5e49bb62024-07-20 10:43:19 +04001132 if err := appTmpl.Render(network, subdomain, repo); err != nil {
1133 return "", err
gio33059762024-07-05 13:19:07 +04001134 }
giob4a3a192024-08-19 09:55:47 +04001135 return initCommitMsg, nil
gio33059762024-07-05 13:19:07 +04001136 })
1137}
gio81246f02024-07-10 12:02:15 +04001138
1139func generatePassword() string {
1140 return "foo"
1141}
giocb34ad22024-07-11 08:01:13 +04001142
gio11617ac2024-07-15 16:09:04 +04001143func (s *DodoAppServer) getNetworks(user string) ([]installer.Network, error) {
gio23bdc1b2024-07-11 16:07:47 +04001144 addr := fmt.Sprintf("%s/api/networks", s.envAppManagerAddr)
giocb34ad22024-07-11 08:01:13 +04001145 resp, err := http.Get(addr)
1146 if err != nil {
1147 return nil, err
1148 }
gio23bdc1b2024-07-11 16:07:47 +04001149 networks := []installer.Network{}
1150 if json.NewDecoder(resp.Body).Decode(&networks); err != nil {
giocb34ad22024-07-11 08:01:13 +04001151 return nil, err
1152 }
gio11617ac2024-07-15 16:09:04 +04001153 return s.nf.Filter(user, networks)
1154}
1155
gio8fae3af2024-07-25 13:43:31 +04001156type publicNetworkData struct {
1157 Name string `json:"name"`
1158 Domain string `json:"domain"`
1159}
1160
1161type publicData struct {
1162 Networks []publicNetworkData `json:"networks"`
1163 Types []string `json:"types"`
1164}
1165
1166func (s *DodoAppServer) handleAPIPublicData(w http.ResponseWriter, r *http.Request) {
giod8ab4f52024-07-26 16:58:34 +04001167 w.Header().Set("Access-Control-Allow-Origin", "*")
1168 s.l.Lock()
1169 defer s.l.Unlock()
gio8fae3af2024-07-25 13:43:31 +04001170 networks, err := s.getNetworks("")
1171 if err != nil {
1172 http.Error(w, err.Error(), http.StatusInternalServerError)
1173 return
1174 }
1175 var ret publicData
1176 for _, n := range networks {
giod8ab4f52024-07-26 16:58:34 +04001177 if s.isNetworkUseAllowed(strings.ToLower(n.Name)) {
1178 ret.Networks = append(ret.Networks, publicNetworkData{n.Name, n.Domain})
1179 }
gio8fae3af2024-07-25 13:43:31 +04001180 }
1181 for _, t := range s.appTmpls.Types() {
giob54db242024-07-30 18:49:33 +04001182 ret.Types = append(ret.Types, strings.Replace(t, "-", ":", 1))
gio8fae3af2024-07-25 13:43:31 +04001183 }
gio8fae3af2024-07-25 13:43:31 +04001184 if err := json.NewEncoder(w).Encode(ret); err != nil {
1185 http.Error(w, err.Error(), http.StatusInternalServerError)
1186 return
1187 }
1188}
1189
giob4a3a192024-08-19 09:55:47 +04001190func (s *DodoAppServer) createCommit(name, hash, message string, err error, resources installer.ReleaseResources) error {
1191 if err != nil {
1192 fmt.Printf("Error: %s\n", err.Error())
1193 if err := s.st.CreateCommit(name, hash, message, "FAILED", err.Error(), nil); err != nil {
1194 fmt.Printf("Error: %s\n", err.Error())
1195 return err
1196 }
1197 return err
1198 }
1199 var resB bytes.Buffer
1200 if err := json.NewEncoder(&resB).Encode(resources); err != nil {
1201 if err := s.st.CreateCommit(name, hash, message, "FAILED", err.Error(), nil); err != nil {
1202 fmt.Printf("Error: %s\n", err.Error())
1203 return err
1204 }
1205 return err
1206 }
1207 if err := s.st.CreateCommit(name, hash, message, "OK", "", resB.Bytes()); err != nil {
1208 fmt.Printf("Error: %s\n", err.Error())
1209 return err
1210 }
1211 return nil
1212}
1213
gio11617ac2024-07-15 16:09:04 +04001214func pickNetwork(networks []installer.Network, network string) []installer.Network {
1215 for _, n := range networks {
1216 if n.Name == network {
1217 return []installer.Network{n}
1218 }
1219 }
1220 return []installer.Network{}
1221}
1222
1223type NetworkFilter interface {
1224 Filter(user string, networks []installer.Network) ([]installer.Network, error)
1225}
1226
1227type noNetworkFilter struct{}
1228
1229func NewNoNetworkFilter() NetworkFilter {
1230 return noNetworkFilter{}
1231}
1232
gio8fae3af2024-07-25 13:43:31 +04001233func (f noNetworkFilter) Filter(user string, networks []installer.Network) ([]installer.Network, error) {
gio11617ac2024-07-15 16:09:04 +04001234 return networks, nil
1235}
1236
1237type filterByOwner struct {
1238 st Store
1239}
1240
1241func NewNetworkFilterByOwner(st Store) NetworkFilter {
1242 return &filterByOwner{st}
1243}
1244
1245func (f *filterByOwner) Filter(user string, networks []installer.Network) ([]installer.Network, error) {
gio8fae3af2024-07-25 13:43:31 +04001246 if user == "" {
1247 return networks, nil
1248 }
gio11617ac2024-07-15 16:09:04 +04001249 network, err := f.st.GetUserNetwork(user)
1250 if err != nil {
1251 return nil, err
gio23bdc1b2024-07-11 16:07:47 +04001252 }
1253 ret := []installer.Network{}
1254 for _, n := range networks {
gio11617ac2024-07-15 16:09:04 +04001255 if n.Name == network {
gio23bdc1b2024-07-11 16:07:47 +04001256 ret = append(ret, n)
1257 }
1258 }
giocb34ad22024-07-11 08:01:13 +04001259 return ret, nil
1260}
gio11617ac2024-07-15 16:09:04 +04001261
1262type allowListFilter struct {
1263 allowed []string
1264}
1265
1266func NewAllowListFilter(allowed []string) NetworkFilter {
1267 return &allowListFilter{allowed}
1268}
1269
gio8fae3af2024-07-25 13:43:31 +04001270func (f *allowListFilter) Filter(user string, networks []installer.Network) ([]installer.Network, error) {
gio11617ac2024-07-15 16:09:04 +04001271 ret := []installer.Network{}
1272 for _, n := range networks {
1273 if slices.Contains(f.allowed, n.Name) {
1274 ret = append(ret, n)
1275 }
1276 }
1277 return ret, nil
1278}
1279
1280type combinedNetworkFilter struct {
1281 filters []NetworkFilter
1282}
1283
1284func NewCombinedFilter(filters ...NetworkFilter) NetworkFilter {
1285 return &combinedNetworkFilter{filters}
1286}
1287
gio8fae3af2024-07-25 13:43:31 +04001288func (f *combinedNetworkFilter) Filter(user string, networks []installer.Network) ([]installer.Network, error) {
gio11617ac2024-07-15 16:09:04 +04001289 ret := networks
1290 var err error
1291 for _, f := range f.filters {
gio8fae3af2024-07-25 13:43:31 +04001292 ret, err = f.Filter(user, ret)
gio11617ac2024-07-15 16:09:04 +04001293 if err != nil {
1294 return nil, err
1295 }
1296 }
1297 return ret, nil
1298}
giocafd4e62024-07-31 10:53:40 +04001299
1300type user struct {
1301 Username string `json:"username"`
1302 Email string `json:"email"`
1303 SSHPublicKeys []string `json:"sshPublicKeys,omitempty"`
1304}
1305
1306func (s *DodoAppServer) handleAPISyncUsers(_ http.ResponseWriter, _ *http.Request) {
1307 go s.syncUsers()
1308}
1309
1310func (s *DodoAppServer) syncUsers() {
1311 if s.external {
1312 panic("MUST NOT REACH!")
1313 }
1314 resp, err := http.Get(fmt.Sprintf("%s?selfAddress=%s/api/sync-users", s.fetchUsersAddr, s.self))
1315 if err != nil {
1316 return
1317 }
1318 users := []user{}
1319 if err := json.NewDecoder(resp.Body).Decode(&users); err != nil {
1320 fmt.Println(err)
1321 return
1322 }
Davit Tabidzea5ea5092024-08-01 15:28:09 +04001323 validUsernames := make(map[string]user)
1324 for _, u := range users {
1325 validUsernames[u.Username] = u
1326 }
1327 allClientUsers, err := s.client.GetAllUsers()
1328 if err != nil {
1329 fmt.Println(err)
1330 return
1331 }
1332 keyToUser := make(map[string]string)
1333 for _, clientUser := range allClientUsers {
Davit Tabidze4aaa27b2024-08-05 20:23:50 +04001334 if clientUser == "admin" || clientUser == "fluxcd" {
1335 continue
1336 }
Davit Tabidzea5ea5092024-08-01 15:28:09 +04001337 userData, ok := validUsernames[clientUser]
1338 if !ok {
1339 if err := s.client.RemoveUser(clientUser); err != nil {
1340 fmt.Println(err)
1341 return
1342 }
1343 } else {
1344 existingKeys, err := s.client.GetUserPublicKeys(clientUser)
1345 if err != nil {
1346 fmt.Println(err)
1347 return
1348 }
1349 for _, existingKey := range existingKeys {
Davit Tabidze4aaa27b2024-08-05 20:23:50 +04001350 cleanKey := soft.CleanKey(existingKey)
Davit Tabidzea5ea5092024-08-01 15:28:09 +04001351 keyOk := slices.ContainsFunc(userData.SSHPublicKeys, func(key string) bool {
Davit Tabidze4aaa27b2024-08-05 20:23:50 +04001352 return cleanKey == soft.CleanKey(key)
Davit Tabidzea5ea5092024-08-01 15:28:09 +04001353 })
1354 if !keyOk {
1355 if err := s.client.RemovePublicKey(clientUser, existingKey); err != nil {
1356 fmt.Println(err)
1357 }
1358 } else {
1359 keyToUser[cleanKey] = clientUser
1360 }
1361 }
1362 }
1363 }
giocafd4e62024-07-31 10:53:40 +04001364 for _, u := range users {
Davit Tabidze4aaa27b2024-08-05 20:23:50 +04001365 if err := s.st.CreateUser(u.Username, nil, ""); err != nil && !errors.Is(err, ErrorAlreadyExists) {
1366 fmt.Println(err)
1367 return
1368 }
giocafd4e62024-07-31 10:53:40 +04001369 if len(u.SSHPublicKeys) == 0 {
1370 continue
1371 }
Davit Tabidzea5ea5092024-08-01 15:28:09 +04001372 ok, err := s.client.UserExists(u.Username)
1373 if err != nil {
giocafd4e62024-07-31 10:53:40 +04001374 fmt.Println(err)
1375 return
Davit Tabidzea5ea5092024-08-01 15:28:09 +04001376 }
1377 if !ok {
1378 if err := s.client.AddUser(u.Username, u.SSHPublicKeys[0]); err != nil {
1379 fmt.Println(err)
1380 return
1381 }
1382 } else {
1383 for _, key := range u.SSHPublicKeys {
Davit Tabidze4aaa27b2024-08-05 20:23:50 +04001384 cleanKey := soft.CleanKey(key)
1385 if user, ok := keyToUser[cleanKey]; ok {
1386 if u.Username != user {
1387 panic("MUST NOT REACH! IMPOSSIBLE KEY USER RECORD")
1388 }
1389 continue
Davit Tabidzea5ea5092024-08-01 15:28:09 +04001390 }
Davit Tabidze4aaa27b2024-08-05 20:23:50 +04001391 if err := s.client.AddPublicKey(u.Username, cleanKey); err != nil {
Davit Tabidzea5ea5092024-08-01 15:28:09 +04001392 fmt.Println(err)
1393 return
giocafd4e62024-07-31 10:53:40 +04001394 }
1395 }
1396 }
1397 }
1398 repos, err := s.client.GetAllRepos()
1399 if err != nil {
1400 return
1401 }
1402 for _, r := range repos {
1403 if r == ConfigRepoName {
1404 continue
1405 }
1406 for _, u := range users {
1407 if err := s.client.AddReadWriteCollaborator(r, u.Username); err != nil {
1408 fmt.Println(err)
Davit Tabidze4aaa27b2024-08-05 20:23:50 +04001409 continue
giocafd4e62024-07-31 10:53:40 +04001410 }
1411 }
1412 }
1413}
giob4a3a192024-08-19 09:55:47 +04001414
1415func extractResourceData(resources []installer.Resource) (resourceData, error) {
1416 var ret resourceData
1417 for _, r := range resources {
1418 t, ok := r.Annotations["dodo.cloud/resource-type"]
1419 if !ok {
1420 continue
1421 }
1422 switch t {
1423 case "volume":
1424 name, ok := r.Annotations["dodo.cloud/resource.volume.name"]
1425 if !ok {
1426 return resourceData{}, fmt.Errorf("no name")
1427 }
1428 size, ok := r.Annotations["dodo.cloud/resource.volume.size"]
1429 if !ok {
1430 return resourceData{}, fmt.Errorf("no size")
1431 }
1432 ret.Volume = append(ret.Volume, volume{name, size})
1433 case "postgresql":
1434 name, ok := r.Annotations["dodo.cloud/resource.postgresql.name"]
1435 if !ok {
1436 return resourceData{}, fmt.Errorf("no name")
1437 }
1438 version, ok := r.Annotations["dodo.cloud/resource.postgresql.version"]
1439 if !ok {
1440 return resourceData{}, fmt.Errorf("no version")
1441 }
1442 volume, ok := r.Annotations["dodo.cloud/resource.postgresql.volume"]
1443 if !ok {
1444 return resourceData{}, fmt.Errorf("no volume")
1445 }
1446 ret.PostgreSQL = append(ret.PostgreSQL, postgresql{name, version, volume})
1447 case "ingress":
1448 host, ok := r.Annotations["dodo.cloud/resource.ingress.host"]
1449 if !ok {
1450 return resourceData{}, fmt.Errorf("no host")
1451 }
1452 ret.Ingress = append(ret.Ingress, ingress{host})
1453 default:
1454 fmt.Printf("Unknown resource: %+v\n", r.Annotations)
1455 }
1456 }
1457 return ret, nil
1458}