blob: 44e86fca82cefceace2906bf9810555e2f56bc1c [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
131func (s *Server) handleQuit(w http.ResponseWriter, r *http.Request) {
132 go s.Stop()
gio0eaf2712024-04-14 13:08:46 +0400133}
134
gio183e8342024-08-20 06:01:24 +0400135func (s *Server) handleLogs(w http.ResponseWriter, r *http.Request) {
gioaa6e27a2025-06-29 23:17:54 +0400136 if logs, err := s.logs.Contents(); err != nil {
137 http.Error(w, "not ready", http.StatusInternalServerError)
138 } else {
139 fmt.Fprint(w, logs)
140 }
gio183e8342024-08-20 06:01:24 +0400141}
142
gio0eaf2712024-04-14 13:08:46 +0400143func (s *Server) handleReady(w http.ResponseWriter, r *http.Request) {
144 s.l.Lock()
145 defer s.l.Unlock()
146 if s.ready {
147 fmt.Fprintln(w, "ok")
148 } else {
149 http.Error(w, "not ready", http.StatusInternalServerError)
150 }
151}
152
gioaa6e27a2025-06-29 23:17:54 +0400153func (s *Server) log(tmpl string, args ...any) {
154 contents := fmt.Sprintf(tmpl, args...)
155 fmt.Fprintf(s.logs, "\033[38;5;212;136;141mdodo:\033[0;00m %s\n", contents)
156}
157
gio0eaf2712024-04-14 13:08:46 +0400158func (s *Server) handleUpdate(w http.ResponseWriter, r *http.Request) {
gio0eaf2712024-04-14 13:08:46 +0400159 s.l.Lock()
160 s.ready = false
161 s.l.Unlock()
gioaa6e27a2025-06-29 23:17:54 +0400162 s.log("Reloading service")
gio0eaf2712024-04-14 13:08:46 +0400163 if err := s.run(); err != nil {
164 http.Error(w, err.Error(), http.StatusInternalServerError)
165 return
166 }
167 s.l.Lock()
168 s.ready = true
169 s.l.Unlock()
170}
171
gioe65d9a92025-06-19 09:02:32 +0400172type command struct {
173 cmd string
174 env []string
175}
176
gio0eaf2712024-04-14 13:08:46 +0400177func (s *Server) run() error {
gioe65d9a92025-06-19 09:02:32 +0400178 newDir := s.appDir
179 commands := []command{}
180 if !s.agentMode {
181 var err error
182 newDir, err = os.MkdirTemp(s.appDir, "code-*")
183 if err != nil {
184 return err
gio5155c1a2025-05-21 15:36:21 +0400185 }
gio0eaf2712024-04-14 13:08:46 +0400186 }
gioe65d9a92025-06-19 09:02:32 +0400187 if s.repoAddr != "" {
gioe8402352025-06-24 21:28:25 +0400188 if _, err := os.Stat(filepath.Join(newDir, ".git")); err != nil && os.IsNotExist(err) {
189 commit, err := CloneRepositoryBranch(s.repoAddr, s.branch, s.rootDir, s.signer, newDir)
190 if err != nil {
gioaa6e27a2025-06-29 23:17:54 +0400191 s.log("Failed to clone repository: %s", err)
gioe8402352025-06-24 21:28:25 +0400192 s.status = &Status{
193 Commit: nil,
194 }
195 return err
gioe65d9a92025-06-19 09:02:32 +0400196 }
gioaa6e27a2025-06-29 23:17:54 +0400197 s.log("Successfully cloned repository %s", commit.Hash)
gioe8402352025-06-24 21:28:25 +0400198 s.status = &Status{
199 Commit: commit,
200 Commands: []CommandStatus{},
201 }
202 } else {
203 s.status = &Status{
204 Commands: []CommandStatus{},
205 }
gioe65d9a92025-06-19 09:02:32 +0400206 }
207 } else {
208 s.status = &Status{
209 Commit: nil,
210 Commands: []CommandStatus{},
211 }
gio5155c1a2025-05-21 15:36:21 +0400212 }
gio89c5b5e2025-07-02 12:15:04 +0400213 if s.status.Commit != nil {
214 s.logs.commitHash = s.status.Commit.Hash
215 } else {
216 s.logs.commitHash = ""
217 }
gio24d6e9a2025-06-24 15:02:29 +0400218 if s.agentMode && s.repoAddr == "" {
gioe65d9a92025-06-19 09:02:32 +0400219 if _, err := os.Stat(filepath.Join(newDir, ".git")); err != nil && os.IsNotExist(err) {
220 commands = append(commands, command{cmd: "git config --global user.name dodo"})
221 s.status.Commands = append(s.status.Commands, CommandStatus{
222 Command: commands[len(commands)-1].cmd,
223 State: "waiting",
224 })
225 commands = append(commands, command{cmd: "git config --global user.email dodo@dodo.cloud"})
226 s.status.Commands = append(s.status.Commands, CommandStatus{
227 Command: commands[len(commands)-1].cmd,
228 State: "waiting",
229 })
230 commands = append(commands, command{cmd: "git init ."})
231 s.status.Commands = append(s.status.Commands, CommandStatus{
232 Command: commands[len(commands)-1].cmd,
233 State: "waiting",
234 })
235 commands = append(commands, command{cmd: "echo \"TODO: Describe project\" > README.md"})
236 s.status.Commands = append(s.status.Commands, CommandStatus{
237 Command: commands[len(commands)-1].cmd,
238 State: "waiting",
239 })
240 commands = append(commands, command{cmd: "git add README.md"})
241 s.status.Commands = append(s.status.Commands, CommandStatus{
242 Command: commands[len(commands)-1].cmd,
243 State: "waiting",
244 })
245 commands = append(commands, command{cmd: "git commit -m \"init\""})
246 s.status.Commands = append(s.status.Commands, CommandStatus{
247 Command: commands[len(commands)-1].cmd,
248 State: "waiting",
249 })
250 }
251 }
gio5155c1a2025-05-21 15:36:21 +0400252 for _, c := range s.runCommands {
gio0eaf2712024-04-14 13:08:46 +0400253 args := []string{c.Bin}
254 args = append(args, c.Args...)
gio5155c1a2025-05-21 15:36:21 +0400255 cmd := strings.Join(args, " ")
gioe65d9a92025-06-19 09:02:32 +0400256 commands = append(commands, command{cmd, c.Env})
gio5155c1a2025-05-21 15:36:21 +0400257 s.status.Commands = append(s.status.Commands, CommandStatus{
258 Command: cmd,
259 State: "waiting",
260 })
261 }
gio5155c1a2025-05-21 15:36:21 +0400262 for i, c := range commands {
263 if i > 0 {
264 s.status.Commands[i-1].State = "success"
265 }
gio0eaf2712024-04-14 13:08:46 +0400266 cmd := &exec.Cmd{
giofc441e32024-11-11 16:26:14 +0400267 Dir: filepath.Join(newDir, s.rootDir),
giobcd25e92025-05-03 19:14:10 +0400268 Path: "/bin/sh",
gioe65d9a92025-06-19 09:02:32 +0400269 Args: []string{"/bin/sh", "-c", c.cmd},
270 Env: append(os.Environ(), c.env...),
gioaa6e27a2025-06-29 23:17:54 +0400271 Stdout: s.logM,
272 Stderr: s.logM,
gio0eaf2712024-04-14 13:08:46 +0400273 }
gioff0ee0f2024-10-15 23:11:54 +0400274 cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
gioaa6e27a2025-06-29 23:17:54 +0400275 s.log("Running: %s", c)
gio5155c1a2025-05-21 15:36:21 +0400276 s.status.Commands[i].State = "running"
gioe65d9a92025-06-19 09:02:32 +0400277 if i < len(commands)-1 {
gio0eaf2712024-04-14 13:08:46 +0400278 if err := cmd.Run(); err != nil {
279 return err
280 }
281 } else {
gio45c31822024-10-24 10:58:02 +0400282 if s.cmd != nil {
283 // TODO(gio): make this point configurable
284 if err := s.kill(); err != nil {
285 return err
286 }
gioe65d9a92025-06-19 09:02:32 +0400287 if s.currDir != "" && !s.agentMode {
gio5155c1a2025-05-21 15:36:21 +0400288 if err := os.RemoveAll(s.currDir); err != nil {
289 return err
290 }
gio45c31822024-10-24 10:58:02 +0400291 }
292 }
gio0eaf2712024-04-14 13:08:46 +0400293 if err := cmd.Start(); err != nil {
294 return err
295 }
296 s.cmd = cmd
297 }
298 }
gio45c31822024-10-24 10:58:02 +0400299 s.currDir = newDir
gio0eaf2712024-04-14 13:08:46 +0400300 return nil
301}
302
303type pingReq struct {
gioaa6e27a2025-06-29 23:17:54 +0400304 Id string `json:"id"`
305 Service string `json:"service"`
306 Address string `json:"address"`
307 Status *Status `json:"status,omitempty"`
308 Logs []LogItem `json:"logs"`
309}
310
311type pingResp struct {
312 Success bool `json:"success"`
313 LogItemsConsumed int `json:"logItemsConsumed"`
314}
315
316func min(a, b int) int {
317 if a < b {
318 return a
319 } else {
320 return b
321 }
gio0eaf2712024-04-14 13:08:46 +0400322}
323
324func (s *Server) pingManager() {
325 defer func() {
326 go func() {
gio37fba252025-07-03 14:02:04 +0400327 s.l.Lock()
328 defer s.l.Unlock()
329 // TODO(gio): Wait until all logs are sent over to the manager.
330 if !s.stop {
gio790c87f2025-07-03 18:37:13 +0400331 time.Sleep(1 * time.Second)
gio37fba252025-07-03 14:02:04 +0400332 s.pingManager()
333 }
gio0eaf2712024-04-14 13:08:46 +0400334 }()
335 }()
gioaa6e27a2025-06-29 23:17:54 +0400336 logItems := s.logs.Items()
337 logItems = logItems[:min(100, len(logItems))]
gioa60f0de2024-07-08 10:49:48 +0400338 buf, err := json.Marshal(pingReq{
gio5155c1a2025-05-21 15:36:21 +0400339 Id: s.id,
giob87415c2025-05-08 22:32:11 +0400340 Service: s.service,
341 Address: fmt.Sprintf("http://%s:%d", s.self, s.port),
gio5155c1a2025-05-21 15:36:21 +0400342 Status: s.status,
gioaa6e27a2025-06-29 23:17:54 +0400343 Logs: logItems,
gioa60f0de2024-07-08 10:49:48 +0400344 })
gio0eaf2712024-04-14 13:08:46 +0400345 if err != nil {
346 return
347 }
giob87415c2025-05-08 22:32:11 +0400348 registerWorkerAddr := fmt.Sprintf("%s/api/project/%s/workers", s.managerAddr, s.appId)
349 resp, err := http.Post(registerWorkerAddr, "application/json", bytes.NewReader(buf))
350 if err != nil {
351 fmt.Println(err)
352 } else {
gioaa6e27a2025-06-29 23:17:54 +0400353 var r pingResp
354 if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
355 fmt.Printf("%s\n", err)
356 } else if r.Success {
357 s.logs.Trim(r.LogItemsConsumed)
358 }
giob87415c2025-05-08 22:32:11 +0400359 }
gio0eaf2712024-04-14 13:08:46 +0400360}
gio45c31822024-10-24 10:58:02 +0400361
362func (s *Server) kill() error {
363 if s.cmd == nil {
364 return nil
365 }
366
367 err := syscall.Kill(-s.cmd.Process.Pid, syscall.SIGKILL)
368 if err != nil {
369 return err
370 }
371 // NOTE(gio): No need to check err as we just killed the process
372 s.cmd.Wait()
373 s.cmd = nil
374 return nil
375}