blob: 3f40e9f65591b2e5602a6fcab6a07e43457070a6 [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()
Davit Tabidzea5ea5092024-08-01 15:28:09 +0400226 for {
227 delay := time.Duration(rand.Intn(60)+60) * time.Second
228 time.Sleep(delay)
giocafd4e62024-07-31 10:53:40 +0400229 s.syncUsers()
230 }
231 }()
232 }
gioa60f0de2024-07-08 10:49:48 +0400233 return <-e
234}
235
gio11617ac2024-07-15 16:09:04 +0400236type UserGetter interface {
237 Get(r *http.Request) string
gio8fae3af2024-07-25 13:43:31 +0400238 Encode(w http.ResponseWriter, user string) error
gio11617ac2024-07-15 16:09:04 +0400239}
240
241type externalUserGetter struct {
242 sc *securecookie.SecureCookie
243}
244
245func NewExternalUserGetter() UserGetter {
gio8fae3af2024-07-25 13:43:31 +0400246 return &externalUserGetter{securecookie.New(
247 securecookie.GenerateRandomKey(64),
248 securecookie.GenerateRandomKey(32),
249 )}
gio11617ac2024-07-15 16:09:04 +0400250}
251
252func (ug *externalUserGetter) Get(r *http.Request) string {
253 cookie, err := r.Cookie(sessionCookie)
254 if err != nil {
255 return ""
256 }
257 var user string
258 if err := ug.sc.Decode(sessionCookie, cookie.Value, &user); err != nil {
259 return ""
260 }
261 return user
262}
263
gio8fae3af2024-07-25 13:43:31 +0400264func (ug *externalUserGetter) Encode(w http.ResponseWriter, user string) error {
265 if encoded, err := ug.sc.Encode(sessionCookie, user); err == nil {
266 cookie := &http.Cookie{
267 Name: sessionCookie,
268 Value: encoded,
269 Path: "/",
270 Secure: true,
271 HttpOnly: true,
272 }
273 http.SetCookie(w, cookie)
274 return nil
275 } else {
276 return err
277 }
278}
279
gio11617ac2024-07-15 16:09:04 +0400280type internalUserGetter struct{}
281
282func NewInternalUserGetter() UserGetter {
283 return internalUserGetter{}
284}
285
286func (ug internalUserGetter) Get(r *http.Request) string {
287 return r.Header.Get("X-User")
288}
289
gio8fae3af2024-07-25 13:43:31 +0400290func (ug internalUserGetter) Encode(w http.ResponseWriter, user string) error {
291 return nil
292}
293
gio81246f02024-07-10 12:02:15 +0400294func (s *DodoAppServer) mwAuth(next http.Handler) http.Handler {
295 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gio8fae3af2024-07-25 13:43:31 +0400296 if strings.HasSuffix(r.URL.Path, loginPath) ||
297 strings.HasPrefix(r.URL.Path, logoutPath) ||
298 strings.HasPrefix(r.URL.Path, staticPath) ||
299 strings.HasPrefix(r.URL.Path, apiPublicData) ||
300 strings.HasPrefix(r.URL.Path, apiCreateApp) {
gio81246f02024-07-10 12:02:15 +0400301 next.ServeHTTP(w, r)
302 return
303 }
gio11617ac2024-07-15 16:09:04 +0400304 user := s.ug.Get(r)
305 if user == "" {
gio81246f02024-07-10 12:02:15 +0400306 vars := mux.Vars(r)
307 appName, ok := vars["app-name"]
308 if !ok || appName == "" {
309 http.Error(w, "missing app-name", http.StatusBadRequest)
310 return
311 }
312 http.Redirect(w, r, fmt.Sprintf("/%s%s", appName, loginPath), http.StatusSeeOther)
313 return
314 }
gio81246f02024-07-10 12:02:15 +0400315 next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), userCtx, user)))
316 })
317}
318
319func (s *DodoAppServer) handleLogout(w http.ResponseWriter, r *http.Request) {
gio8fae3af2024-07-25 13:43:31 +0400320 // TODO(gio): move to UserGetter
gio81246f02024-07-10 12:02:15 +0400321 http.SetCookie(w, &http.Cookie{
322 Name: sessionCookie,
323 Value: "",
324 Path: "/",
325 HttpOnly: true,
326 Secure: true,
327 })
328 http.Redirect(w, r, "/", http.StatusSeeOther)
329}
330
331func (s *DodoAppServer) handleLoginForm(w http.ResponseWriter, r *http.Request) {
332 vars := mux.Vars(r)
333 appName, ok := vars["app-name"]
334 if !ok || appName == "" {
335 http.Error(w, "missing app-name", http.StatusBadRequest)
336 return
337 }
338 fmt.Fprint(w, `
339<!DOCTYPE html>
340<html lang='en'>
341 <head>
342 <title>dodo: app - login</title>
343 <meta charset='utf-8'>
344 </head>
345 <body>
346 <form action="" method="POST">
347 <input type="password" placeholder="Password" name="password" required />
348 <button type="submit">Login</button>
349 </form>
350 </body>
351</html>
352`)
353}
354
355func (s *DodoAppServer) handleLogin(w http.ResponseWriter, r *http.Request) {
356 vars := mux.Vars(r)
357 appName, ok := vars["app-name"]
358 if !ok || appName == "" {
359 http.Error(w, "missing app-name", http.StatusBadRequest)
360 return
361 }
362 password := r.FormValue("password")
363 if password == "" {
364 http.Error(w, "missing password", http.StatusBadRequest)
365 return
366 }
367 user, err := s.st.GetAppOwner(appName)
368 if err != nil {
369 http.Error(w, err.Error(), http.StatusInternalServerError)
370 return
371 }
372 hashed, err := s.st.GetUserPassword(user)
373 if err != nil {
374 http.Error(w, err.Error(), http.StatusInternalServerError)
375 return
376 }
377 if err := bcrypt.CompareHashAndPassword(hashed, []byte(password)); err != nil {
378 http.Redirect(w, r, r.URL.Path, http.StatusSeeOther)
379 return
380 }
gio8fae3af2024-07-25 13:43:31 +0400381 if err := s.ug.Encode(w, user); err != nil {
382 http.Error(w, err.Error(), http.StatusInternalServerError)
383 return
gio81246f02024-07-10 12:02:15 +0400384 }
385 http.Redirect(w, r, fmt.Sprintf("/%s", appName), http.StatusSeeOther)
386}
387
giob4a3a192024-08-19 09:55:47 +0400388type navItem struct {
389 Name string
390 Address string
391}
392
gio23bdc1b2024-07-11 16:07:47 +0400393type statusData struct {
giob4a3a192024-08-19 09:55:47 +0400394 Navigation []navItem
395 Apps []string
396 Networks []installer.Network
397 Types []string
gio23bdc1b2024-07-11 16:07:47 +0400398}
399
gioa60f0de2024-07-08 10:49:48 +0400400func (s *DodoAppServer) handleStatus(w http.ResponseWriter, r *http.Request) {
gio81246f02024-07-10 12:02:15 +0400401 user := r.Context().Value(userCtx)
402 if user == nil {
403 http.Error(w, "unauthorized", http.StatusUnauthorized)
404 return
405 }
406 apps, err := s.st.GetUserApps(user.(string))
gioa60f0de2024-07-08 10:49:48 +0400407 if err != nil {
408 http.Error(w, err.Error(), http.StatusInternalServerError)
409 return
410 }
gio11617ac2024-07-15 16:09:04 +0400411 networks, err := s.getNetworks(user.(string))
412 if err != nil {
413 http.Error(w, err.Error(), http.StatusInternalServerError)
414 return
415 }
giob54db242024-07-30 18:49:33 +0400416 var types []string
417 for _, t := range s.appTmpls.Types() {
418 types = append(types, strings.Replace(t, "-", ":", 1))
419 }
giob4a3a192024-08-19 09:55:47 +0400420 n := []navItem{navItem{"Home", "/"}}
421 data := statusData{n, apps, networks, types}
gio23bdc1b2024-07-11 16:07:47 +0400422 if err := s.tmplts.index.Execute(w, data); err != nil {
423 http.Error(w, err.Error(), http.StatusInternalServerError)
424 return
gioa60f0de2024-07-08 10:49:48 +0400425 }
426}
427
gio5e49bb62024-07-20 10:43:19 +0400428type appStatusData struct {
giob4a3a192024-08-19 09:55:47 +0400429 Navigation []navItem
gio5e49bb62024-07-20 10:43:19 +0400430 Name string
431 GitCloneCommand string
giob4a3a192024-08-19 09:55:47 +0400432 Commits []CommitMeta
gio183e8342024-08-20 06:01:24 +0400433 LastCommit resourceData
gio5e49bb62024-07-20 10:43:19 +0400434}
435
gioa60f0de2024-07-08 10:49:48 +0400436func (s *DodoAppServer) handleAppStatus(w http.ResponseWriter, r *http.Request) {
437 vars := mux.Vars(r)
438 appName, ok := vars["app-name"]
439 if !ok || appName == "" {
440 http.Error(w, "missing app-name", http.StatusBadRequest)
441 return
442 }
gio94904702024-07-26 16:58:34 +0400443 u := r.Context().Value(userCtx)
444 if u == nil {
445 http.Error(w, "unauthorized", http.StatusUnauthorized)
446 return
447 }
448 user, ok := u.(string)
449 if !ok {
450 http.Error(w, "could not get user", http.StatusInternalServerError)
451 return
452 }
453 owner, err := s.st.GetAppOwner(appName)
454 if err != nil {
455 http.Error(w, err.Error(), http.StatusInternalServerError)
456 return
457 }
458 if owner != user {
459 http.Error(w, "unauthorized", http.StatusUnauthorized)
460 return
461 }
gioa60f0de2024-07-08 10:49:48 +0400462 commits, err := s.st.GetCommitHistory(appName)
463 if err != nil {
464 http.Error(w, err.Error(), http.StatusInternalServerError)
465 return
466 }
gio183e8342024-08-20 06:01:24 +0400467 var lastCommitResources resourceData
468 if len(commits) > 0 {
469 lastCommit, err := s.st.GetCommit(commits[len(commits)-1].Hash)
470 if err != nil {
471 http.Error(w, err.Error(), http.StatusInternalServerError)
472 return
473 }
474 r, err := extractResourceData(lastCommit.Resources.Helm)
475 if err != nil {
476 http.Error(w, err.Error(), http.StatusInternalServerError)
477 return
478 }
479 lastCommitResources = r
480 }
gio5e49bb62024-07-20 10:43:19 +0400481 data := appStatusData{
giob4a3a192024-08-19 09:55:47 +0400482 Navigation: []navItem{
483 navItem{"Home", "/"},
484 navItem{appName, "/" + appName},
485 },
gio5e49bb62024-07-20 10:43:19 +0400486 Name: appName,
487 GitCloneCommand: fmt.Sprintf("git clone %s/%s\n\n\n", s.repoPublicAddr, appName),
488 Commits: commits,
gio183e8342024-08-20 06:01:24 +0400489 LastCommit: lastCommitResources,
gio5e49bb62024-07-20 10:43:19 +0400490 }
491 if err := s.tmplts.appStatus.Execute(w, data); err != nil {
492 http.Error(w, err.Error(), http.StatusInternalServerError)
493 return
gioa60f0de2024-07-08 10:49:48 +0400494 }
gio0eaf2712024-04-14 13:08:46 +0400495}
496
giob4a3a192024-08-19 09:55:47 +0400497type volume struct {
498 Name string
499 Size string
500}
501
502type postgresql struct {
503 Name string
504 Version string
505 Volume string
506}
507
508type ingress struct {
509 Host string
510}
511
512type resourceData struct {
513 Volume []volume
514 PostgreSQL []postgresql
515 Ingress []ingress
516}
517
518type commitStatusData struct {
519 Navigation []navItem
520 AppName string
521 Commit Commit
522 Resources resourceData
523}
524
525func (s *DodoAppServer) handleAppCommit(w http.ResponseWriter, r *http.Request) {
526 vars := mux.Vars(r)
527 appName, ok := vars["app-name"]
528 if !ok || appName == "" {
529 http.Error(w, "missing app-name", http.StatusBadRequest)
530 return
531 }
532 hash, ok := vars["hash"]
533 if !ok || appName == "" {
534 http.Error(w, "missing app-name", http.StatusBadRequest)
535 return
536 }
537 u := r.Context().Value(userCtx)
538 if u == nil {
539 http.Error(w, "unauthorized", http.StatusUnauthorized)
540 return
541 }
542 user, ok := u.(string)
543 if !ok {
544 http.Error(w, "could not get user", http.StatusInternalServerError)
545 return
546 }
547 owner, err := s.st.GetAppOwner(appName)
548 if err != nil {
549 http.Error(w, err.Error(), http.StatusInternalServerError)
550 return
551 }
552 if owner != user {
553 http.Error(w, "unauthorized", http.StatusUnauthorized)
554 return
555 }
556 commit, err := s.st.GetCommit(hash)
557 if err != nil {
558 // TODO(gio): not-found ?
559 http.Error(w, err.Error(), http.StatusInternalServerError)
560 return
561 }
562 var res strings.Builder
563 if err := json.NewEncoder(&res).Encode(commit.Resources.Helm); err != nil {
564 http.Error(w, err.Error(), http.StatusInternalServerError)
565 return
566 }
567 resData, err := extractResourceData(commit.Resources.Helm)
568 if err != nil {
569 http.Error(w, err.Error(), http.StatusInternalServerError)
570 return
571 }
572 data := commitStatusData{
573 Navigation: []navItem{
574 navItem{"Home", "/"},
575 navItem{appName, "/" + appName},
576 navItem{hash, "/" + appName + "/" + hash},
577 },
578 AppName: appName,
579 Commit: commit,
580 Resources: resData,
581 }
582 if err := s.tmplts.commitStatus.Execute(w, data); err != nil {
583 http.Error(w, err.Error(), http.StatusInternalServerError)
584 return
585 }
586}
587
gio183e8342024-08-20 06:01:24 +0400588type logData struct {
589 Navigation []navItem
590 AppName string
591 Logs template.HTML
592}
593
594func (s *DodoAppServer) handleAppLogs(w http.ResponseWriter, r *http.Request) {
595 vars := mux.Vars(r)
596 appName, ok := vars["app-name"]
597 if !ok || appName == "" {
598 http.Error(w, "missing app-name", http.StatusBadRequest)
599 return
600 }
601 u := r.Context().Value(userCtx)
602 if u == nil {
603 http.Error(w, "unauthorized", http.StatusUnauthorized)
604 return
605 }
606 user, ok := u.(string)
607 if !ok {
608 http.Error(w, "could not get user", http.StatusInternalServerError)
609 return
610 }
611 owner, err := s.st.GetAppOwner(appName)
612 if err != nil {
613 http.Error(w, err.Error(), http.StatusInternalServerError)
614 return
615 }
616 if owner != user {
617 http.Error(w, "unauthorized", http.StatusUnauthorized)
618 return
619 }
620 data := logData{
621 Navigation: []navItem{
622 navItem{"Home", "/"},
623 navItem{appName, "/" + appName},
624 navItem{"Logs", "/" + appName + "/logs"},
625 },
626 AppName: appName,
627 Logs: template.HTML(strings.ReplaceAll(s.logs[appName], "\n", "<br/>")),
628 }
629 if err := s.tmplts.logs.Execute(w, data); err != nil {
630 fmt.Println(err)
631 http.Error(w, err.Error(), http.StatusInternalServerError)
632 return
633 }
634}
635
gio81246f02024-07-10 12:02:15 +0400636type apiUpdateReq struct {
gio266c04f2024-07-03 14:18:45 +0400637 Ref string `json:"ref"`
638 Repository struct {
639 Name string `json:"name"`
640 } `json:"repository"`
gioe2e31e12024-08-18 08:20:56 +0400641 After string `json:"after"`
642 Commits []struct {
643 Id string `json:"id"`
644 Message string `json:"message"`
645 } `json:"commits"`
gio0eaf2712024-04-14 13:08:46 +0400646}
647
gio8fae3af2024-07-25 13:43:31 +0400648func (s *DodoAppServer) handleAPIUpdate(w http.ResponseWriter, r *http.Request) {
gio0eaf2712024-04-14 13:08:46 +0400649 fmt.Println("update")
gio81246f02024-07-10 12:02:15 +0400650 var req apiUpdateReq
gio0eaf2712024-04-14 13:08:46 +0400651 var contents strings.Builder
652 io.Copy(&contents, r.Body)
653 c := contents.String()
654 fmt.Println(c)
655 if err := json.NewDecoder(strings.NewReader(c)).Decode(&req); err != nil {
gio23bdc1b2024-07-11 16:07:47 +0400656 http.Error(w, err.Error(), http.StatusBadRequest)
gio0eaf2712024-04-14 13:08:46 +0400657 return
658 }
gioa60f0de2024-07-08 10:49:48 +0400659 if req.Ref != "refs/heads/master" || req.Repository.Name == ConfigRepoName {
gio0eaf2712024-04-14 13:08:46 +0400660 return
661 }
gioa60f0de2024-07-08 10:49:48 +0400662 // TODO(gio): Create commit record on app init as well
gio0eaf2712024-04-14 13:08:46 +0400663 go func() {
gio11617ac2024-07-15 16:09:04 +0400664 owner, err := s.st.GetAppOwner(req.Repository.Name)
665 if err != nil {
666 return
667 }
668 networks, err := s.getNetworks(owner)
giocb34ad22024-07-11 08:01:13 +0400669 if err != nil {
670 return
671 }
gio94904702024-07-26 16:58:34 +0400672 apps := installer.NewInMemoryAppRepository(installer.CreateAllApps())
673 instanceAppStatus, err := installer.FindEnvApp(apps, "dodo-app-instance-status")
674 if err != nil {
675 return
676 }
gioe2e31e12024-08-18 08:20:56 +0400677 found := false
678 commitMsg := ""
679 for _, c := range req.Commits {
680 if c.Id == req.After {
681 found = true
682 commitMsg = c.Message
683 break
gioa60f0de2024-07-08 10:49:48 +0400684 }
685 }
gioe2e31e12024-08-18 08:20:56 +0400686 if !found {
687 fmt.Printf("Error: could not find commit message")
688 return
689 }
giob4a3a192024-08-19 09:55:47 +0400690 resources, err := s.updateDodoApp(instanceAppStatus, req.Repository.Name, s.appConfigs[req.Repository.Name].Namespace, networks)
691 if err = s.createCommit(req.Repository.Name, req.After, commitMsg, err, resources); err != nil {
gio12e887d2024-08-18 16:09:47 +0400692 fmt.Printf("Error: %s\n", err.Error())
gioe2e31e12024-08-18 08:20:56 +0400693 return
694 }
gioa60f0de2024-07-08 10:49:48 +0400695 for addr, _ := range s.workers[req.Repository.Name] {
696 go func() {
697 // TODO(gio): make port configurable
698 http.Get(fmt.Sprintf("http://%s/update", addr))
699 }()
gio0eaf2712024-04-14 13:08:46 +0400700 }
701 }()
gio0eaf2712024-04-14 13:08:46 +0400702}
703
gio81246f02024-07-10 12:02:15 +0400704type apiRegisterWorkerReq struct {
gio0eaf2712024-04-14 13:08:46 +0400705 Address string `json:"address"`
gio183e8342024-08-20 06:01:24 +0400706 Logs string `json:"logs"`
gio0eaf2712024-04-14 13:08:46 +0400707}
708
gio8fae3af2024-07-25 13:43:31 +0400709func (s *DodoAppServer) handleAPIRegisterWorker(w http.ResponseWriter, r *http.Request) {
gioa60f0de2024-07-08 10:49:48 +0400710 vars := mux.Vars(r)
711 appName, ok := vars["app-name"]
712 if !ok || appName == "" {
713 http.Error(w, "missing app-name", http.StatusBadRequest)
714 return
715 }
gio81246f02024-07-10 12:02:15 +0400716 var req apiRegisterWorkerReq
gio0eaf2712024-04-14 13:08:46 +0400717 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
718 http.Error(w, err.Error(), http.StatusInternalServerError)
719 return
720 }
gioa60f0de2024-07-08 10:49:48 +0400721 if _, ok := s.workers[appName]; !ok {
722 s.workers[appName] = map[string]struct{}{}
gio266c04f2024-07-03 14:18:45 +0400723 }
gioa60f0de2024-07-08 10:49:48 +0400724 s.workers[appName][req.Address] = struct{}{}
gio183e8342024-08-20 06:01:24 +0400725 s.logs[appName] = req.Logs
gio0eaf2712024-04-14 13:08:46 +0400726}
727
gio11617ac2024-07-15 16:09:04 +0400728func (s *DodoAppServer) handleCreateApp(w http.ResponseWriter, r *http.Request) {
729 u := r.Context().Value(userCtx)
730 if u == nil {
731 http.Error(w, "unauthorized", http.StatusUnauthorized)
732 return
733 }
734 user, ok := u.(string)
735 if !ok {
736 http.Error(w, "could not get user", http.StatusInternalServerError)
737 return
738 }
739 network := r.FormValue("network")
740 if network == "" {
741 http.Error(w, "missing network", http.StatusBadRequest)
742 return
743 }
gio5e49bb62024-07-20 10:43:19 +0400744 subdomain := r.FormValue("subdomain")
745 if subdomain == "" {
746 http.Error(w, "missing subdomain", http.StatusBadRequest)
747 return
748 }
749 appType := r.FormValue("type")
750 if appType == "" {
751 http.Error(w, "missing type", http.StatusBadRequest)
752 return
753 }
gio11617ac2024-07-15 16:09:04 +0400754 g := installer.NewFixedLengthRandomNameGenerator(3)
755 appName, err := g.Generate()
756 if err != nil {
757 http.Error(w, err.Error(), http.StatusInternalServerError)
758 return
759 }
760 if ok, err := s.client.UserExists(user); err != nil {
761 http.Error(w, err.Error(), http.StatusInternalServerError)
762 return
763 } else if !ok {
giocafd4e62024-07-31 10:53:40 +0400764 http.Error(w, "user sync has not finished, please try again in few minutes", http.StatusFailedDependency)
765 return
gio11617ac2024-07-15 16:09:04 +0400766 }
giocafd4e62024-07-31 10:53:40 +0400767 if err := s.st.CreateUser(user, nil, network); err != nil && !errors.Is(err, ErrorAlreadyExists) {
gio11617ac2024-07-15 16:09:04 +0400768 http.Error(w, err.Error(), http.StatusInternalServerError)
769 return
770 }
771 if err := s.st.CreateApp(appName, user); err != nil {
772 http.Error(w, err.Error(), http.StatusInternalServerError)
773 return
774 }
giod8ab4f52024-07-26 16:58:34 +0400775 if err := s.createApp(user, appName, appType, network, subdomain); err != nil {
gio11617ac2024-07-15 16:09:04 +0400776 http.Error(w, err.Error(), http.StatusInternalServerError)
777 return
778 }
779 http.Redirect(w, r, fmt.Sprintf("/%s", appName), http.StatusSeeOther)
780}
781
gio81246f02024-07-10 12:02:15 +0400782type apiCreateAppReq struct {
gio5e49bb62024-07-20 10:43:19 +0400783 AppType string `json:"type"`
gio33059762024-07-05 13:19:07 +0400784 AdminPublicKey string `json:"adminPublicKey"`
gio11617ac2024-07-15 16:09:04 +0400785 Network string `json:"network"`
gio5e49bb62024-07-20 10:43:19 +0400786 Subdomain string `json:"subdomain"`
gio33059762024-07-05 13:19:07 +0400787}
788
gio81246f02024-07-10 12:02:15 +0400789type apiCreateAppResp struct {
790 AppName string `json:"appName"`
791 Password string `json:"password"`
gio33059762024-07-05 13:19:07 +0400792}
793
gio8fae3af2024-07-25 13:43:31 +0400794func (s *DodoAppServer) handleAPICreateApp(w http.ResponseWriter, r *http.Request) {
giod8ab4f52024-07-26 16:58:34 +0400795 w.Header().Set("Access-Control-Allow-Origin", "*")
gio81246f02024-07-10 12:02:15 +0400796 var req apiCreateAppReq
gio33059762024-07-05 13:19:07 +0400797 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
798 http.Error(w, err.Error(), http.StatusBadRequest)
799 return
800 }
801 g := installer.NewFixedLengthRandomNameGenerator(3)
802 appName, err := g.Generate()
803 if err != nil {
804 http.Error(w, err.Error(), http.StatusInternalServerError)
805 return
806 }
gio11617ac2024-07-15 16:09:04 +0400807 user, err := s.client.FindUser(req.AdminPublicKey)
gio81246f02024-07-10 12:02:15 +0400808 if err != nil {
gio33059762024-07-05 13:19:07 +0400809 http.Error(w, err.Error(), http.StatusInternalServerError)
810 return
811 }
gio11617ac2024-07-15 16:09:04 +0400812 if user != "" {
813 http.Error(w, "public key already registered", http.StatusBadRequest)
814 return
815 }
816 user = appName
817 if err := s.client.AddUser(user, req.AdminPublicKey); err != nil {
818 http.Error(w, err.Error(), http.StatusInternalServerError)
819 return
820 }
821 password := generatePassword()
822 hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
823 if err != nil {
824 http.Error(w, err.Error(), http.StatusInternalServerError)
825 return
826 }
giocafd4e62024-07-31 10:53:40 +0400827 if err := s.st.CreateUser(user, hashed, req.Network); err != nil {
gio11617ac2024-07-15 16:09:04 +0400828 http.Error(w, err.Error(), http.StatusInternalServerError)
829 return
830 }
831 if err := s.st.CreateApp(appName, user); err != nil {
832 http.Error(w, err.Error(), http.StatusInternalServerError)
833 return
834 }
giod8ab4f52024-07-26 16:58:34 +0400835 if err := s.createApp(user, appName, req.AppType, req.Network, req.Subdomain); err != nil {
gio11617ac2024-07-15 16:09:04 +0400836 http.Error(w, err.Error(), http.StatusInternalServerError)
837 return
838 }
gio81246f02024-07-10 12:02:15 +0400839 resp := apiCreateAppResp{
840 AppName: appName,
841 Password: password,
842 }
gio33059762024-07-05 13:19:07 +0400843 if err := json.NewEncoder(w).Encode(resp); err != nil {
844 http.Error(w, err.Error(), http.StatusInternalServerError)
845 return
846 }
847}
848
giod8ab4f52024-07-26 16:58:34 +0400849func (s *DodoAppServer) isNetworkUseAllowed(network string) bool {
giocafd4e62024-07-31 10:53:40 +0400850 if !s.external {
giod8ab4f52024-07-26 16:58:34 +0400851 return true
852 }
853 for _, cfg := range s.appConfigs {
854 if strings.ToLower(cfg.Network) == network {
855 return false
856 }
857 }
858 return true
859}
860
861func (s *DodoAppServer) createApp(user, appName, appType, network, subdomain string) error {
gio9d66f322024-07-06 13:45:10 +0400862 s.l.Lock()
863 defer s.l.Unlock()
gio33059762024-07-05 13:19:07 +0400864 fmt.Printf("Creating app: %s\n", appName)
giod8ab4f52024-07-26 16:58:34 +0400865 network = strings.ToLower(network)
866 if !s.isNetworkUseAllowed(network) {
867 return fmt.Errorf("network already used: %s", network)
868 }
gio33059762024-07-05 13:19:07 +0400869 if ok, err := s.client.RepoExists(appName); err != nil {
gio11617ac2024-07-15 16:09:04 +0400870 return err
gio33059762024-07-05 13:19:07 +0400871 } else if ok {
gio11617ac2024-07-15 16:09:04 +0400872 return nil
gioa60f0de2024-07-08 10:49:48 +0400873 }
gio5e49bb62024-07-20 10:43:19 +0400874 networks, err := s.getNetworks(user)
875 if err != nil {
876 return err
877 }
giod8ab4f52024-07-26 16:58:34 +0400878 n, ok := installer.NetworkMap(networks)[network]
gio5e49bb62024-07-20 10:43:19 +0400879 if !ok {
880 return fmt.Errorf("network not found: %s\n", network)
881 }
gio33059762024-07-05 13:19:07 +0400882 if err := s.client.AddRepository(appName); err != nil {
gio11617ac2024-07-15 16:09:04 +0400883 return err
gio33059762024-07-05 13:19:07 +0400884 }
885 appRepo, err := s.client.GetRepo(appName)
886 if err != nil {
gio11617ac2024-07-15 16:09:04 +0400887 return err
gio33059762024-07-05 13:19:07 +0400888 }
giob4a3a192024-08-19 09:55:47 +0400889 commit, err := s.initRepo(appRepo, appType, n, subdomain)
890 if err != nil {
gio11617ac2024-07-15 16:09:04 +0400891 return err
gio33059762024-07-05 13:19:07 +0400892 }
893 apps := installer.NewInMemoryAppRepository(installer.CreateAllApps())
gio94904702024-07-26 16:58:34 +0400894 instanceApp, err := installer.FindEnvApp(apps, "dodo-app-instance")
895 if err != nil {
896 return err
897 }
898 instanceAppStatus, err := installer.FindEnvApp(apps, "dodo-app-instance-status")
gio33059762024-07-05 13:19:07 +0400899 if err != nil {
gio11617ac2024-07-15 16:09:04 +0400900 return err
gio33059762024-07-05 13:19:07 +0400901 }
902 suffixGen := installer.NewFixedLengthRandomSuffixGenerator(3)
903 suffix, err := suffixGen.Generate()
904 if err != nil {
gio11617ac2024-07-15 16:09:04 +0400905 return err
gio33059762024-07-05 13:19:07 +0400906 }
gio94904702024-07-26 16:58:34 +0400907 namespace := fmt.Sprintf("%s%s%s", s.env.NamespacePrefix, instanceApp.Namespace(), suffix)
giod8ab4f52024-07-26 16:58:34 +0400908 s.appConfigs[appName] = appConfig{namespace, network}
giob4a3a192024-08-19 09:55:47 +0400909 resources, err := s.updateDodoApp(instanceAppStatus, appName, namespace, networks)
910 if err != nil {
911 return err
912 }
913 if err = s.createCommit(appName, commit, initCommitMsg, err, resources); err != nil {
914 fmt.Printf("Error: %s\n", err.Error())
gio11617ac2024-07-15 16:09:04 +0400915 return err
gio33059762024-07-05 13:19:07 +0400916 }
giod8ab4f52024-07-26 16:58:34 +0400917 configRepo, err := s.client.GetRepo(ConfigRepoName)
gio33059762024-07-05 13:19:07 +0400918 if err != nil {
gio11617ac2024-07-15 16:09:04 +0400919 return err
gio33059762024-07-05 13:19:07 +0400920 }
921 hf := installer.NewGitHelmFetcher()
giod8ab4f52024-07-26 16:58:34 +0400922 m, err := installer.NewAppManager(configRepo, s.nsc, s.jc, hf, "/")
gio33059762024-07-05 13:19:07 +0400923 if err != nil {
gio11617ac2024-07-15 16:09:04 +0400924 return err
gio33059762024-07-05 13:19:07 +0400925 }
giob4a3a192024-08-19 09:55:47 +0400926 _, err = configRepo.Do(func(fs soft.RepoFS) (string, error) {
giod8ab4f52024-07-26 16:58:34 +0400927 w, err := fs.Writer(appConfigsFile)
gio9d66f322024-07-06 13:45:10 +0400928 if err != nil {
929 return "", err
930 }
931 defer w.Close()
giod8ab4f52024-07-26 16:58:34 +0400932 if err := json.NewEncoder(w).Encode(s.appConfigs); err != nil {
gio9d66f322024-07-06 13:45:10 +0400933 return "", err
934 }
935 if _, err := m.Install(
gio94904702024-07-26 16:58:34 +0400936 instanceApp,
gio9d66f322024-07-06 13:45:10 +0400937 appName,
938 "/"+appName,
939 namespace,
940 map[string]any{
941 "repoAddr": s.client.GetRepoAddress(appName),
942 "repoHost": strings.Split(s.client.Address(), ":")[0],
943 "gitRepoPublicKey": s.gitRepoPublicKey,
944 },
945 installer.WithConfig(&s.env),
gio23bdc1b2024-07-11 16:07:47 +0400946 installer.WithNoNetworks(),
gio9d66f322024-07-06 13:45:10 +0400947 installer.WithNoPublish(),
948 installer.WithNoLock(),
949 ); err != nil {
950 return "", err
951 }
952 return fmt.Sprintf("Installed app: %s", appName), nil
giob4a3a192024-08-19 09:55:47 +0400953 })
954 if err != nil {
gio11617ac2024-07-15 16:09:04 +0400955 return err
gio33059762024-07-05 13:19:07 +0400956 }
957 cfg, err := m.FindInstance(appName)
958 if err != nil {
gio11617ac2024-07-15 16:09:04 +0400959 return err
gio33059762024-07-05 13:19:07 +0400960 }
961 fluxKeys, ok := cfg.Input["fluxKeys"]
962 if !ok {
gio11617ac2024-07-15 16:09:04 +0400963 return fmt.Errorf("Fluxcd keys not found")
gio33059762024-07-05 13:19:07 +0400964 }
965 fluxPublicKey, ok := fluxKeys.(map[string]any)["public"]
966 if !ok {
gio11617ac2024-07-15 16:09:04 +0400967 return fmt.Errorf("Fluxcd keys not found")
gio33059762024-07-05 13:19:07 +0400968 }
969 if ok, err := s.client.UserExists("fluxcd"); err != nil {
gio11617ac2024-07-15 16:09:04 +0400970 return err
gio33059762024-07-05 13:19:07 +0400971 } else if ok {
972 if err := s.client.AddPublicKey("fluxcd", fluxPublicKey.(string)); err != nil {
gio11617ac2024-07-15 16:09:04 +0400973 return err
gio33059762024-07-05 13:19:07 +0400974 }
975 } else {
976 if err := s.client.AddUser("fluxcd", fluxPublicKey.(string)); err != nil {
gio11617ac2024-07-15 16:09:04 +0400977 return err
gio33059762024-07-05 13:19:07 +0400978 }
979 }
980 if err := s.client.AddReadOnlyCollaborator(appName, "fluxcd"); err != nil {
gio11617ac2024-07-15 16:09:04 +0400981 return err
gio33059762024-07-05 13:19:07 +0400982 }
983 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 +0400984 return err
gio33059762024-07-05 13:19:07 +0400985 }
gio81246f02024-07-10 12:02:15 +0400986 if err := s.client.AddReadWriteCollaborator(appName, user); err != nil {
gio11617ac2024-07-15 16:09:04 +0400987 return err
gio33059762024-07-05 13:19:07 +0400988 }
gio2ccb6e32024-08-15 12:01:33 +0400989 if !s.external {
990 go func() {
991 users, err := s.client.GetAllUsers()
992 if err != nil {
993 fmt.Println(err)
994 return
995 }
996 for _, user := range users {
997 // TODO(gio): fluxcd should have only read access
998 if err := s.client.AddReadWriteCollaborator(appName, user); err != nil {
999 fmt.Println(err)
1000 }
1001 }
1002 }()
1003 }
gio43b0f422024-08-21 10:40:13 +04001004 ctx, _ := context.WithTimeout(context.Background(), 2*time.Minute)
1005 go s.reconciler.Reconcile(ctx, s.namespace, "config")
gio11617ac2024-07-15 16:09:04 +04001006 return nil
gio33059762024-07-05 13:19:07 +04001007}
1008
gio81246f02024-07-10 12:02:15 +04001009type apiAddAdminKeyReq struct {
gio70be3e52024-06-26 18:27:19 +04001010 Public string `json:"public"`
1011}
1012
gio8fae3af2024-07-25 13:43:31 +04001013func (s *DodoAppServer) handleAPIAddAdminKey(w http.ResponseWriter, r *http.Request) {
gio81246f02024-07-10 12:02:15 +04001014 var req apiAddAdminKeyReq
gio70be3e52024-06-26 18:27:19 +04001015 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
1016 http.Error(w, err.Error(), http.StatusBadRequest)
1017 return
1018 }
1019 if err := s.client.AddPublicKey("admin", req.Public); err != nil {
1020 http.Error(w, err.Error(), http.StatusInternalServerError)
1021 return
1022 }
1023}
1024
gio94904702024-07-26 16:58:34 +04001025type dodoAppRendered struct {
1026 App struct {
1027 Ingress struct {
1028 Network string `json:"network"`
1029 Subdomain string `json:"subdomain"`
1030 } `json:"ingress"`
1031 } `json:"app"`
1032 Input struct {
1033 AppId string `json:"appId"`
1034 } `json:"input"`
1035}
1036
gio43b0f422024-08-21 10:40:13 +04001037func (s *DodoAppServer) updateDodoApp(
1038 appStatus installer.EnvApp,
1039 name, namespace string,
1040 networks []installer.Network,
1041) (installer.ReleaseResources, error) {
gio33059762024-07-05 13:19:07 +04001042 repo, err := s.client.GetRepo(name)
gio0eaf2712024-04-14 13:08:46 +04001043 if err != nil {
giob4a3a192024-08-19 09:55:47 +04001044 return installer.ReleaseResources{}, err
gio0eaf2712024-04-14 13:08:46 +04001045 }
giof8843412024-05-22 16:38:05 +04001046 hf := installer.NewGitHelmFetcher()
gio33059762024-07-05 13:19:07 +04001047 m, err := installer.NewAppManager(repo, s.nsc, s.jc, hf, "/.dodo")
gio0eaf2712024-04-14 13:08:46 +04001048 if err != nil {
giob4a3a192024-08-19 09:55:47 +04001049 return installer.ReleaseResources{}, err
gio0eaf2712024-04-14 13:08:46 +04001050 }
1051 appCfg, err := soft.ReadFile(repo, "app.cue")
gio0eaf2712024-04-14 13:08:46 +04001052 if err != nil {
giob4a3a192024-08-19 09:55:47 +04001053 return installer.ReleaseResources{}, err
gio0eaf2712024-04-14 13:08:46 +04001054 }
1055 app, err := installer.NewDodoApp(appCfg)
1056 if err != nil {
giob4a3a192024-08-19 09:55:47 +04001057 return installer.ReleaseResources{}, err
gio0eaf2712024-04-14 13:08:46 +04001058 }
giof8843412024-05-22 16:38:05 +04001059 lg := installer.GitRepositoryLocalChartGenerator{"app", namespace}
giob4a3a192024-08-19 09:55:47 +04001060 var ret installer.ReleaseResources
1061 if _, err := repo.Do(func(r soft.RepoFS) (string, error) {
1062 ret, err = m.Install(
gio94904702024-07-26 16:58:34 +04001063 app,
1064 "app",
1065 "/.dodo/app",
1066 namespace,
1067 map[string]any{
1068 "repoAddr": repo.FullAddress(),
1069 "managerAddr": fmt.Sprintf("http://%s", s.self),
1070 "appId": name,
1071 "sshPrivateKey": s.sshKey,
1072 },
1073 installer.WithNoPull(),
1074 installer.WithNoPublish(),
1075 installer.WithConfig(&s.env),
1076 installer.WithNetworks(networks),
1077 installer.WithLocalChartGenerator(lg),
1078 installer.WithNoLock(),
1079 )
1080 if err != nil {
1081 return "", err
1082 }
1083 var rendered dodoAppRendered
giob4a3a192024-08-19 09:55:47 +04001084 if err := json.NewDecoder(bytes.NewReader(ret.RenderedRaw)).Decode(&rendered); err != nil {
gio94904702024-07-26 16:58:34 +04001085 return "", nil
1086 }
1087 if _, err := m.Install(
1088 appStatus,
1089 "status",
1090 "/.dodo/status",
1091 s.namespace,
1092 map[string]any{
1093 "appName": rendered.Input.AppId,
1094 "network": rendered.App.Ingress.Network,
1095 "appSubdomain": rendered.App.Ingress.Subdomain,
1096 },
1097 installer.WithNoPull(),
1098 installer.WithNoPublish(),
1099 installer.WithConfig(&s.env),
1100 installer.WithNetworks(networks),
1101 installer.WithLocalChartGenerator(lg),
1102 installer.WithNoLock(),
1103 ); err != nil {
1104 return "", err
1105 }
1106 return "install app", nil
1107 },
1108 soft.WithCommitToBranch("dodo"),
1109 soft.WithForce(),
giob4a3a192024-08-19 09:55:47 +04001110 ); err != nil {
1111 return installer.ReleaseResources{}, err
1112 }
gio43b0f422024-08-21 10:40:13 +04001113 ctx, _ := context.WithTimeout(context.Background(), 2*time.Minute)
1114 go s.reconciler.Reconcile(ctx, namespace, "app")
giob4a3a192024-08-19 09:55:47 +04001115 return ret, nil
gio0eaf2712024-04-14 13:08:46 +04001116}
gio33059762024-07-05 13:19:07 +04001117
giob4a3a192024-08-19 09:55:47 +04001118func (s *DodoAppServer) initRepo(repo soft.RepoIO, appType string, network installer.Network, subdomain string) (string, error) {
giob54db242024-07-30 18:49:33 +04001119 appType = strings.Replace(appType, ":", "-", 1)
gio5e49bb62024-07-20 10:43:19 +04001120 appTmpl, err := s.appTmpls.Find(appType)
1121 if err != nil {
giob4a3a192024-08-19 09:55:47 +04001122 return "", err
gio33059762024-07-05 13:19:07 +04001123 }
gio33059762024-07-05 13:19:07 +04001124 return repo.Do(func(fs soft.RepoFS) (string, error) {
gio5e49bb62024-07-20 10:43:19 +04001125 if err := appTmpl.Render(network, subdomain, repo); err != nil {
1126 return "", err
gio33059762024-07-05 13:19:07 +04001127 }
giob4a3a192024-08-19 09:55:47 +04001128 return initCommitMsg, nil
gio33059762024-07-05 13:19:07 +04001129 })
1130}
gio81246f02024-07-10 12:02:15 +04001131
1132func generatePassword() string {
1133 return "foo"
1134}
giocb34ad22024-07-11 08:01:13 +04001135
gio11617ac2024-07-15 16:09:04 +04001136func (s *DodoAppServer) getNetworks(user string) ([]installer.Network, error) {
gio23bdc1b2024-07-11 16:07:47 +04001137 addr := fmt.Sprintf("%s/api/networks", s.envAppManagerAddr)
giocb34ad22024-07-11 08:01:13 +04001138 resp, err := http.Get(addr)
1139 if err != nil {
1140 return nil, err
1141 }
gio23bdc1b2024-07-11 16:07:47 +04001142 networks := []installer.Network{}
1143 if json.NewDecoder(resp.Body).Decode(&networks); err != nil {
giocb34ad22024-07-11 08:01:13 +04001144 return nil, err
1145 }
gio11617ac2024-07-15 16:09:04 +04001146 return s.nf.Filter(user, networks)
1147}
1148
gio8fae3af2024-07-25 13:43:31 +04001149type publicNetworkData struct {
1150 Name string `json:"name"`
1151 Domain string `json:"domain"`
1152}
1153
1154type publicData struct {
1155 Networks []publicNetworkData `json:"networks"`
1156 Types []string `json:"types"`
1157}
1158
1159func (s *DodoAppServer) handleAPIPublicData(w http.ResponseWriter, r *http.Request) {
giod8ab4f52024-07-26 16:58:34 +04001160 w.Header().Set("Access-Control-Allow-Origin", "*")
1161 s.l.Lock()
1162 defer s.l.Unlock()
gio8fae3af2024-07-25 13:43:31 +04001163 networks, err := s.getNetworks("")
1164 if err != nil {
1165 http.Error(w, err.Error(), http.StatusInternalServerError)
1166 return
1167 }
1168 var ret publicData
1169 for _, n := range networks {
giod8ab4f52024-07-26 16:58:34 +04001170 if s.isNetworkUseAllowed(strings.ToLower(n.Name)) {
1171 ret.Networks = append(ret.Networks, publicNetworkData{n.Name, n.Domain})
1172 }
gio8fae3af2024-07-25 13:43:31 +04001173 }
1174 for _, t := range s.appTmpls.Types() {
giob54db242024-07-30 18:49:33 +04001175 ret.Types = append(ret.Types, strings.Replace(t, "-", ":", 1))
gio8fae3af2024-07-25 13:43:31 +04001176 }
gio8fae3af2024-07-25 13:43:31 +04001177 if err := json.NewEncoder(w).Encode(ret); err != nil {
1178 http.Error(w, err.Error(), http.StatusInternalServerError)
1179 return
1180 }
1181}
1182
giob4a3a192024-08-19 09:55:47 +04001183func (s *DodoAppServer) createCommit(name, hash, message string, err error, resources installer.ReleaseResources) error {
1184 if err != nil {
1185 fmt.Printf("Error: %s\n", err.Error())
1186 if err := s.st.CreateCommit(name, hash, message, "FAILED", err.Error(), nil); err != nil {
1187 fmt.Printf("Error: %s\n", err.Error())
1188 return err
1189 }
1190 return err
1191 }
1192 var resB bytes.Buffer
1193 if err := json.NewEncoder(&resB).Encode(resources); err != nil {
1194 if err := s.st.CreateCommit(name, hash, message, "FAILED", err.Error(), nil); err != nil {
1195 fmt.Printf("Error: %s\n", err.Error())
1196 return err
1197 }
1198 return err
1199 }
1200 if err := s.st.CreateCommit(name, hash, message, "OK", "", resB.Bytes()); err != nil {
1201 fmt.Printf("Error: %s\n", err.Error())
1202 return err
1203 }
1204 return nil
1205}
1206
gio11617ac2024-07-15 16:09:04 +04001207func pickNetwork(networks []installer.Network, network string) []installer.Network {
1208 for _, n := range networks {
1209 if n.Name == network {
1210 return []installer.Network{n}
1211 }
1212 }
1213 return []installer.Network{}
1214}
1215
1216type NetworkFilter interface {
1217 Filter(user string, networks []installer.Network) ([]installer.Network, error)
1218}
1219
1220type noNetworkFilter struct{}
1221
1222func NewNoNetworkFilter() NetworkFilter {
1223 return noNetworkFilter{}
1224}
1225
gio8fae3af2024-07-25 13:43:31 +04001226func (f noNetworkFilter) Filter(user string, networks []installer.Network) ([]installer.Network, error) {
gio11617ac2024-07-15 16:09:04 +04001227 return networks, nil
1228}
1229
1230type filterByOwner struct {
1231 st Store
1232}
1233
1234func NewNetworkFilterByOwner(st Store) NetworkFilter {
1235 return &filterByOwner{st}
1236}
1237
1238func (f *filterByOwner) Filter(user string, networks []installer.Network) ([]installer.Network, error) {
gio8fae3af2024-07-25 13:43:31 +04001239 if user == "" {
1240 return networks, nil
1241 }
gio11617ac2024-07-15 16:09:04 +04001242 network, err := f.st.GetUserNetwork(user)
1243 if err != nil {
1244 return nil, err
gio23bdc1b2024-07-11 16:07:47 +04001245 }
1246 ret := []installer.Network{}
1247 for _, n := range networks {
gio11617ac2024-07-15 16:09:04 +04001248 if n.Name == network {
gio23bdc1b2024-07-11 16:07:47 +04001249 ret = append(ret, n)
1250 }
1251 }
giocb34ad22024-07-11 08:01:13 +04001252 return ret, nil
1253}
gio11617ac2024-07-15 16:09:04 +04001254
1255type allowListFilter struct {
1256 allowed []string
1257}
1258
1259func NewAllowListFilter(allowed []string) NetworkFilter {
1260 return &allowListFilter{allowed}
1261}
1262
gio8fae3af2024-07-25 13:43:31 +04001263func (f *allowListFilter) Filter(user string, networks []installer.Network) ([]installer.Network, error) {
gio11617ac2024-07-15 16:09:04 +04001264 ret := []installer.Network{}
1265 for _, n := range networks {
1266 if slices.Contains(f.allowed, n.Name) {
1267 ret = append(ret, n)
1268 }
1269 }
1270 return ret, nil
1271}
1272
1273type combinedNetworkFilter struct {
1274 filters []NetworkFilter
1275}
1276
1277func NewCombinedFilter(filters ...NetworkFilter) NetworkFilter {
1278 return &combinedNetworkFilter{filters}
1279}
1280
gio8fae3af2024-07-25 13:43:31 +04001281func (f *combinedNetworkFilter) Filter(user string, networks []installer.Network) ([]installer.Network, error) {
gio11617ac2024-07-15 16:09:04 +04001282 ret := networks
1283 var err error
1284 for _, f := range f.filters {
gio8fae3af2024-07-25 13:43:31 +04001285 ret, err = f.Filter(user, ret)
gio11617ac2024-07-15 16:09:04 +04001286 if err != nil {
1287 return nil, err
1288 }
1289 }
1290 return ret, nil
1291}
giocafd4e62024-07-31 10:53:40 +04001292
1293type user struct {
1294 Username string `json:"username"`
1295 Email string `json:"email"`
1296 SSHPublicKeys []string `json:"sshPublicKeys,omitempty"`
1297}
1298
1299func (s *DodoAppServer) handleAPISyncUsers(_ http.ResponseWriter, _ *http.Request) {
1300 go s.syncUsers()
1301}
1302
1303func (s *DodoAppServer) syncUsers() {
1304 if s.external {
1305 panic("MUST NOT REACH!")
1306 }
1307 resp, err := http.Get(fmt.Sprintf("%s?selfAddress=%s/api/sync-users", s.fetchUsersAddr, s.self))
1308 if err != nil {
1309 return
1310 }
1311 users := []user{}
1312 if err := json.NewDecoder(resp.Body).Decode(&users); err != nil {
1313 fmt.Println(err)
1314 return
1315 }
Davit Tabidzea5ea5092024-08-01 15:28:09 +04001316 validUsernames := make(map[string]user)
1317 for _, u := range users {
1318 validUsernames[u.Username] = u
1319 }
1320 allClientUsers, err := s.client.GetAllUsers()
1321 if err != nil {
1322 fmt.Println(err)
1323 return
1324 }
1325 keyToUser := make(map[string]string)
1326 for _, clientUser := range allClientUsers {
Davit Tabidze4aaa27b2024-08-05 20:23:50 +04001327 if clientUser == "admin" || clientUser == "fluxcd" {
1328 continue
1329 }
Davit Tabidzea5ea5092024-08-01 15:28:09 +04001330 userData, ok := validUsernames[clientUser]
1331 if !ok {
1332 if err := s.client.RemoveUser(clientUser); err != nil {
1333 fmt.Println(err)
1334 return
1335 }
1336 } else {
1337 existingKeys, err := s.client.GetUserPublicKeys(clientUser)
1338 if err != nil {
1339 fmt.Println(err)
1340 return
1341 }
1342 for _, existingKey := range existingKeys {
Davit Tabidze4aaa27b2024-08-05 20:23:50 +04001343 cleanKey := soft.CleanKey(existingKey)
Davit Tabidzea5ea5092024-08-01 15:28:09 +04001344 keyOk := slices.ContainsFunc(userData.SSHPublicKeys, func(key string) bool {
Davit Tabidze4aaa27b2024-08-05 20:23:50 +04001345 return cleanKey == soft.CleanKey(key)
Davit Tabidzea5ea5092024-08-01 15:28:09 +04001346 })
1347 if !keyOk {
1348 if err := s.client.RemovePublicKey(clientUser, existingKey); err != nil {
1349 fmt.Println(err)
1350 }
1351 } else {
1352 keyToUser[cleanKey] = clientUser
1353 }
1354 }
1355 }
1356 }
giocafd4e62024-07-31 10:53:40 +04001357 for _, u := range users {
Davit Tabidze4aaa27b2024-08-05 20:23:50 +04001358 if err := s.st.CreateUser(u.Username, nil, ""); err != nil && !errors.Is(err, ErrorAlreadyExists) {
1359 fmt.Println(err)
1360 return
1361 }
giocafd4e62024-07-31 10:53:40 +04001362 if len(u.SSHPublicKeys) == 0 {
1363 continue
1364 }
Davit Tabidzea5ea5092024-08-01 15:28:09 +04001365 ok, err := s.client.UserExists(u.Username)
1366 if err != nil {
giocafd4e62024-07-31 10:53:40 +04001367 fmt.Println(err)
1368 return
Davit Tabidzea5ea5092024-08-01 15:28:09 +04001369 }
1370 if !ok {
1371 if err := s.client.AddUser(u.Username, u.SSHPublicKeys[0]); err != nil {
1372 fmt.Println(err)
1373 return
1374 }
1375 } else {
1376 for _, key := range u.SSHPublicKeys {
Davit Tabidze4aaa27b2024-08-05 20:23:50 +04001377 cleanKey := soft.CleanKey(key)
1378 if user, ok := keyToUser[cleanKey]; ok {
1379 if u.Username != user {
1380 panic("MUST NOT REACH! IMPOSSIBLE KEY USER RECORD")
1381 }
1382 continue
Davit Tabidzea5ea5092024-08-01 15:28:09 +04001383 }
Davit Tabidze4aaa27b2024-08-05 20:23:50 +04001384 if err := s.client.AddPublicKey(u.Username, cleanKey); err != nil {
Davit Tabidzea5ea5092024-08-01 15:28:09 +04001385 fmt.Println(err)
1386 return
giocafd4e62024-07-31 10:53:40 +04001387 }
1388 }
1389 }
1390 }
1391 repos, err := s.client.GetAllRepos()
1392 if err != nil {
1393 return
1394 }
1395 for _, r := range repos {
1396 if r == ConfigRepoName {
1397 continue
1398 }
1399 for _, u := range users {
1400 if err := s.client.AddReadWriteCollaborator(r, u.Username); err != nil {
1401 fmt.Println(err)
Davit Tabidze4aaa27b2024-08-05 20:23:50 +04001402 continue
giocafd4e62024-07-31 10:53:40 +04001403 }
1404 }
1405 }
1406}
giob4a3a192024-08-19 09:55:47 +04001407
1408func extractResourceData(resources []installer.Resource) (resourceData, error) {
1409 var ret resourceData
1410 for _, r := range resources {
1411 t, ok := r.Annotations["dodo.cloud/resource-type"]
1412 if !ok {
1413 continue
1414 }
1415 switch t {
1416 case "volume":
1417 name, ok := r.Annotations["dodo.cloud/resource.volume.name"]
1418 if !ok {
1419 return resourceData{}, fmt.Errorf("no name")
1420 }
1421 size, ok := r.Annotations["dodo.cloud/resource.volume.size"]
1422 if !ok {
1423 return resourceData{}, fmt.Errorf("no size")
1424 }
1425 ret.Volume = append(ret.Volume, volume{name, size})
1426 case "postgresql":
1427 name, ok := r.Annotations["dodo.cloud/resource.postgresql.name"]
1428 if !ok {
1429 return resourceData{}, fmt.Errorf("no name")
1430 }
1431 version, ok := r.Annotations["dodo.cloud/resource.postgresql.version"]
1432 if !ok {
1433 return resourceData{}, fmt.Errorf("no version")
1434 }
1435 volume, ok := r.Annotations["dodo.cloud/resource.postgresql.volume"]
1436 if !ok {
1437 return resourceData{}, fmt.Errorf("no volume")
1438 }
1439 ret.PostgreSQL = append(ret.PostgreSQL, postgresql{name, version, volume})
1440 case "ingress":
1441 host, ok := r.Annotations["dodo.cloud/resource.ingress.host"]
1442 if !ok {
1443 return resourceData{}, fmt.Errorf("no host")
1444 }
1445 ret.Ingress = append(ret.Ingress, ingress{host})
1446 default:
1447 fmt.Printf("Unknown resource: %+v\n", r.Annotations)
1448 }
1449 }
1450 return ret, nil
1451}