blob: 462a95dba5e1c7a8acb7da779a2ad1aa8911b2b4 [file] [log] [blame]
gio0eaf2712024-04-14 13:08:46 +04001package welcome
2
3import (
gio81246f02024-07-10 12:02:15 +04004 "context"
gio23bdc1b2024-07-11 16:07:47 +04005 "embed"
gio0eaf2712024-04-14 13:08:46 +04006 "encoding/json"
gio9d66f322024-07-06 13:45:10 +04007 "errors"
gio0eaf2712024-04-14 13:08:46 +04008 "fmt"
gio81246f02024-07-10 12:02:15 +04009 "golang.org/x/crypto/bcrypt"
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"
gio0eaf2712024-04-14 13:08:46 +040017
18 "github.com/giolekva/pcloud/core/installer"
19 "github.com/giolekva/pcloud/core/installer/soft"
gio33059762024-07-05 13:19:07 +040020
21 "github.com/gorilla/mux"
gio81246f02024-07-10 12:02:15 +040022 "github.com/gorilla/securecookie"
gio0eaf2712024-04-14 13:08:46 +040023)
24
gio23bdc1b2024-07-11 16:07:47 +040025//go:embed dodo-app-tmpl/*
26var dodoAppTmplFS embed.FS
27
gio5e49bb62024-07-20 10:43:19 +040028//go:embed all:app-tmpl
29var appTmplsFS embed.FS
30
31//go:embed static
32var staticResources embed.FS
33
gio9d66f322024-07-06 13:45:10 +040034const (
gioa60f0de2024-07-08 10:49:48 +040035 ConfigRepoName = "config"
giod8ab4f52024-07-26 16:58:34 +040036 appConfigsFile = "/apps.json"
gio81246f02024-07-10 12:02:15 +040037 loginPath = "/login"
38 logoutPath = "/logout"
gio5e49bb62024-07-20 10:43:19 +040039 staticPath = "/static"
gio8fae3af2024-07-25 13:43:31 +040040 apiPublicData = "/api/public-data"
41 apiCreateApp = "/api/apps"
gio81246f02024-07-10 12:02:15 +040042 sessionCookie = "dodo-app-session"
43 userCtx = "user"
gio9d66f322024-07-06 13:45:10 +040044)
45
gio5e49bb62024-07-20 10:43:19 +040046var types = []string{"golang:1.22.0", "golang:1.20.0", "hugo:latest"}
47
gio23bdc1b2024-07-11 16:07:47 +040048type dodoAppTmplts struct {
gio5e49bb62024-07-20 10:43:19 +040049 index *template.Template
50 appStatus *template.Template
gio23bdc1b2024-07-11 16:07:47 +040051}
52
53func parseTemplatesDodoApp(fs embed.FS) (dodoAppTmplts, error) {
gio5e49bb62024-07-20 10:43:19 +040054 base, err := template.ParseFS(fs, "dodo-app-tmpl/base.html")
gio23bdc1b2024-07-11 16:07:47 +040055 if err != nil {
56 return dodoAppTmplts{}, err
57 }
gio5e49bb62024-07-20 10:43:19 +040058 parse := func(path string) (*template.Template, error) {
59 if b, err := base.Clone(); err != nil {
60 return nil, err
61 } else {
62 return b.ParseFS(fs, path)
63 }
64 }
65 index, err := parse("dodo-app-tmpl/index.html")
66 if err != nil {
67 return dodoAppTmplts{}, err
68 }
69 appStatus, err := parse("dodo-app-tmpl/app_status.html")
70 if err != nil {
71 return dodoAppTmplts{}, err
72 }
73 return dodoAppTmplts{index, appStatus}, nil
gio23bdc1b2024-07-11 16:07:47 +040074}
75
gio0eaf2712024-04-14 13:08:46 +040076type DodoAppServer struct {
giocb34ad22024-07-11 08:01:13 +040077 l sync.Locker
78 st Store
gio11617ac2024-07-15 16:09:04 +040079 nf NetworkFilter
80 ug UserGetter
giocb34ad22024-07-11 08:01:13 +040081 port int
82 apiPort int
83 self string
gio11617ac2024-07-15 16:09:04 +040084 repoPublicAddr string
giocb34ad22024-07-11 08:01:13 +040085 sshKey string
86 gitRepoPublicKey string
87 client soft.Client
88 namespace string
89 envAppManagerAddr string
90 env installer.EnvConfig
91 nsc installer.NamespaceCreator
92 jc installer.JobCreator
93 workers map[string]map[string]struct{}
giod8ab4f52024-07-26 16:58:34 +040094 appConfigs map[string]appConfig
gio23bdc1b2024-07-11 16:07:47 +040095 tmplts dodoAppTmplts
gio5e49bb62024-07-20 10:43:19 +040096 appTmpls AppTmplStore
giod8ab4f52024-07-26 16:58:34 +040097 allowNetworkReuse bool
98}
99
100type appConfig struct {
101 Namespace string `json:"namespace"`
102 Network string `json:"network"`
gio0eaf2712024-04-14 13:08:46 +0400103}
104
gio33059762024-07-05 13:19:07 +0400105// TODO(gio): Initialize appNs on startup
gio0eaf2712024-04-14 13:08:46 +0400106func NewDodoAppServer(
gioa60f0de2024-07-08 10:49:48 +0400107 st Store,
gio11617ac2024-07-15 16:09:04 +0400108 nf NetworkFilter,
109 ug UserGetter,
gio0eaf2712024-04-14 13:08:46 +0400110 port int,
gioa60f0de2024-07-08 10:49:48 +0400111 apiPort int,
gio33059762024-07-05 13:19:07 +0400112 self string,
gio11617ac2024-07-15 16:09:04 +0400113 repoPublicAddr string,
gio0eaf2712024-04-14 13:08:46 +0400114 sshKey string,
gio33059762024-07-05 13:19:07 +0400115 gitRepoPublicKey string,
gio0eaf2712024-04-14 13:08:46 +0400116 client soft.Client,
117 namespace string,
giocb34ad22024-07-11 08:01:13 +0400118 envAppManagerAddr string,
gio33059762024-07-05 13:19:07 +0400119 nsc installer.NamespaceCreator,
giof8843412024-05-22 16:38:05 +0400120 jc installer.JobCreator,
gio0eaf2712024-04-14 13:08:46 +0400121 env installer.EnvConfig,
giod8ab4f52024-07-26 16:58:34 +0400122 allowNetworkReuse bool,
gio9d66f322024-07-06 13:45:10 +0400123) (*DodoAppServer, error) {
gio23bdc1b2024-07-11 16:07:47 +0400124 tmplts, err := parseTemplatesDodoApp(dodoAppTmplFS)
125 if err != nil {
126 return nil, err
127 }
gio5e49bb62024-07-20 10:43:19 +0400128 apps, err := fs.Sub(appTmplsFS, "app-tmpl")
129 if err != nil {
130 return nil, err
131 }
132 appTmpls, err := NewAppTmplStoreFS(apps)
133 if err != nil {
134 return nil, err
135 }
gio9d66f322024-07-06 13:45:10 +0400136 s := &DodoAppServer{
137 &sync.Mutex{},
gioa60f0de2024-07-08 10:49:48 +0400138 st,
gio11617ac2024-07-15 16:09:04 +0400139 nf,
140 ug,
gio0eaf2712024-04-14 13:08:46 +0400141 port,
gioa60f0de2024-07-08 10:49:48 +0400142 apiPort,
gio33059762024-07-05 13:19:07 +0400143 self,
gio11617ac2024-07-15 16:09:04 +0400144 repoPublicAddr,
gio0eaf2712024-04-14 13:08:46 +0400145 sshKey,
gio33059762024-07-05 13:19:07 +0400146 gitRepoPublicKey,
gio0eaf2712024-04-14 13:08:46 +0400147 client,
148 namespace,
giocb34ad22024-07-11 08:01:13 +0400149 envAppManagerAddr,
gio0eaf2712024-04-14 13:08:46 +0400150 env,
gio33059762024-07-05 13:19:07 +0400151 nsc,
giof8843412024-05-22 16:38:05 +0400152 jc,
gio266c04f2024-07-03 14:18:45 +0400153 map[string]map[string]struct{}{},
giod8ab4f52024-07-26 16:58:34 +0400154 map[string]appConfig{},
gio23bdc1b2024-07-11 16:07:47 +0400155 tmplts,
gio5e49bb62024-07-20 10:43:19 +0400156 appTmpls,
giod8ab4f52024-07-26 16:58:34 +0400157 allowNetworkReuse,
gio0eaf2712024-04-14 13:08:46 +0400158 }
gioa60f0de2024-07-08 10:49:48 +0400159 config, err := client.GetRepo(ConfigRepoName)
gio9d66f322024-07-06 13:45:10 +0400160 if err != nil {
161 return nil, err
162 }
giod8ab4f52024-07-26 16:58:34 +0400163 r, err := config.Reader(appConfigsFile)
gio9d66f322024-07-06 13:45:10 +0400164 if err == nil {
165 defer r.Close()
giod8ab4f52024-07-26 16:58:34 +0400166 if err := json.NewDecoder(r).Decode(&s.appConfigs); err != nil {
gio9d66f322024-07-06 13:45:10 +0400167 return nil, err
168 }
169 } else if !errors.Is(err, fs.ErrNotExist) {
170 return nil, err
171 }
172 return s, nil
gio0eaf2712024-04-14 13:08:46 +0400173}
174
175func (s *DodoAppServer) Start() error {
gioa60f0de2024-07-08 10:49:48 +0400176 e := make(chan error)
177 go func() {
178 r := mux.NewRouter()
gio81246f02024-07-10 12:02:15 +0400179 r.Use(s.mwAuth)
gio5e49bb62024-07-20 10:43:19 +0400180 r.PathPrefix(staticPath).Handler(http.FileServer(http.FS(staticResources)))
gio81246f02024-07-10 12:02:15 +0400181 r.HandleFunc(logoutPath, s.handleLogout).Methods(http.MethodGet)
gio8fae3af2024-07-25 13:43:31 +0400182 r.HandleFunc(apiPublicData, s.handleAPIPublicData)
183 r.HandleFunc(apiCreateApp, s.handleAPICreateApp).Methods(http.MethodPost)
gio81246f02024-07-10 12:02:15 +0400184 r.HandleFunc("/{app-name}"+loginPath, s.handleLoginForm).Methods(http.MethodGet)
185 r.HandleFunc("/{app-name}"+loginPath, s.handleLogin).Methods(http.MethodPost)
186 r.HandleFunc("/{app-name}", s.handleAppStatus).Methods(http.MethodGet)
187 r.HandleFunc("/", s.handleStatus).Methods(http.MethodGet)
gio11617ac2024-07-15 16:09:04 +0400188 r.HandleFunc("/", s.handleCreateApp).Methods(http.MethodPost)
gioa60f0de2024-07-08 10:49:48 +0400189 e <- http.ListenAndServe(fmt.Sprintf(":%d", s.port), r)
190 }()
191 go func() {
192 r := mux.NewRouter()
gio8fae3af2024-07-25 13:43:31 +0400193 r.HandleFunc("/update", s.handleAPIUpdate)
194 r.HandleFunc("/api/apps/{app-name}/workers", s.handleAPIRegisterWorker).Methods(http.MethodPost)
195 r.HandleFunc("/api/add-admin-key", s.handleAPIAddAdminKey).Methods(http.MethodPost)
gioa60f0de2024-07-08 10:49:48 +0400196 e <- http.ListenAndServe(fmt.Sprintf(":%d", s.apiPort), r)
197 }()
198 return <-e
199}
200
gio11617ac2024-07-15 16:09:04 +0400201type UserGetter interface {
202 Get(r *http.Request) string
gio8fae3af2024-07-25 13:43:31 +0400203 Encode(w http.ResponseWriter, user string) error
gio11617ac2024-07-15 16:09:04 +0400204}
205
206type externalUserGetter struct {
207 sc *securecookie.SecureCookie
208}
209
210func NewExternalUserGetter() UserGetter {
gio8fae3af2024-07-25 13:43:31 +0400211 return &externalUserGetter{securecookie.New(
212 securecookie.GenerateRandomKey(64),
213 securecookie.GenerateRandomKey(32),
214 )}
gio11617ac2024-07-15 16:09:04 +0400215}
216
217func (ug *externalUserGetter) Get(r *http.Request) string {
218 cookie, err := r.Cookie(sessionCookie)
219 if err != nil {
220 return ""
221 }
222 var user string
223 if err := ug.sc.Decode(sessionCookie, cookie.Value, &user); err != nil {
224 return ""
225 }
226 return user
227}
228
gio8fae3af2024-07-25 13:43:31 +0400229func (ug *externalUserGetter) Encode(w http.ResponseWriter, user string) error {
230 if encoded, err := ug.sc.Encode(sessionCookie, user); err == nil {
231 cookie := &http.Cookie{
232 Name: sessionCookie,
233 Value: encoded,
234 Path: "/",
235 Secure: true,
236 HttpOnly: true,
237 }
238 http.SetCookie(w, cookie)
239 return nil
240 } else {
241 return err
242 }
243}
244
gio11617ac2024-07-15 16:09:04 +0400245type internalUserGetter struct{}
246
247func NewInternalUserGetter() UserGetter {
248 return internalUserGetter{}
249}
250
251func (ug internalUserGetter) Get(r *http.Request) string {
252 return r.Header.Get("X-User")
253}
254
gio8fae3af2024-07-25 13:43:31 +0400255func (ug internalUserGetter) Encode(w http.ResponseWriter, user string) error {
256 return nil
257}
258
gio81246f02024-07-10 12:02:15 +0400259func (s *DodoAppServer) mwAuth(next http.Handler) http.Handler {
260 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gio8fae3af2024-07-25 13:43:31 +0400261 if strings.HasSuffix(r.URL.Path, loginPath) ||
262 strings.HasPrefix(r.URL.Path, logoutPath) ||
263 strings.HasPrefix(r.URL.Path, staticPath) ||
264 strings.HasPrefix(r.URL.Path, apiPublicData) ||
265 strings.HasPrefix(r.URL.Path, apiCreateApp) {
gio81246f02024-07-10 12:02:15 +0400266 next.ServeHTTP(w, r)
267 return
268 }
gio11617ac2024-07-15 16:09:04 +0400269 user := s.ug.Get(r)
270 if user == "" {
gio81246f02024-07-10 12:02:15 +0400271 vars := mux.Vars(r)
272 appName, ok := vars["app-name"]
273 if !ok || appName == "" {
274 http.Error(w, "missing app-name", http.StatusBadRequest)
275 return
276 }
277 http.Redirect(w, r, fmt.Sprintf("/%s%s", appName, loginPath), http.StatusSeeOther)
278 return
279 }
gio81246f02024-07-10 12:02:15 +0400280 next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), userCtx, user)))
281 })
282}
283
284func (s *DodoAppServer) handleLogout(w http.ResponseWriter, r *http.Request) {
gio8fae3af2024-07-25 13:43:31 +0400285 // TODO(gio): move to UserGetter
gio81246f02024-07-10 12:02:15 +0400286 http.SetCookie(w, &http.Cookie{
287 Name: sessionCookie,
288 Value: "",
289 Path: "/",
290 HttpOnly: true,
291 Secure: true,
292 })
293 http.Redirect(w, r, "/", http.StatusSeeOther)
294}
295
296func (s *DodoAppServer) handleLoginForm(w http.ResponseWriter, r *http.Request) {
297 vars := mux.Vars(r)
298 appName, ok := vars["app-name"]
299 if !ok || appName == "" {
300 http.Error(w, "missing app-name", http.StatusBadRequest)
301 return
302 }
303 fmt.Fprint(w, `
304<!DOCTYPE html>
305<html lang='en'>
306 <head>
307 <title>dodo: app - login</title>
308 <meta charset='utf-8'>
309 </head>
310 <body>
311 <form action="" method="POST">
312 <input type="password" placeholder="Password" name="password" required />
313 <button type="submit">Login</button>
314 </form>
315 </body>
316</html>
317`)
318}
319
320func (s *DodoAppServer) handleLogin(w http.ResponseWriter, r *http.Request) {
321 vars := mux.Vars(r)
322 appName, ok := vars["app-name"]
323 if !ok || appName == "" {
324 http.Error(w, "missing app-name", http.StatusBadRequest)
325 return
326 }
327 password := r.FormValue("password")
328 if password == "" {
329 http.Error(w, "missing password", http.StatusBadRequest)
330 return
331 }
332 user, err := s.st.GetAppOwner(appName)
333 if err != nil {
334 http.Error(w, err.Error(), http.StatusInternalServerError)
335 return
336 }
337 hashed, err := s.st.GetUserPassword(user)
338 if err != nil {
339 http.Error(w, err.Error(), http.StatusInternalServerError)
340 return
341 }
342 if err := bcrypt.CompareHashAndPassword(hashed, []byte(password)); err != nil {
343 http.Redirect(w, r, r.URL.Path, http.StatusSeeOther)
344 return
345 }
gio8fae3af2024-07-25 13:43:31 +0400346 if err := s.ug.Encode(w, user); err != nil {
347 http.Error(w, err.Error(), http.StatusInternalServerError)
348 return
gio81246f02024-07-10 12:02:15 +0400349 }
350 http.Redirect(w, r, fmt.Sprintf("/%s", appName), http.StatusSeeOther)
351}
352
gio23bdc1b2024-07-11 16:07:47 +0400353type statusData struct {
gio11617ac2024-07-15 16:09:04 +0400354 Apps []string
355 Networks []installer.Network
gio5e49bb62024-07-20 10:43:19 +0400356 Types []string
gio23bdc1b2024-07-11 16:07:47 +0400357}
358
gioa60f0de2024-07-08 10:49:48 +0400359func (s *DodoAppServer) handleStatus(w http.ResponseWriter, r *http.Request) {
gio81246f02024-07-10 12:02:15 +0400360 user := r.Context().Value(userCtx)
361 if user == nil {
362 http.Error(w, "unauthorized", http.StatusUnauthorized)
363 return
364 }
365 apps, err := s.st.GetUserApps(user.(string))
gioa60f0de2024-07-08 10:49:48 +0400366 if err != nil {
367 http.Error(w, err.Error(), http.StatusInternalServerError)
368 return
369 }
gio11617ac2024-07-15 16:09:04 +0400370 networks, err := s.getNetworks(user.(string))
371 if err != nil {
372 http.Error(w, err.Error(), http.StatusInternalServerError)
373 return
374 }
gio5e49bb62024-07-20 10:43:19 +0400375 data := statusData{apps, networks, types}
gio23bdc1b2024-07-11 16:07:47 +0400376 if err := s.tmplts.index.Execute(w, data); err != nil {
377 http.Error(w, err.Error(), http.StatusInternalServerError)
378 return
gioa60f0de2024-07-08 10:49:48 +0400379 }
380}
381
gio5e49bb62024-07-20 10:43:19 +0400382type appStatusData struct {
383 Name string
384 GitCloneCommand string
385 Commits []Commit
386}
387
gioa60f0de2024-07-08 10:49:48 +0400388func (s *DodoAppServer) handleAppStatus(w http.ResponseWriter, r *http.Request) {
389 vars := mux.Vars(r)
390 appName, ok := vars["app-name"]
391 if !ok || appName == "" {
392 http.Error(w, "missing app-name", http.StatusBadRequest)
393 return
394 }
395 commits, err := s.st.GetCommitHistory(appName)
396 if err != nil {
397 http.Error(w, err.Error(), http.StatusInternalServerError)
398 return
399 }
gio5e49bb62024-07-20 10:43:19 +0400400 data := appStatusData{
401 Name: appName,
402 GitCloneCommand: fmt.Sprintf("git clone %s/%s\n\n\n", s.repoPublicAddr, appName),
403 Commits: commits,
404 }
405 if err := s.tmplts.appStatus.Execute(w, data); err != nil {
406 http.Error(w, err.Error(), http.StatusInternalServerError)
407 return
gioa60f0de2024-07-08 10:49:48 +0400408 }
gio0eaf2712024-04-14 13:08:46 +0400409}
410
gio81246f02024-07-10 12:02:15 +0400411type apiUpdateReq struct {
gio266c04f2024-07-03 14:18:45 +0400412 Ref string `json:"ref"`
413 Repository struct {
414 Name string `json:"name"`
415 } `json:"repository"`
gioa60f0de2024-07-08 10:49:48 +0400416 After string `json:"after"`
gio0eaf2712024-04-14 13:08:46 +0400417}
418
gio8fae3af2024-07-25 13:43:31 +0400419func (s *DodoAppServer) handleAPIUpdate(w http.ResponseWriter, r *http.Request) {
gio0eaf2712024-04-14 13:08:46 +0400420 fmt.Println("update")
gio81246f02024-07-10 12:02:15 +0400421 var req apiUpdateReq
gio0eaf2712024-04-14 13:08:46 +0400422 var contents strings.Builder
423 io.Copy(&contents, r.Body)
424 c := contents.String()
425 fmt.Println(c)
426 if err := json.NewDecoder(strings.NewReader(c)).Decode(&req); err != nil {
gio23bdc1b2024-07-11 16:07:47 +0400427 http.Error(w, err.Error(), http.StatusBadRequest)
gio0eaf2712024-04-14 13:08:46 +0400428 return
429 }
gioa60f0de2024-07-08 10:49:48 +0400430 if req.Ref != "refs/heads/master" || req.Repository.Name == ConfigRepoName {
gio0eaf2712024-04-14 13:08:46 +0400431 return
432 }
gioa60f0de2024-07-08 10:49:48 +0400433 // TODO(gio): Create commit record on app init as well
gio0eaf2712024-04-14 13:08:46 +0400434 go func() {
gio11617ac2024-07-15 16:09:04 +0400435 owner, err := s.st.GetAppOwner(req.Repository.Name)
436 if err != nil {
437 return
438 }
439 networks, err := s.getNetworks(owner)
giocb34ad22024-07-11 08:01:13 +0400440 if err != nil {
441 return
442 }
giod8ab4f52024-07-26 16:58:34 +0400443 if err := s.updateDodoApp(req.Repository.Name, s.appConfigs[req.Repository.Name].Namespace, networks); err != nil {
gioa60f0de2024-07-08 10:49:48 +0400444 if err := s.st.CreateCommit(req.Repository.Name, req.After, err.Error()); err != nil {
445 fmt.Printf("Error: %s\n", err.Error())
446 return
447 }
448 }
449 if err := s.st.CreateCommit(req.Repository.Name, req.After, "OK"); err != nil {
450 fmt.Printf("Error: %s\n", err.Error())
451 }
452 for addr, _ := range s.workers[req.Repository.Name] {
453 go func() {
454 // TODO(gio): make port configurable
455 http.Get(fmt.Sprintf("http://%s/update", addr))
456 }()
gio0eaf2712024-04-14 13:08:46 +0400457 }
458 }()
gio0eaf2712024-04-14 13:08:46 +0400459}
460
gio81246f02024-07-10 12:02:15 +0400461type apiRegisterWorkerReq struct {
gio0eaf2712024-04-14 13:08:46 +0400462 Address string `json:"address"`
463}
464
gio8fae3af2024-07-25 13:43:31 +0400465func (s *DodoAppServer) handleAPIRegisterWorker(w http.ResponseWriter, r *http.Request) {
gioa60f0de2024-07-08 10:49:48 +0400466 vars := mux.Vars(r)
467 appName, ok := vars["app-name"]
468 if !ok || appName == "" {
469 http.Error(w, "missing app-name", http.StatusBadRequest)
470 return
471 }
gio81246f02024-07-10 12:02:15 +0400472 var req apiRegisterWorkerReq
gio0eaf2712024-04-14 13:08:46 +0400473 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
474 http.Error(w, err.Error(), http.StatusInternalServerError)
475 return
476 }
gioa60f0de2024-07-08 10:49:48 +0400477 if _, ok := s.workers[appName]; !ok {
478 s.workers[appName] = map[string]struct{}{}
gio266c04f2024-07-03 14:18:45 +0400479 }
gioa60f0de2024-07-08 10:49:48 +0400480 s.workers[appName][req.Address] = struct{}{}
gio0eaf2712024-04-14 13:08:46 +0400481}
482
gio11617ac2024-07-15 16:09:04 +0400483func (s *DodoAppServer) handleCreateApp(w http.ResponseWriter, r *http.Request) {
484 u := r.Context().Value(userCtx)
485 if u == nil {
486 http.Error(w, "unauthorized", http.StatusUnauthorized)
487 return
488 }
489 user, ok := u.(string)
490 if !ok {
491 http.Error(w, "could not get user", http.StatusInternalServerError)
492 return
493 }
494 network := r.FormValue("network")
495 if network == "" {
496 http.Error(w, "missing network", http.StatusBadRequest)
497 return
498 }
gio5e49bb62024-07-20 10:43:19 +0400499 subdomain := r.FormValue("subdomain")
500 if subdomain == "" {
501 http.Error(w, "missing subdomain", http.StatusBadRequest)
502 return
503 }
504 appType := r.FormValue("type")
505 if appType == "" {
506 http.Error(w, "missing type", http.StatusBadRequest)
507 return
508 }
gio11617ac2024-07-15 16:09:04 +0400509 adminPublicKey := r.FormValue("admin-public-key")
gio5e49bb62024-07-20 10:43:19 +0400510 if adminPublicKey == "" {
gio11617ac2024-07-15 16:09:04 +0400511 http.Error(w, "missing admin public key", http.StatusBadRequest)
512 return
513 }
514 g := installer.NewFixedLengthRandomNameGenerator(3)
515 appName, err := g.Generate()
516 if err != nil {
517 http.Error(w, err.Error(), http.StatusInternalServerError)
518 return
519 }
520 if ok, err := s.client.UserExists(user); err != nil {
521 http.Error(w, err.Error(), http.StatusInternalServerError)
522 return
523 } else if !ok {
524 if err := s.client.AddUser(user, adminPublicKey); err != nil {
525 http.Error(w, err.Error(), http.StatusInternalServerError)
526 return
527 }
528 }
529 if err := s.st.CreateUser(user, nil, adminPublicKey, network); err != nil && !errors.Is(err, ErrorAlreadyExists) {
530 http.Error(w, err.Error(), http.StatusInternalServerError)
531 return
532 }
533 if err := s.st.CreateApp(appName, user); err != nil {
534 http.Error(w, err.Error(), http.StatusInternalServerError)
535 return
536 }
giod8ab4f52024-07-26 16:58:34 +0400537 if err := s.createApp(user, appName, appType, network, subdomain); err != nil {
gio11617ac2024-07-15 16:09:04 +0400538 http.Error(w, err.Error(), http.StatusInternalServerError)
539 return
540 }
541 http.Redirect(w, r, fmt.Sprintf("/%s", appName), http.StatusSeeOther)
542}
543
gio81246f02024-07-10 12:02:15 +0400544type apiCreateAppReq struct {
gio5e49bb62024-07-20 10:43:19 +0400545 AppType string `json:"type"`
gio33059762024-07-05 13:19:07 +0400546 AdminPublicKey string `json:"adminPublicKey"`
gio11617ac2024-07-15 16:09:04 +0400547 Network string `json:"network"`
gio5e49bb62024-07-20 10:43:19 +0400548 Subdomain string `json:"subdomain"`
gio33059762024-07-05 13:19:07 +0400549}
550
gio81246f02024-07-10 12:02:15 +0400551type apiCreateAppResp struct {
552 AppName string `json:"appName"`
553 Password string `json:"password"`
gio33059762024-07-05 13:19:07 +0400554}
555
gio8fae3af2024-07-25 13:43:31 +0400556func (s *DodoAppServer) handleAPICreateApp(w http.ResponseWriter, r *http.Request) {
giod8ab4f52024-07-26 16:58:34 +0400557 w.Header().Set("Access-Control-Allow-Origin", "*")
gio81246f02024-07-10 12:02:15 +0400558 var req apiCreateAppReq
gio33059762024-07-05 13:19:07 +0400559 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
560 http.Error(w, err.Error(), http.StatusBadRequest)
561 return
562 }
563 g := installer.NewFixedLengthRandomNameGenerator(3)
564 appName, err := g.Generate()
565 if err != nil {
566 http.Error(w, err.Error(), http.StatusInternalServerError)
567 return
568 }
gio11617ac2024-07-15 16:09:04 +0400569 user, err := s.client.FindUser(req.AdminPublicKey)
gio81246f02024-07-10 12:02:15 +0400570 if err != nil {
gio33059762024-07-05 13:19:07 +0400571 http.Error(w, err.Error(), http.StatusInternalServerError)
572 return
573 }
gio11617ac2024-07-15 16:09:04 +0400574 if user != "" {
575 http.Error(w, "public key already registered", http.StatusBadRequest)
576 return
577 }
578 user = appName
579 if err := s.client.AddUser(user, req.AdminPublicKey); err != nil {
580 http.Error(w, err.Error(), http.StatusInternalServerError)
581 return
582 }
583 password := generatePassword()
584 hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
585 if err != nil {
586 http.Error(w, err.Error(), http.StatusInternalServerError)
587 return
588 }
589 if err := s.st.CreateUser(user, hashed, req.AdminPublicKey, req.Network); err != nil {
590 http.Error(w, err.Error(), http.StatusInternalServerError)
591 return
592 }
593 if err := s.st.CreateApp(appName, user); err != nil {
594 http.Error(w, err.Error(), http.StatusInternalServerError)
595 return
596 }
giod8ab4f52024-07-26 16:58:34 +0400597 if err := s.createApp(user, appName, req.AppType, req.Network, req.Subdomain); err != nil {
gio11617ac2024-07-15 16:09:04 +0400598 http.Error(w, err.Error(), http.StatusInternalServerError)
599 return
600 }
gio81246f02024-07-10 12:02:15 +0400601 resp := apiCreateAppResp{
602 AppName: appName,
603 Password: password,
604 }
gio33059762024-07-05 13:19:07 +0400605 if err := json.NewEncoder(w).Encode(resp); err != nil {
606 http.Error(w, err.Error(), http.StatusInternalServerError)
607 return
608 }
609}
610
giod8ab4f52024-07-26 16:58:34 +0400611func (s *DodoAppServer) isNetworkUseAllowed(network string) bool {
612 if s.allowNetworkReuse {
613 return true
614 }
615 for _, cfg := range s.appConfigs {
616 if strings.ToLower(cfg.Network) == network {
617 return false
618 }
619 }
620 return true
621}
622
623func (s *DodoAppServer) createApp(user, appName, appType, network, subdomain string) error {
gio9d66f322024-07-06 13:45:10 +0400624 s.l.Lock()
625 defer s.l.Unlock()
gio33059762024-07-05 13:19:07 +0400626 fmt.Printf("Creating app: %s\n", appName)
giod8ab4f52024-07-26 16:58:34 +0400627 network = strings.ToLower(network)
628 if !s.isNetworkUseAllowed(network) {
629 return fmt.Errorf("network already used: %s", network)
630 }
gio33059762024-07-05 13:19:07 +0400631 if ok, err := s.client.RepoExists(appName); err != nil {
gio11617ac2024-07-15 16:09:04 +0400632 return err
gio33059762024-07-05 13:19:07 +0400633 } else if ok {
gio11617ac2024-07-15 16:09:04 +0400634 return nil
gioa60f0de2024-07-08 10:49:48 +0400635 }
gio5e49bb62024-07-20 10:43:19 +0400636 networks, err := s.getNetworks(user)
637 if err != nil {
638 return err
639 }
giod8ab4f52024-07-26 16:58:34 +0400640 n, ok := installer.NetworkMap(networks)[network]
gio5e49bb62024-07-20 10:43:19 +0400641 if !ok {
642 return fmt.Errorf("network not found: %s\n", network)
643 }
gio33059762024-07-05 13:19:07 +0400644 if err := s.client.AddRepository(appName); err != nil {
gio11617ac2024-07-15 16:09:04 +0400645 return err
gio33059762024-07-05 13:19:07 +0400646 }
647 appRepo, err := s.client.GetRepo(appName)
648 if err != nil {
gio11617ac2024-07-15 16:09:04 +0400649 return err
gio33059762024-07-05 13:19:07 +0400650 }
gio5e49bb62024-07-20 10:43:19 +0400651 if err := s.initRepo(appRepo, appType, n, subdomain); err != nil {
gio11617ac2024-07-15 16:09:04 +0400652 return err
gio33059762024-07-05 13:19:07 +0400653 }
654 apps := installer.NewInMemoryAppRepository(installer.CreateAllApps())
655 app, err := installer.FindEnvApp(apps, "dodo-app-instance")
656 if err != nil {
gio11617ac2024-07-15 16:09:04 +0400657 return err
gio33059762024-07-05 13:19:07 +0400658 }
659 suffixGen := installer.NewFixedLengthRandomSuffixGenerator(3)
660 suffix, err := suffixGen.Generate()
661 if err != nil {
gio11617ac2024-07-15 16:09:04 +0400662 return err
gio33059762024-07-05 13:19:07 +0400663 }
664 namespace := fmt.Sprintf("%s%s%s", s.env.NamespacePrefix, app.Namespace(), suffix)
giod8ab4f52024-07-26 16:58:34 +0400665 s.appConfigs[appName] = appConfig{namespace, network}
giocb34ad22024-07-11 08:01:13 +0400666 if err := s.updateDodoApp(appName, namespace, networks); err != nil {
gio11617ac2024-07-15 16:09:04 +0400667 return err
gio33059762024-07-05 13:19:07 +0400668 }
giod8ab4f52024-07-26 16:58:34 +0400669 configRepo, err := s.client.GetRepo(ConfigRepoName)
gio33059762024-07-05 13:19:07 +0400670 if err != nil {
gio11617ac2024-07-15 16:09:04 +0400671 return err
gio33059762024-07-05 13:19:07 +0400672 }
673 hf := installer.NewGitHelmFetcher()
giod8ab4f52024-07-26 16:58:34 +0400674 m, err := installer.NewAppManager(configRepo, s.nsc, s.jc, hf, "/")
gio33059762024-07-05 13:19:07 +0400675 if err != nil {
gio11617ac2024-07-15 16:09:04 +0400676 return err
gio33059762024-07-05 13:19:07 +0400677 }
giod8ab4f52024-07-26 16:58:34 +0400678 if err := configRepo.Do(func(fs soft.RepoFS) (string, error) {
679 w, err := fs.Writer(appConfigsFile)
gio9d66f322024-07-06 13:45:10 +0400680 if err != nil {
681 return "", err
682 }
683 defer w.Close()
giod8ab4f52024-07-26 16:58:34 +0400684 if err := json.NewEncoder(w).Encode(s.appConfigs); err != nil {
gio9d66f322024-07-06 13:45:10 +0400685 return "", err
686 }
687 if _, err := m.Install(
688 app,
689 appName,
690 "/"+appName,
691 namespace,
692 map[string]any{
693 "repoAddr": s.client.GetRepoAddress(appName),
694 "repoHost": strings.Split(s.client.Address(), ":")[0],
695 "gitRepoPublicKey": s.gitRepoPublicKey,
696 },
697 installer.WithConfig(&s.env),
gio23bdc1b2024-07-11 16:07:47 +0400698 installer.WithNoNetworks(),
gio9d66f322024-07-06 13:45:10 +0400699 installer.WithNoPublish(),
700 installer.WithNoLock(),
701 ); err != nil {
702 return "", err
703 }
704 return fmt.Sprintf("Installed app: %s", appName), nil
705 }); err != nil {
gio11617ac2024-07-15 16:09:04 +0400706 return err
gio33059762024-07-05 13:19:07 +0400707 }
708 cfg, err := m.FindInstance(appName)
709 if err != nil {
gio11617ac2024-07-15 16:09:04 +0400710 return err
gio33059762024-07-05 13:19:07 +0400711 }
712 fluxKeys, ok := cfg.Input["fluxKeys"]
713 if !ok {
gio11617ac2024-07-15 16:09:04 +0400714 return fmt.Errorf("Fluxcd keys not found")
gio33059762024-07-05 13:19:07 +0400715 }
716 fluxPublicKey, ok := fluxKeys.(map[string]any)["public"]
717 if !ok {
gio11617ac2024-07-15 16:09:04 +0400718 return fmt.Errorf("Fluxcd keys not found")
gio33059762024-07-05 13:19:07 +0400719 }
720 if ok, err := s.client.UserExists("fluxcd"); err != nil {
gio11617ac2024-07-15 16:09:04 +0400721 return err
gio33059762024-07-05 13:19:07 +0400722 } else if ok {
723 if err := s.client.AddPublicKey("fluxcd", fluxPublicKey.(string)); err != nil {
gio11617ac2024-07-15 16:09:04 +0400724 return err
gio33059762024-07-05 13:19:07 +0400725 }
726 } else {
727 if err := s.client.AddUser("fluxcd", fluxPublicKey.(string)); err != nil {
gio11617ac2024-07-15 16:09:04 +0400728 return err
gio33059762024-07-05 13:19:07 +0400729 }
730 }
731 if err := s.client.AddReadOnlyCollaborator(appName, "fluxcd"); err != nil {
gio11617ac2024-07-15 16:09:04 +0400732 return err
gio33059762024-07-05 13:19:07 +0400733 }
734 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 +0400735 return err
gio33059762024-07-05 13:19:07 +0400736 }
gio81246f02024-07-10 12:02:15 +0400737 if err := s.client.AddReadWriteCollaborator(appName, user); err != nil {
gio11617ac2024-07-15 16:09:04 +0400738 return err
gio33059762024-07-05 13:19:07 +0400739 }
gio11617ac2024-07-15 16:09:04 +0400740 return nil
gio33059762024-07-05 13:19:07 +0400741}
742
gio81246f02024-07-10 12:02:15 +0400743type apiAddAdminKeyReq struct {
gio70be3e52024-06-26 18:27:19 +0400744 Public string `json:"public"`
745}
746
gio8fae3af2024-07-25 13:43:31 +0400747func (s *DodoAppServer) handleAPIAddAdminKey(w http.ResponseWriter, r *http.Request) {
gio81246f02024-07-10 12:02:15 +0400748 var req apiAddAdminKeyReq
gio70be3e52024-06-26 18:27:19 +0400749 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
750 http.Error(w, err.Error(), http.StatusBadRequest)
751 return
752 }
753 if err := s.client.AddPublicKey("admin", req.Public); err != nil {
754 http.Error(w, err.Error(), http.StatusInternalServerError)
755 return
756 }
757}
758
giocb34ad22024-07-11 08:01:13 +0400759func (s *DodoAppServer) updateDodoApp(name, namespace string, networks []installer.Network) error {
gio33059762024-07-05 13:19:07 +0400760 repo, err := s.client.GetRepo(name)
gio0eaf2712024-04-14 13:08:46 +0400761 if err != nil {
762 return err
763 }
giof8843412024-05-22 16:38:05 +0400764 hf := installer.NewGitHelmFetcher()
gio33059762024-07-05 13:19:07 +0400765 m, err := installer.NewAppManager(repo, s.nsc, s.jc, hf, "/.dodo")
gio0eaf2712024-04-14 13:08:46 +0400766 if err != nil {
767 return err
768 }
769 appCfg, err := soft.ReadFile(repo, "app.cue")
gio0eaf2712024-04-14 13:08:46 +0400770 if err != nil {
771 return err
772 }
773 app, err := installer.NewDodoApp(appCfg)
774 if err != nil {
775 return err
776 }
giof8843412024-05-22 16:38:05 +0400777 lg := installer.GitRepositoryLocalChartGenerator{"app", namespace}
giof71a0832024-06-27 14:45:45 +0400778 if _, err := m.Install(
779 app,
780 "app",
781 "/.dodo/app",
782 namespace,
783 map[string]any{
gioa60f0de2024-07-08 10:49:48 +0400784 "repoAddr": repo.FullAddress(),
785 "managerAddr": fmt.Sprintf("http://%s", s.self),
786 "appId": name,
787 "sshPrivateKey": s.sshKey,
giof71a0832024-06-27 14:45:45 +0400788 },
gio33059762024-07-05 13:19:07 +0400789 installer.WithConfig(&s.env),
giocb34ad22024-07-11 08:01:13 +0400790 installer.WithNetworks(networks),
giof71a0832024-06-27 14:45:45 +0400791 installer.WithLocalChartGenerator(lg),
792 installer.WithBranch("dodo"),
793 installer.WithForce(),
794 ); err != nil {
gio0eaf2712024-04-14 13:08:46 +0400795 return err
796 }
797 return nil
798}
gio33059762024-07-05 13:19:07 +0400799
gio5e49bb62024-07-20 10:43:19 +0400800func (s *DodoAppServer) initRepo(repo soft.RepoIO, appType string, network installer.Network, subdomain string) error {
801 appType = strings.ReplaceAll(appType, ":", "-")
802 appTmpl, err := s.appTmpls.Find(appType)
803 if err != nil {
804 return err
gio33059762024-07-05 13:19:07 +0400805 }
gio33059762024-07-05 13:19:07 +0400806 return repo.Do(func(fs soft.RepoFS) (string, error) {
gio5e49bb62024-07-20 10:43:19 +0400807 if err := appTmpl.Render(network, subdomain, repo); err != nil {
808 return "", err
gio33059762024-07-05 13:19:07 +0400809 }
gio5e49bb62024-07-20 10:43:19 +0400810 return "init", nil
gio33059762024-07-05 13:19:07 +0400811 })
812}
gio81246f02024-07-10 12:02:15 +0400813
814func generatePassword() string {
815 return "foo"
816}
giocb34ad22024-07-11 08:01:13 +0400817
gio11617ac2024-07-15 16:09:04 +0400818func (s *DodoAppServer) getNetworks(user string) ([]installer.Network, error) {
gio23bdc1b2024-07-11 16:07:47 +0400819 addr := fmt.Sprintf("%s/api/networks", s.envAppManagerAddr)
giocb34ad22024-07-11 08:01:13 +0400820 resp, err := http.Get(addr)
821 if err != nil {
822 return nil, err
823 }
gio23bdc1b2024-07-11 16:07:47 +0400824 networks := []installer.Network{}
825 if json.NewDecoder(resp.Body).Decode(&networks); err != nil {
giocb34ad22024-07-11 08:01:13 +0400826 return nil, err
827 }
gio11617ac2024-07-15 16:09:04 +0400828 return s.nf.Filter(user, networks)
829}
830
gio8fae3af2024-07-25 13:43:31 +0400831type publicNetworkData struct {
832 Name string `json:"name"`
833 Domain string `json:"domain"`
834}
835
836type publicData struct {
837 Networks []publicNetworkData `json:"networks"`
838 Types []string `json:"types"`
839}
840
841func (s *DodoAppServer) handleAPIPublicData(w http.ResponseWriter, r *http.Request) {
giod8ab4f52024-07-26 16:58:34 +0400842 w.Header().Set("Access-Control-Allow-Origin", "*")
843 s.l.Lock()
844 defer s.l.Unlock()
gio8fae3af2024-07-25 13:43:31 +0400845 networks, err := s.getNetworks("")
846 if err != nil {
847 http.Error(w, err.Error(), http.StatusInternalServerError)
848 return
849 }
850 var ret publicData
851 for _, n := range networks {
giod8ab4f52024-07-26 16:58:34 +0400852 if s.isNetworkUseAllowed(strings.ToLower(n.Name)) {
853 ret.Networks = append(ret.Networks, publicNetworkData{n.Name, n.Domain})
854 }
gio8fae3af2024-07-25 13:43:31 +0400855 }
856 for _, t := range s.appTmpls.Types() {
857 ret.Types = append(ret.Types, strings.ReplaceAll(t, "-", ":"))
858 }
859 w.Header().Set("Access-Control-Allow-Origin", "*")
860 if err := json.NewEncoder(w).Encode(ret); err != nil {
861 http.Error(w, err.Error(), http.StatusInternalServerError)
862 return
863 }
864}
865
gio11617ac2024-07-15 16:09:04 +0400866func pickNetwork(networks []installer.Network, network string) []installer.Network {
867 for _, n := range networks {
868 if n.Name == network {
869 return []installer.Network{n}
870 }
871 }
872 return []installer.Network{}
873}
874
875type NetworkFilter interface {
876 Filter(user string, networks []installer.Network) ([]installer.Network, error)
877}
878
879type noNetworkFilter struct{}
880
881func NewNoNetworkFilter() NetworkFilter {
882 return noNetworkFilter{}
883}
884
gio8fae3af2024-07-25 13:43:31 +0400885func (f noNetworkFilter) Filter(user string, networks []installer.Network) ([]installer.Network, error) {
gio11617ac2024-07-15 16:09:04 +0400886 return networks, nil
887}
888
889type filterByOwner struct {
890 st Store
891}
892
893func NewNetworkFilterByOwner(st Store) NetworkFilter {
894 return &filterByOwner{st}
895}
896
897func (f *filterByOwner) Filter(user string, networks []installer.Network) ([]installer.Network, error) {
gio8fae3af2024-07-25 13:43:31 +0400898 if user == "" {
899 return networks, nil
900 }
gio11617ac2024-07-15 16:09:04 +0400901 network, err := f.st.GetUserNetwork(user)
902 if err != nil {
903 return nil, err
gio23bdc1b2024-07-11 16:07:47 +0400904 }
905 ret := []installer.Network{}
906 for _, n := range networks {
gio11617ac2024-07-15 16:09:04 +0400907 if n.Name == network {
gio23bdc1b2024-07-11 16:07:47 +0400908 ret = append(ret, n)
909 }
910 }
giocb34ad22024-07-11 08:01:13 +0400911 return ret, nil
912}
gio11617ac2024-07-15 16:09:04 +0400913
914type allowListFilter struct {
915 allowed []string
916}
917
918func NewAllowListFilter(allowed []string) NetworkFilter {
919 return &allowListFilter{allowed}
920}
921
gio8fae3af2024-07-25 13:43:31 +0400922func (f *allowListFilter) Filter(user string, networks []installer.Network) ([]installer.Network, error) {
gio11617ac2024-07-15 16:09:04 +0400923 ret := []installer.Network{}
924 for _, n := range networks {
925 if slices.Contains(f.allowed, n.Name) {
926 ret = append(ret, n)
927 }
928 }
929 return ret, nil
930}
931
932type combinedNetworkFilter struct {
933 filters []NetworkFilter
934}
935
936func NewCombinedFilter(filters ...NetworkFilter) NetworkFilter {
937 return &combinedNetworkFilter{filters}
938}
939
gio8fae3af2024-07-25 13:43:31 +0400940func (f *combinedNetworkFilter) Filter(user string, networks []installer.Network) ([]installer.Network, error) {
gio11617ac2024-07-15 16:09:04 +0400941 ret := networks
942 var err error
943 for _, f := range f.filters {
gio8fae3af2024-07-25 13:43:31 +0400944 ret, err = f.Filter(user, ret)
gio11617ac2024-07-15 16:09:04 +0400945 if err != nil {
946 return nil, err
947 }
948 }
949 return ret, nil
950}