blob: 89b7ad2ecfa12944835f60c93c52d90b579da267 [file] [log] [blame]
gio0eaf2712024-04-14 13:08:46 +04001package welcome
2
3import (
4 "encoding/json"
gio9d66f322024-07-06 13:45:10 +04005 "errors"
gio0eaf2712024-04-14 13:08:46 +04006 "fmt"
7 "io"
gio9d66f322024-07-06 13:45:10 +04008 "io/fs"
gio0eaf2712024-04-14 13:08:46 +04009 "net/http"
10 "strings"
gio9d66f322024-07-06 13:45:10 +040011 "sync"
gio0eaf2712024-04-14 13:08:46 +040012
13 "github.com/giolekva/pcloud/core/installer"
14 "github.com/giolekva/pcloud/core/installer/soft"
gio33059762024-07-05 13:19:07 +040015
16 "github.com/gorilla/mux"
gio0eaf2712024-04-14 13:08:46 +040017)
18
gio9d66f322024-07-06 13:45:10 +040019const (
gioa60f0de2024-07-08 10:49:48 +040020 ConfigRepoName = "config"
gio9d66f322024-07-06 13:45:10 +040021 namespacesFile = "/namespaces.json"
22)
23
gio0eaf2712024-04-14 13:08:46 +040024type DodoAppServer struct {
gio9d66f322024-07-06 13:45:10 +040025 l sync.Locker
gioa60f0de2024-07-08 10:49:48 +040026 st Store
gio33059762024-07-05 13:19:07 +040027 port int
gioa60f0de2024-07-08 10:49:48 +040028 apiPort int
gio33059762024-07-05 13:19:07 +040029 self string
30 sshKey string
31 gitRepoPublicKey string
32 client soft.Client
33 namespace string
34 env installer.EnvConfig
35 nsc installer.NamespaceCreator
36 jc installer.JobCreator
37 workers map[string]map[string]struct{}
38 appNs map[string]string
gio0eaf2712024-04-14 13:08:46 +040039}
40
gio33059762024-07-05 13:19:07 +040041// TODO(gio): Initialize appNs on startup
gio0eaf2712024-04-14 13:08:46 +040042func NewDodoAppServer(
gioa60f0de2024-07-08 10:49:48 +040043 st Store,
gio0eaf2712024-04-14 13:08:46 +040044 port int,
gioa60f0de2024-07-08 10:49:48 +040045 apiPort int,
gio33059762024-07-05 13:19:07 +040046 self string,
gio0eaf2712024-04-14 13:08:46 +040047 sshKey string,
gio33059762024-07-05 13:19:07 +040048 gitRepoPublicKey string,
gio0eaf2712024-04-14 13:08:46 +040049 client soft.Client,
50 namespace string,
gio33059762024-07-05 13:19:07 +040051 nsc installer.NamespaceCreator,
giof8843412024-05-22 16:38:05 +040052 jc installer.JobCreator,
gio0eaf2712024-04-14 13:08:46 +040053 env installer.EnvConfig,
gio9d66f322024-07-06 13:45:10 +040054) (*DodoAppServer, error) {
gio9d66f322024-07-06 13:45:10 +040055 s := &DodoAppServer{
56 &sync.Mutex{},
gioa60f0de2024-07-08 10:49:48 +040057 st,
gio0eaf2712024-04-14 13:08:46 +040058 port,
gioa60f0de2024-07-08 10:49:48 +040059 apiPort,
gio33059762024-07-05 13:19:07 +040060 self,
gio0eaf2712024-04-14 13:08:46 +040061 sshKey,
gio33059762024-07-05 13:19:07 +040062 gitRepoPublicKey,
gio0eaf2712024-04-14 13:08:46 +040063 client,
64 namespace,
65 env,
gio33059762024-07-05 13:19:07 +040066 nsc,
giof8843412024-05-22 16:38:05 +040067 jc,
gio266c04f2024-07-03 14:18:45 +040068 map[string]map[string]struct{}{},
gio33059762024-07-05 13:19:07 +040069 map[string]string{},
gio0eaf2712024-04-14 13:08:46 +040070 }
gioa60f0de2024-07-08 10:49:48 +040071 config, err := client.GetRepo(ConfigRepoName)
gio9d66f322024-07-06 13:45:10 +040072 if err != nil {
73 return nil, err
74 }
75 r, err := config.Reader(namespacesFile)
76 if err == nil {
77 defer r.Close()
78 if err := json.NewDecoder(r).Decode(&s.appNs); err != nil {
79 return nil, err
80 }
81 } else if !errors.Is(err, fs.ErrNotExist) {
82 return nil, err
83 }
84 return s, nil
gio0eaf2712024-04-14 13:08:46 +040085}
86
87func (s *DodoAppServer) Start() error {
gioa60f0de2024-07-08 10:49:48 +040088 e := make(chan error)
89 go func() {
90 r := mux.NewRouter()
91 r.HandleFunc("/status/{app-name}", s.handleAppStatus).Methods(http.MethodGet)
92 r.HandleFunc("/status", s.handleStatus).Methods(http.MethodGet)
93 e <- http.ListenAndServe(fmt.Sprintf(":%d", s.port), r)
94 }()
95 go func() {
96 r := mux.NewRouter()
97 r.HandleFunc("/update", s.handleUpdate)
98 r.HandleFunc("/api/apps/{app-name}/workers", s.handleRegisterWorker).Methods(http.MethodPost)
99 r.HandleFunc("/api/apps", s.handleCreateApp).Methods(http.MethodPost)
100 r.HandleFunc("/api/add-admin-key", s.handleAddAdminKey).Methods(http.MethodPost)
101 e <- http.ListenAndServe(fmt.Sprintf(":%d", s.apiPort), r)
102 }()
103 return <-e
104}
105
106func (s *DodoAppServer) handleStatus(w http.ResponseWriter, r *http.Request) {
107 apps, err := s.st.GetApps()
108 if err != nil {
109 http.Error(w, err.Error(), http.StatusInternalServerError)
110 return
111 }
112 for _, a := range apps {
113 fmt.Fprintf(w, "%s\n", a)
114 }
115}
116
117func (s *DodoAppServer) handleAppStatus(w http.ResponseWriter, r *http.Request) {
118 vars := mux.Vars(r)
119 appName, ok := vars["app-name"]
120 if !ok || appName == "" {
121 http.Error(w, "missing app-name", http.StatusBadRequest)
122 return
123 }
124 commits, err := s.st.GetCommitHistory(appName)
125 if err != nil {
126 http.Error(w, err.Error(), http.StatusInternalServerError)
127 return
128 }
129 for _, c := range commits {
130 fmt.Fprintf(w, "%s %s\n", c.Hash, c.Message)
131 }
gio0eaf2712024-04-14 13:08:46 +0400132}
133
134type updateReq struct {
gio266c04f2024-07-03 14:18:45 +0400135 Ref string `json:"ref"`
136 Repository struct {
137 Name string `json:"name"`
138 } `json:"repository"`
gioa60f0de2024-07-08 10:49:48 +0400139 After string `json:"after"`
gio0eaf2712024-04-14 13:08:46 +0400140}
141
142func (s *DodoAppServer) handleUpdate(w http.ResponseWriter, r *http.Request) {
143 fmt.Println("update")
144 var req updateReq
145 var contents strings.Builder
146 io.Copy(&contents, r.Body)
147 c := contents.String()
148 fmt.Println(c)
149 if err := json.NewDecoder(strings.NewReader(c)).Decode(&req); err != nil {
150 fmt.Println(err)
151 return
152 }
gioa60f0de2024-07-08 10:49:48 +0400153 if req.Ref != "refs/heads/master" || req.Repository.Name == ConfigRepoName {
gio0eaf2712024-04-14 13:08:46 +0400154 return
155 }
gioa60f0de2024-07-08 10:49:48 +0400156 // TODO(gio): Create commit record on app init as well
gio0eaf2712024-04-14 13:08:46 +0400157 go func() {
gio33059762024-07-05 13:19:07 +0400158 if err := s.updateDodoApp(req.Repository.Name, s.appNs[req.Repository.Name]); err != nil {
gioa60f0de2024-07-08 10:49:48 +0400159 if err := s.st.CreateCommit(req.Repository.Name, req.After, err.Error()); err != nil {
160 fmt.Printf("Error: %s\n", err.Error())
161 return
162 }
163 }
164 if err := s.st.CreateCommit(req.Repository.Name, req.After, "OK"); err != nil {
165 fmt.Printf("Error: %s\n", err.Error())
166 }
167 for addr, _ := range s.workers[req.Repository.Name] {
168 go func() {
169 // TODO(gio): make port configurable
170 http.Get(fmt.Sprintf("http://%s/update", addr))
171 }()
gio0eaf2712024-04-14 13:08:46 +0400172 }
173 }()
gio0eaf2712024-04-14 13:08:46 +0400174}
175
176type registerWorkerReq struct {
177 Address string `json:"address"`
178}
179
180func (s *DodoAppServer) handleRegisterWorker(w http.ResponseWriter, r *http.Request) {
gioa60f0de2024-07-08 10:49:48 +0400181 vars := mux.Vars(r)
182 appName, ok := vars["app-name"]
183 if !ok || appName == "" {
184 http.Error(w, "missing app-name", http.StatusBadRequest)
185 return
186 }
gio0eaf2712024-04-14 13:08:46 +0400187 var req registerWorkerReq
188 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
189 http.Error(w, err.Error(), http.StatusInternalServerError)
190 return
191 }
gioa60f0de2024-07-08 10:49:48 +0400192 if _, ok := s.workers[appName]; !ok {
193 s.workers[appName] = map[string]struct{}{}
gio266c04f2024-07-03 14:18:45 +0400194 }
gioa60f0de2024-07-08 10:49:48 +0400195 s.workers[appName][req.Address] = struct{}{}
gio0eaf2712024-04-14 13:08:46 +0400196}
197
gio33059762024-07-05 13:19:07 +0400198type createAppReq struct {
199 AdminPublicKey string `json:"adminPublicKey"`
200}
201
202type createAppResp struct {
203 AppName string `json:"appName"`
204}
205
206func (s *DodoAppServer) handleCreateApp(w http.ResponseWriter, r *http.Request) {
207 var req createAppReq
208 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
209 http.Error(w, err.Error(), http.StatusBadRequest)
210 return
211 }
212 g := installer.NewFixedLengthRandomNameGenerator(3)
213 appName, err := g.Generate()
214 if err != nil {
215 http.Error(w, err.Error(), http.StatusInternalServerError)
216 return
217 }
218 if err := s.CreateApp(appName, req.AdminPublicKey); err != nil {
219 http.Error(w, err.Error(), http.StatusInternalServerError)
220 return
221 }
222 resp := createAppResp{appName}
223 if err := json.NewEncoder(w).Encode(resp); err != nil {
224 http.Error(w, err.Error(), http.StatusInternalServerError)
225 return
226 }
227}
228
229func (s *DodoAppServer) CreateApp(appName, adminPublicKey string) error {
gio9d66f322024-07-06 13:45:10 +0400230 s.l.Lock()
231 defer s.l.Unlock()
gio33059762024-07-05 13:19:07 +0400232 fmt.Printf("Creating app: %s\n", appName)
233 if ok, err := s.client.RepoExists(appName); err != nil {
234 return err
235 } else if ok {
236 return nil
237 }
gioa60f0de2024-07-08 10:49:48 +0400238 if err := s.st.CreateApp(appName); err != nil {
239 return err
240 }
gio33059762024-07-05 13:19:07 +0400241 if err := s.client.AddRepository(appName); err != nil {
242 return err
243 }
244 appRepo, err := s.client.GetRepo(appName)
245 if err != nil {
246 return err
247 }
248 if err := InitRepo(appRepo); err != nil {
249 return err
250 }
251 apps := installer.NewInMemoryAppRepository(installer.CreateAllApps())
252 app, err := installer.FindEnvApp(apps, "dodo-app-instance")
253 if err != nil {
254 return err
255 }
256 suffixGen := installer.NewFixedLengthRandomSuffixGenerator(3)
257 suffix, err := suffixGen.Generate()
258 if err != nil {
259 return err
260 }
261 namespace := fmt.Sprintf("%s%s%s", s.env.NamespacePrefix, app.Namespace(), suffix)
262 s.appNs[appName] = namespace
263 if err := s.updateDodoApp(appName, namespace); err != nil {
264 return err
265 }
gioa60f0de2024-07-08 10:49:48 +0400266 repo, err := s.client.GetRepo(ConfigRepoName)
gio33059762024-07-05 13:19:07 +0400267 if err != nil {
268 return err
269 }
270 hf := installer.NewGitHelmFetcher()
271 m, err := installer.NewAppManager(repo, s.nsc, s.jc, hf, "/")
272 if err != nil {
273 return err
274 }
gio9d66f322024-07-06 13:45:10 +0400275 if err := repo.Do(func(fs soft.RepoFS) (string, error) {
276 w, err := fs.Writer(namespacesFile)
277 if err != nil {
278 return "", err
279 }
280 defer w.Close()
281 if err := json.NewEncoder(w).Encode(s.appNs); err != nil {
282 return "", err
283 }
284 if _, err := m.Install(
285 app,
286 appName,
287 "/"+appName,
288 namespace,
289 map[string]any{
290 "repoAddr": s.client.GetRepoAddress(appName),
291 "repoHost": strings.Split(s.client.Address(), ":")[0],
292 "gitRepoPublicKey": s.gitRepoPublicKey,
293 },
294 installer.WithConfig(&s.env),
295 installer.WithNoPublish(),
296 installer.WithNoLock(),
297 ); err != nil {
298 return "", err
299 }
300 return fmt.Sprintf("Installed app: %s", appName), nil
301 }); err != nil {
gio33059762024-07-05 13:19:07 +0400302 return err
303 }
304 cfg, err := m.FindInstance(appName)
305 if err != nil {
306 return err
307 }
308 fluxKeys, ok := cfg.Input["fluxKeys"]
309 if !ok {
310 return fmt.Errorf("Fluxcd keys not found")
311 }
312 fluxPublicKey, ok := fluxKeys.(map[string]any)["public"]
313 if !ok {
314 return fmt.Errorf("Fluxcd keys not found")
315 }
316 if ok, err := s.client.UserExists("fluxcd"); err != nil {
317 return err
318 } else if ok {
319 if err := s.client.AddPublicKey("fluxcd", fluxPublicKey.(string)); err != nil {
320 return err
321 }
322 } else {
323 if err := s.client.AddUser("fluxcd", fluxPublicKey.(string)); err != nil {
324 return err
325 }
326 }
327 if err := s.client.AddReadOnlyCollaborator(appName, "fluxcd"); err != nil {
328 return err
329 }
330 if err := s.client.AddWebhook(appName, fmt.Sprintf("http://%s/update", s.self), "--active=true", "--events=push", "--content-type=json"); err != nil {
331 return err
332 }
333 if user, err := s.client.FindUser(adminPublicKey); err != nil {
334 return err
335 } else if user != "" {
336 if err := s.client.AddReadWriteCollaborator(appName, user); err != nil {
337 return err
338 }
339 } else {
340 if err := s.client.AddUser(appName, adminPublicKey); err != nil {
341 return err
342 }
343 if err := s.client.AddReadWriteCollaborator(appName, appName); err != nil {
344 return err
345 }
346 }
347 return nil
348}
349
gio70be3e52024-06-26 18:27:19 +0400350type addAdminKeyReq struct {
351 Public string `json:"public"`
352}
353
354func (s *DodoAppServer) handleAddAdminKey(w http.ResponseWriter, r *http.Request) {
355 var req addAdminKeyReq
356 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
357 http.Error(w, err.Error(), http.StatusBadRequest)
358 return
359 }
360 if err := s.client.AddPublicKey("admin", req.Public); err != nil {
361 http.Error(w, err.Error(), http.StatusInternalServerError)
362 return
363 }
364}
365
gio33059762024-07-05 13:19:07 +0400366func (s *DodoAppServer) updateDodoApp(name, namespace string) error {
367 repo, err := s.client.GetRepo(name)
gio0eaf2712024-04-14 13:08:46 +0400368 if err != nil {
369 return err
370 }
giof8843412024-05-22 16:38:05 +0400371 hf := installer.NewGitHelmFetcher()
gio33059762024-07-05 13:19:07 +0400372 m, err := installer.NewAppManager(repo, s.nsc, s.jc, hf, "/.dodo")
gio0eaf2712024-04-14 13:08:46 +0400373 if err != nil {
374 return err
375 }
376 appCfg, err := soft.ReadFile(repo, "app.cue")
gio0eaf2712024-04-14 13:08:46 +0400377 if err != nil {
378 return err
379 }
380 app, err := installer.NewDodoApp(appCfg)
381 if err != nil {
382 return err
383 }
giof8843412024-05-22 16:38:05 +0400384 lg := installer.GitRepositoryLocalChartGenerator{"app", namespace}
giof71a0832024-06-27 14:45:45 +0400385 if _, err := m.Install(
386 app,
387 "app",
388 "/.dodo/app",
389 namespace,
390 map[string]any{
gioa60f0de2024-07-08 10:49:48 +0400391 "repoAddr": repo.FullAddress(),
392 "managerAddr": fmt.Sprintf("http://%s", s.self),
393 "appId": name,
394 "sshPrivateKey": s.sshKey,
giof71a0832024-06-27 14:45:45 +0400395 },
gio33059762024-07-05 13:19:07 +0400396 installer.WithConfig(&s.env),
giof71a0832024-06-27 14:45:45 +0400397 installer.WithLocalChartGenerator(lg),
398 installer.WithBranch("dodo"),
399 installer.WithForce(),
400 ); err != nil {
gio0eaf2712024-04-14 13:08:46 +0400401 return err
402 }
403 return nil
404}
gio33059762024-07-05 13:19:07 +0400405
406const goMod = `module dodo.app
407
408go 1.18
409`
410
411const mainGo = `package main
412
413import (
414 "flag"
415 "fmt"
416 "log"
417 "net/http"
418)
419
420var port = flag.Int("port", 8080, "Port to listen on")
421
422func handler(w http.ResponseWriter, r *http.Request) {
423 fmt.Fprintln(w, "Hello from Dodo App!")
424}
425
426func main() {
427 flag.Parse()
428 http.HandleFunc("/", handler)
429 log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", *port), nil))
430}
431`
432
433const appCue = `app: {
434 type: "golang:1.22.0"
435 run: "main.go"
436 ingress: {
437 network: "Private" // or Public
438 subdomain: "testapp"
439 auth: enabled: false
440 }
441}
442`
443
444func InitRepo(repo soft.RepoIO) error {
445 return repo.Do(func(fs soft.RepoFS) (string, error) {
446 {
447 w, err := fs.Writer("go.mod")
448 if err != nil {
449 return "", err
450 }
451 defer w.Close()
452 fmt.Fprint(w, goMod)
453 }
454 {
455 w, err := fs.Writer("main.go")
456 if err != nil {
457 return "", err
458 }
459 defer w.Close()
460 fmt.Fprintf(w, "%s", mainGo)
461 }
462 {
463 w, err := fs.Writer("app.cue")
464 if err != nil {
465 return "", err
466 }
467 defer w.Close()
468 fmt.Fprint(w, appCue)
469 }
470 return "go web app template", nil
471 })
472}