blob: 080a1340346ba417495c7946ab50c01a657e2341 [file] [log] [blame]
gio0eaf2712024-04-14 13:08:46 +04001package main
2
3import (
4 "bytes"
gio37fba252025-07-03 14:02:04 +04005 "context"
gio0eaf2712024-04-14 13:08:46 +04006 "encoding/json"
gio37fba252025-07-03 14:02:04 +04007 "errors"
gio0eaf2712024-04-14 13:08:46 +04008 "fmt"
gio183e8342024-08-20 06:01:24 +04009 "io"
gio0eaf2712024-04-14 13:08:46 +040010 "net/http"
11 "os"
12 "os/exec"
giofc441e32024-11-11 16:26:14 +040013 "path/filepath"
giobcd25e92025-05-03 19:14:10 +040014 "strings"
gio0eaf2712024-04-14 13:08:46 +040015 "sync"
gioff0ee0f2024-10-15 23:11:54 +040016 "syscall"
gio0eaf2712024-04-14 13:08:46 +040017 "time"
18
19 "golang.org/x/crypto/ssh"
20)
21
gio5155c1a2025-05-21 15:36:21 +040022type CommandState string
23
24type CommandStatus struct {
25 Command string `json:"command"`
26 State CommandState `json:"state"`
27}
28
29type Status struct {
gio9635ccb2025-05-22 08:33:38 +040030 Commit *Commit `json:"commit"`
gio5155c1a2025-05-21 15:36:21 +040031 Commands []CommandStatus `json:"commands"`
32}
33
gio0eaf2712024-04-14 13:08:46 +040034type Server struct {
gio37fba252025-07-03 14:02:04 +040035 l sync.Locker
36 hs *http.Server
gioaa6e27a2025-06-29 23:17:54 +040037 // TODO(gio): randomly generate string
38 runId int
gioe65d9a92025-06-19 09:02:32 +040039 agentMode bool
gio0eaf2712024-04-14 13:08:46 +040040 port int
gio266c04f2024-07-03 14:18:45 +040041 appId string
giob87415c2025-05-08 22:32:11 +040042 service string
gio5155c1a2025-05-21 15:36:21 +040043 id string
gio0eaf2712024-04-14 13:08:46 +040044 ready bool
45 cmd *exec.Cmd
46 repoAddr string
gio2b1157a2024-10-24 08:45:07 +040047 branch string
giofc441e32024-11-11 16:26:14 +040048 rootDir string
gio0eaf2712024-04-14 13:08:46 +040049 signer ssh.Signer
50 appDir string
51 runCommands []Command
52 self string
gioa60f0de2024-07-08 10:49:48 +040053 managerAddr string
gioaa6e27a2025-06-29 23:17:54 +040054 logs *Logger
55 logM io.Writer
gio45c31822024-10-24 10:58:02 +040056 currDir string
gio5155c1a2025-05-21 15:36:21 +040057 status *Status
gio37fba252025-07-03 14:02:04 +040058 stop bool
gio0eaf2712024-04-14 13:08:46 +040059}
60
gioe65d9a92025-06-19 09:02:32 +040061func NewServer(agentMode bool, port int, appId, service, id, repoAddr, branch, rootDir string, signer ssh.Signer, appDir string, runCommands []Command, self string, manager string) *Server {
gioaa6e27a2025-06-29 23:17:54 +040062 logger := NewLogger("0")
63 logM := io.MultiWriter(os.Stdout, logger)
gio0eaf2712024-04-14 13:08:46 +040064 return &Server{
65 l: &sync.Mutex{},
gio37fba252025-07-03 14:02:04 +040066 hs: nil,
gioaa6e27a2025-06-29 23:17:54 +040067 runId: 0,
gioe65d9a92025-06-19 09:02:32 +040068 agentMode: agentMode,
gio0eaf2712024-04-14 13:08:46 +040069 port: port,
70 ready: false,
gio266c04f2024-07-03 14:18:45 +040071 appId: appId,
giob87415c2025-05-08 22:32:11 +040072 service: service,
gio5155c1a2025-05-21 15:36:21 +040073 id: id,
gio0eaf2712024-04-14 13:08:46 +040074 repoAddr: repoAddr,
gio2b1157a2024-10-24 08:45:07 +040075 branch: branch,
giofc441e32024-11-11 16:26:14 +040076 rootDir: rootDir,
gio0eaf2712024-04-14 13:08:46 +040077 signer: signer,
78 appDir: appDir,
79 runCommands: runCommands,
80 self: self,
gioa60f0de2024-07-08 10:49:48 +040081 managerAddr: manager,
gioaa6e27a2025-06-29 23:17:54 +040082 logs: logger,
83 logM: logM,
gio45c31822024-10-24 10:58:02 +040084 currDir: "",
gio5155c1a2025-05-21 15:36:21 +040085 status: nil,
gio37fba252025-07-03 14:02:04 +040086 stop: false,
gio0eaf2712024-04-14 13:08:46 +040087 }
88}
89
90func (s *Server) Start() error {
91 http.HandleFunc("/update", s.handleUpdate)
92 http.HandleFunc("/ready", s.handleReady)
gio183e8342024-08-20 06:01:24 +040093 http.HandleFunc("/logs", s.handleLogs)
gio37fba252025-07-03 14:02:04 +040094 http.HandleFunc("/quitquitquit", s.handleQuit)
gioec744fa2025-05-20 12:47:03 +040095 if s.managerAddr != "" && s.appId != "" {
96 go s.pingManager()
97 }
gio0eaf2712024-04-14 13:08:46 +040098 if err := s.run(); err != nil {
99 return err
100 }
gio37fba252025-07-03 14:02:04 +0400101 hs := &http.Server{
102 Addr: fmt.Sprintf(":%d", s.port),
103 }
104 s.hs = hs
105 if err := s.hs.ListenAndServe(); err == nil || errors.Is(err, http.ErrServerClosed) {
106 return nil
107 } else {
108 return err
109 }
110}
111
112func (s *Server) Stop() {
113 fmt.Println("Stopping")
114 s.l.Lock()
115 defer s.l.Unlock()
116 s.stop = true
117 if err := s.kill(); err != nil {
118 fmt.Printf("Failed to stop last command: %s\n", err)
119 } else {
120 fmt.Println("Stopped last command")
121 }
122 if s.hs != nil {
123 if err := s.hs.Shutdown(context.Background()); err != nil {
124 fmt.Printf("Failed to stop web server: %s\n", err)
125 } else {
126 fmt.Println("Stopped web server")
127 }
128 }
129}
130
gio5be6f782025-07-07 17:42:00 +0400131func (s *Server) UpdateRunCommands(runCommands []Command) {
132 s.l.Lock()
133 defer s.l.Unlock()
134 s.runCommands = runCommands
135 s.run()
136}
137
gio37fba252025-07-03 14:02:04 +0400138func (s *Server) handleQuit(w http.ResponseWriter, r *http.Request) {
139 go s.Stop()
gio0eaf2712024-04-14 13:08:46 +0400140}
141
gio183e8342024-08-20 06:01:24 +0400142func (s *Server) handleLogs(w http.ResponseWriter, r *http.Request) {
gioaa6e27a2025-06-29 23:17:54 +0400143 if logs, err := s.logs.Contents(); err != nil {
144 http.Error(w, "not ready", http.StatusInternalServerError)
145 } else {
146 fmt.Fprint(w, logs)
147 }
gio183e8342024-08-20 06:01:24 +0400148}
149
gio0eaf2712024-04-14 13:08:46 +0400150func (s *Server) handleReady(w http.ResponseWriter, r *http.Request) {
151 s.l.Lock()
152 defer s.l.Unlock()
153 if s.ready {
154 fmt.Fprintln(w, "ok")
155 } else {
156 http.Error(w, "not ready", http.StatusInternalServerError)
157 }
158}
159
gioaa6e27a2025-06-29 23:17:54 +0400160func (s *Server) log(tmpl string, args ...any) {
161 contents := fmt.Sprintf(tmpl, args...)
162 fmt.Fprintf(s.logs, "\033[38;5;212;136;141mdodo:\033[0;00m %s\n", contents)
163}
164
gio0eaf2712024-04-14 13:08:46 +0400165func (s *Server) handleUpdate(w http.ResponseWriter, r *http.Request) {
gio0eaf2712024-04-14 13:08:46 +0400166 s.l.Lock()
167 s.ready = false
168 s.l.Unlock()
gioaa6e27a2025-06-29 23:17:54 +0400169 s.log("Reloading service")
gio0eaf2712024-04-14 13:08:46 +0400170 if err := s.run(); err != nil {
171 http.Error(w, err.Error(), http.StatusInternalServerError)
172 return
173 }
174 s.l.Lock()
175 s.ready = true
176 s.l.Unlock()
177}
178
gioe65d9a92025-06-19 09:02:32 +0400179type command struct {
180 cmd string
181 env []string
182}
183
gio0eaf2712024-04-14 13:08:46 +0400184func (s *Server) run() error {
gioe65d9a92025-06-19 09:02:32 +0400185 newDir := s.appDir
186 commands := []command{}
187 if !s.agentMode {
188 var err error
189 newDir, err = os.MkdirTemp(s.appDir, "code-*")
190 if err != nil {
191 return err
gio5155c1a2025-05-21 15:36:21 +0400192 }
gio0eaf2712024-04-14 13:08:46 +0400193 }
gioe65d9a92025-06-19 09:02:32 +0400194 if s.repoAddr != "" {
gioe8402352025-06-24 21:28:25 +0400195 if _, err := os.Stat(filepath.Join(newDir, ".git")); err != nil && os.IsNotExist(err) {
196 commit, err := CloneRepositoryBranch(s.repoAddr, s.branch, s.rootDir, s.signer, newDir)
197 if err != nil {
gioaa6e27a2025-06-29 23:17:54 +0400198 s.log("Failed to clone repository: %s", err)
gioe8402352025-06-24 21:28:25 +0400199 s.status = &Status{
200 Commit: nil,
201 }
202 return err
gioe65d9a92025-06-19 09:02:32 +0400203 }
gioaa6e27a2025-06-29 23:17:54 +0400204 s.log("Successfully cloned repository %s", commit.Hash)
gioe8402352025-06-24 21:28:25 +0400205 s.status = &Status{
206 Commit: commit,
207 Commands: []CommandStatus{},
208 }
209 } else {
210 s.status = &Status{
211 Commands: []CommandStatus{},
212 }
gioe65d9a92025-06-19 09:02:32 +0400213 }
214 } else {
215 s.status = &Status{
216 Commit: nil,
217 Commands: []CommandStatus{},
218 }
gio5155c1a2025-05-21 15:36:21 +0400219 }
gio89c5b5e2025-07-02 12:15:04 +0400220 if s.status.Commit != nil {
221 s.logs.commitHash = s.status.Commit.Hash
222 } else {
223 s.logs.commitHash = ""
224 }
gio24d6e9a2025-06-24 15:02:29 +0400225 if s.agentMode && s.repoAddr == "" {
gioe65d9a92025-06-19 09:02:32 +0400226 if _, err := os.Stat(filepath.Join(newDir, ".git")); err != nil && os.IsNotExist(err) {
227 commands = append(commands, command{cmd: "git config --global user.name dodo"})
228 s.status.Commands = append(s.status.Commands, CommandStatus{
229 Command: commands[len(commands)-1].cmd,
230 State: "waiting",
231 })
232 commands = append(commands, command{cmd: "git config --global user.email dodo@dodo.cloud"})
233 s.status.Commands = append(s.status.Commands, CommandStatus{
234 Command: commands[len(commands)-1].cmd,
235 State: "waiting",
236 })
237 commands = append(commands, command{cmd: "git init ."})
238 s.status.Commands = append(s.status.Commands, CommandStatus{
239 Command: commands[len(commands)-1].cmd,
240 State: "waiting",
241 })
242 commands = append(commands, command{cmd: "echo \"TODO: Describe project\" > README.md"})
243 s.status.Commands = append(s.status.Commands, CommandStatus{
244 Command: commands[len(commands)-1].cmd,
245 State: "waiting",
246 })
247 commands = append(commands, command{cmd: "git add README.md"})
248 s.status.Commands = append(s.status.Commands, CommandStatus{
249 Command: commands[len(commands)-1].cmd,
250 State: "waiting",
251 })
252 commands = append(commands, command{cmd: "git commit -m \"init\""})
253 s.status.Commands = append(s.status.Commands, CommandStatus{
254 Command: commands[len(commands)-1].cmd,
255 State: "waiting",
256 })
257 }
258 }
gio5155c1a2025-05-21 15:36:21 +0400259 for _, c := range s.runCommands {
gio0eaf2712024-04-14 13:08:46 +0400260 args := []string{c.Bin}
261 args = append(args, c.Args...)
gio5155c1a2025-05-21 15:36:21 +0400262 cmd := strings.Join(args, " ")
gioe65d9a92025-06-19 09:02:32 +0400263 commands = append(commands, command{cmd, c.Env})
gio5155c1a2025-05-21 15:36:21 +0400264 s.status.Commands = append(s.status.Commands, CommandStatus{
265 Command: cmd,
266 State: "waiting",
267 })
268 }
gio5155c1a2025-05-21 15:36:21 +0400269 for i, c := range commands {
270 if i > 0 {
271 s.status.Commands[i-1].State = "success"
272 }
gio0eaf2712024-04-14 13:08:46 +0400273 cmd := &exec.Cmd{
giofc441e32024-11-11 16:26:14 +0400274 Dir: filepath.Join(newDir, s.rootDir),
giobcd25e92025-05-03 19:14:10 +0400275 Path: "/bin/sh",
gioe65d9a92025-06-19 09:02:32 +0400276 Args: []string{"/bin/sh", "-c", c.cmd},
277 Env: append(os.Environ(), c.env...),
gioaa6e27a2025-06-29 23:17:54 +0400278 Stdout: s.logM,
279 Stderr: s.logM,
gio0eaf2712024-04-14 13:08:46 +0400280 }
gioff0ee0f2024-10-15 23:11:54 +0400281 cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
gioaa6e27a2025-06-29 23:17:54 +0400282 s.log("Running: %s", c)
gio5155c1a2025-05-21 15:36:21 +0400283 s.status.Commands[i].State = "running"
gioe65d9a92025-06-19 09:02:32 +0400284 if i < len(commands)-1 {
gio0eaf2712024-04-14 13:08:46 +0400285 if err := cmd.Run(); err != nil {
286 return err
287 }
288 } else {
gio45c31822024-10-24 10:58:02 +0400289 if s.cmd != nil {
290 // TODO(gio): make this point configurable
291 if err := s.kill(); err != nil {
292 return err
293 }
gioe65d9a92025-06-19 09:02:32 +0400294 if s.currDir != "" && !s.agentMode {
gio5155c1a2025-05-21 15:36:21 +0400295 if err := os.RemoveAll(s.currDir); err != nil {
296 return err
297 }
gio45c31822024-10-24 10:58:02 +0400298 }
299 }
gio0eaf2712024-04-14 13:08:46 +0400300 if err := cmd.Start(); err != nil {
301 return err
302 }
303 s.cmd = cmd
304 }
305 }
gio45c31822024-10-24 10:58:02 +0400306 s.currDir = newDir
gio0eaf2712024-04-14 13:08:46 +0400307 return nil
308}
309
310type pingReq struct {
gioaa6e27a2025-06-29 23:17:54 +0400311 Id string `json:"id"`
312 Service string `json:"service"`
313 Address string `json:"address"`
314 Status *Status `json:"status,omitempty"`
315 Logs []LogItem `json:"logs"`
316}
317
318type pingResp struct {
319 Success bool `json:"success"`
320 LogItemsConsumed int `json:"logItemsConsumed"`
321}
322
323func min(a, b int) int {
324 if a < b {
325 return a
326 } else {
327 return b
328 }
gio0eaf2712024-04-14 13:08:46 +0400329}
330
331func (s *Server) pingManager() {
332 defer func() {
333 go func() {
gio37fba252025-07-03 14:02:04 +0400334 s.l.Lock()
335 defer s.l.Unlock()
336 // TODO(gio): Wait until all logs are sent over to the manager.
337 if !s.stop {
gio790c87f2025-07-03 18:37:13 +0400338 time.Sleep(1 * time.Second)
gio37fba252025-07-03 14:02:04 +0400339 s.pingManager()
340 }
gio0eaf2712024-04-14 13:08:46 +0400341 }()
342 }()
gioaa6e27a2025-06-29 23:17:54 +0400343 logItems := s.logs.Items()
344 logItems = logItems[:min(100, len(logItems))]
gioa60f0de2024-07-08 10:49:48 +0400345 buf, err := json.Marshal(pingReq{
gio5155c1a2025-05-21 15:36:21 +0400346 Id: s.id,
giob87415c2025-05-08 22:32:11 +0400347 Service: s.service,
348 Address: fmt.Sprintf("http://%s:%d", s.self, s.port),
gio5155c1a2025-05-21 15:36:21 +0400349 Status: s.status,
gioaa6e27a2025-06-29 23:17:54 +0400350 Logs: logItems,
gioa60f0de2024-07-08 10:49:48 +0400351 })
gio0eaf2712024-04-14 13:08:46 +0400352 if err != nil {
353 return
354 }
giob87415c2025-05-08 22:32:11 +0400355 registerWorkerAddr := fmt.Sprintf("%s/api/project/%s/workers", s.managerAddr, s.appId)
356 resp, err := http.Post(registerWorkerAddr, "application/json", bytes.NewReader(buf))
357 if err != nil {
358 fmt.Println(err)
359 } else {
gioaa6e27a2025-06-29 23:17:54 +0400360 var r pingResp
361 if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
362 fmt.Printf("%s\n", err)
363 } else if r.Success {
364 s.logs.Trim(r.LogItemsConsumed)
365 }
giob87415c2025-05-08 22:32:11 +0400366 }
gio0eaf2712024-04-14 13:08:46 +0400367}
gio45c31822024-10-24 10:58:02 +0400368
369func (s *Server) kill() error {
370 if s.cmd == nil {
371 return nil
372 }
373
374 err := syscall.Kill(-s.cmd.Process.Pid, syscall.SIGKILL)
375 if err != nil {
376 return err
377 }
378 // NOTE(gio): No need to check err as we just killed the process
379 s.cmd.Wait()
380 s.cmd = nil
381 return nil
382}