server.go
1// Package appview is the web UI and agent API over a runner (DESIGN.md
2// ยง11): server-rendered pages that work on a phone without JavaScript (a
3// little JS streams logs live), owner login for approvals, and bearer
4// tokens for agents that may read, trigger and run non-danger actions but
5// never approve gates.
6package appview
7
8import (
9 "context"
10 "embed"
11 "encoding/json"
12 "fmt"
13 "html/template"
14 "io"
15 "log/slog"
16 "net/http"
17 "net/url"
18 "slices"
19 "strconv"
20 "strings"
21 "time"
22
23 "git.kilimanjaro.io/ci/internal/engine"
24 "git.kilimanjaro.io/ci/internal/ident"
25 "git.kilimanjaro.io/ci/internal/lex"
26 "git.kilimanjaro.io/ci/internal/runner"
27)
28
29//go:embed templates/*.html static/*
30var assets embed.FS
31
32// Server is the appview.
33type Server struct {
34 Runner *Runner
35 Auth *Auth
36 // Knot is the forge host (repos are did:web:<Knot>:<repo>).
37 Knot string
38 // OwnerDid is recorded as the actor of the owner's decisions.
39 OwnerDid string
40 Logger *slog.Logger
41
42 tmpl *template.Template
43}
44
45// Handler serves the UI and the agent API.
46func (s *Server) Handler() http.Handler {
47 s.tmpl = template.Must(template.New("").Funcs(funcs).ParseFS(assets, "templates/*.html"))
48 if s.Logger == nil {
49 s.Logger = slog.Default()
50 }
51 mux := http.NewServeMux()
52 mux.Handle("GET /static/", http.FileServerFS(assets))
53 mux.HandleFunc("GET /login", s.loginPage)
54 mux.HandleFunc("POST /login", s.login)
55 mux.HandleFunc("POST /logout", s.logout)
56 mux.HandleFunc("GET /{$}", s.page(s.runsPage))
57 mux.HandleFunc("GET /runs/{id}", s.page(s.runPage))
58 mux.HandleFunc("GET /runs/{id}/live", s.page(s.live))
59 mux.HandleFunc("GET /runs/{id}/files/{path...}", s.page(s.file))
60 mux.HandleFunc("POST /runs/{id}/gate", s.owner(s.gate))
61 mux.HandleFunc("POST /runs/{id}/actions/{action}", s.owner(s.action))
62 mux.HandleFunc("POST /runs/{id}/cancel", s.owner(s.cancel))
63 mux.HandleFunc("POST /trigger", s.owner(s.trigger))
64
65 // agent API (bearer token; the owner's session works too)
66 mux.HandleFunc("GET /api/runs", s.api(s.apiRuns))
67 mux.HandleFunc("GET /api/runs/{id}", s.api(s.apiRun))
68 mux.HandleFunc("GET /api/runs/{id}/log.txt", s.api(s.apiText))
69 mux.HandleFunc("POST /api/trigger", s.api(s.apiTrigger))
70 mux.HandleFunc("POST /api/runs/{id}/actions/{action}", s.api(s.apiAction))
71 // local runs: reporting (ci run --report) and concurrency leases
72 mux.HandleFunc("PUT /api/runs/{id}/import", s.api(func(w http.ResponseWriter, r *http.Request, who Who) {
73 s.Runner.Forward(w, r, "/api/runs/"+url.PathEscape(r.PathValue("id"))+"/import")
74 }))
75 lock := s.api(func(w http.ResponseWriter, r *http.Request, who Who) {
76 s.Runner.Forward(w, r, "/api/locks/"+url.PathEscape(r.PathValue("group"))+"?"+r.URL.RawQuery)
77 })
78 mux.HandleFunc("POST /api/locks/{group}", lock)
79 mux.HandleFunc("DELETE /api/locks/{group}", lock)
80 mux.HandleFunc("POST /api/runs/{id}/gate", s.api(func(w http.ResponseWriter, r *http.Request, who Who) {
81 apiError(w, http.StatusForbidden, "approving gates needs the owner's session in the appview")
82 }))
83 mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "ok") })
84 return securityHeaders(mux)
85}
86
87func securityHeaders(h http.Handler) http.Handler {
88 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
89 w.Header().Set("X-Content-Type-Options", "nosniff")
90 w.Header().Set("Referrer-Policy", "same-origin")
91 w.Header().Set("X-Frame-Options", "DENY")
92 w.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'self'; script-src 'self'; img-src 'self' data:; frame-ancestors 'none'")
93 h.ServeHTTP(w, r)
94 })
95}
96
97// page requires the owner's session for HTML pages (read-only pages too:
98// logs can carry deploy details).
99func (s *Server) page(h func(http.ResponseWriter, *http.Request, Who)) http.HandlerFunc {
100 return func(w http.ResponseWriter, r *http.Request) {
101 who := s.Auth.Who(r)
102 if !who.Owner {
103 http.Redirect(w, r, "/login?next="+urlEscape(r.URL.RequestURI()), http.StatusSeeOther)
104 return
105 }
106 h(w, r, who)
107 }
108}
109
110// owner guards state-changing form posts: the owner's session and its CSRF
111// token.
112func (s *Server) owner(h func(http.ResponseWriter, *http.Request, Who)) http.HandlerFunc {
113 return func(w http.ResponseWriter, r *http.Request) {
114 who := s.Auth.Who(r)
115 if !who.CanApprove() {
116 http.Error(w, "forbidden", http.StatusForbidden)
117 return
118 }
119 if !s.Auth.CheckCSRF(r) {
120 http.Error(w, "bad form token; reload the page", http.StatusForbidden)
121 return
122 }
123 h(w, r, who)
124 }
125}
126
127func (s *Server) api(h func(http.ResponseWriter, *http.Request, Who)) http.HandlerFunc {
128 return func(w http.ResponseWriter, r *http.Request) {
129 who := s.Auth.Who(r)
130 if !who.CanRead() {
131 apiError(w, http.StatusUnauthorized, "unauthorized")
132 return
133 }
134 h(w, r, who)
135 }
136}
137
138func apiError(w http.ResponseWriter, code int, msg string) {
139 w.Header().Set("Content-Type", "application/json")
140 w.WriteHeader(code)
141 _ = json.NewEncoder(w).Encode(map[string]string{"error": msg})
142}
143
144func apiJSON(w http.ResponseWriter, v any) {
145 w.Header().Set("Content-Type", "application/json")
146 _ = json.NewEncoder(w).Encode(v)
147}
148
149func (s *Server) render(w http.ResponseWriter, name string, data map[string]any) {
150 w.Header().Set("Content-Type", "text/html; charset=utf-8")
151 if err := s.tmpl.ExecuteTemplate(w, name, data); err != nil {
152 s.Logger.Error("render", "template", name, "err", err)
153 }
154}
155
156func (s *Server) loginPage(w http.ResponseWriter, r *http.Request) {
157 s.render(w, "login.html", map[string]any{"Next": safeNext(r.URL.Query().Get("next")), "Error": ""})
158}
159
160func (s *Server) login(w http.ResponseWriter, r *http.Request) {
161 next := safeNext(r.FormValue("next"))
162 if !s.Auth.CheckPassword(r.FormValue("password")) {
163 time.Sleep(500 * time.Millisecond)
164 w.WriteHeader(http.StatusUnauthorized)
165 s.render(w, "login.html", map[string]any{"Next": next, "Error": "Wrong password."})
166 return
167 }
168 s.Auth.Login(w)
169 http.Redirect(w, r, next, http.StatusSeeOther)
170}
171
172func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
173 if s.Auth.CheckCSRF(r) {
174 s.Auth.Logout(w)
175 }
176 http.Redirect(w, r, "/login", http.StatusSeeOther)
177}
178
179// safeNext keeps redirects on this site.
180func safeNext(n string) string {
181 if !strings.HasPrefix(n, "/") || strings.HasPrefix(n, "//") || strings.Contains(n, "\\") {
182 return "/"
183 }
184 return n
185}
186
187func (s *Server) runsPage(w http.ResponseWriter, r *http.Request, who Who) {
188 runs, err := s.Runner.Runs(r.Context(), r.URL.Query().Get("repo"), 100)
189 if err != nil {
190 http.Error(w, err.Error(), http.StatusBadGateway)
191 return
192 }
193 var waiting, rest []runner.RunView
194 repos := map[string][]string{}
195 for _, x := range runs {
196 if x.Status == engine.StatusPaused {
197 waiting = append(waiting, x)
198 } else {
199 rest = append(rest, x)
200 }
201 if !slices.Contains(repos[x.Repo], x.Workflow) {
202 repos[x.Repo] = append(repos[x.Repo], x.Workflow)
203 }
204 }
205 s.render(w, "runs.html", map[string]any{
206 "Waiting": waiting, "Runs": rest, "Repos": repos, "Repo": r.URL.Query().Get("repo"), "CSRF": s.Auth.CSRF(r),
207 })
208}
209
210// timelineEntry is an action execution with its result summary.
211type timelineEntry struct {
212 engine.Exec
213 Summary string
214 Attachments []lex.Attachment
215}
216
217func (s *Server) runPage(w http.ResponseWriter, r *http.Request, who Who) {
218 id := r.PathValue("id")
219 v, err := s.Runner.Run(r.Context(), id)
220 if err != nil {
221 http.Error(w, err.Error(), http.StatusNotFound)
222 return
223 }
224 evs, err := s.Runner.Events(r.Context(), id, 0)
225 if err != nil {
226 http.Error(w, err.Error(), http.StatusBadGateway)
227 return
228 }
229 results := map[int]*lex.Result{}
230 var lines []logLine
231 last := 0
232 for _, ev := range evs {
233 if ev.Kind == lex.KindResult && ev.Result != nil {
234 results[ev.Attempt] = ev.Result
235 }
236 if l, ok := renderLine(ev); ok {
237 lines = append(lines, l)
238 }
239 last = ev.Seq
240 }
241 var timeline []timelineEntry
242 if v.State != nil {
243 for _, x := range v.State.History {
244 t := timelineEntry{Exec: x}
245 if res := results[x.N]; res != nil {
246 t.Summary, t.Attachments = res.Summary, res.Attachments
247 }
248 timeline = append(timeline, t)
249 }
250 }
251 s.render(w, "run.html", map[string]any{
252 "Run": v, "Timeline": timeline, "Lines": lines, "Last": last, "CSRF": s.Auth.CSRF(r),
253 "Live": v.State != nil && (v.State.Status == engine.StatusRunning || v.Status == lex.StatusPending),
254 })
255}
256
257// logLine is a rendered event for the log view.
258type logLine struct {
259 Class string `json:"class"`
260 Text string `json:"text"`
261}
262
263func renderLine(ev lex.RunEvent) (logLine, bool) {
264 switch ev.Kind {
265 case lex.KindStart:
266 return logLine{"start", fmt.Sprintf("โถ %s %s", ev.Action, firstLine(ev.Command))}, true
267 case lex.KindLog:
268 if ev.Stream == "stderr" {
269 return logLine{"stderr", ev.Content}, true
270 }
271 return logLine{"stdout", ev.Content}, true
272 case lex.KindEnd:
273 return logLine{"end " + ev.Status, fmt.Sprintf("%s %s: %s", mark(ev.Status), ev.Action, ev.Status)}, true
274 case lex.KindGate:
275 if ev.Gate != nil {
276 t := "โธ gate " + ev.Gate.State
277 if ev.Gate.Choice != "" {
278 t += ": " + ev.Gate.Choice
279 }
280 if ev.Gate.Actor != "" {
281 t += " by " + ev.Gate.Actor
282 }
283 return logLine{"gate", t}, true
284 }
285 case lex.KindRun:
286 if ev.Status != engine.StatusRunning {
287 return logLine{"run " + ev.Status, fmt.Sprintf("โ %s %s", ev.Status, ev.Content)}, true
288 }
289 }
290 return logLine{}, false
291}
292
293func mark(status string) string {
294 switch status {
295 case lex.Failure:
296 return "โ"
297 case lex.Warning:
298 return "!"
299 }
300 return "โ"
301}
302
303func firstLine(s string) string {
304 s = strings.TrimSpace(s)
305 if i := strings.IndexByte(s, '\n'); i >= 0 {
306 return s[:i] + " โฆ"
307 }
308 return s
309}
310
311// live streams new log lines as server-sent events until the run stops
312// running; the page reloads then to show the gate or the result.
313func (s *Server) live(w http.ResponseWriter, r *http.Request, who Who) {
314 id := r.PathValue("id")
315 after, _ := strconv.Atoi(r.URL.Query().Get("after"))
316 fl, ok := w.(http.Flusher)
317 if !ok {
318 http.Error(w, "streaming unsupported", http.StatusInternalServerError)
319 return
320 }
321 w.Header().Set("Content-Type", "text/event-stream")
322 w.Header().Set("Cache-Control", "no-cache")
323 ctx := r.Context()
324 for {
325 evs, err := s.Runner.Events(ctx, id, after)
326 if err != nil {
327 return
328 }
329 for _, ev := range evs {
330 after = ev.Seq
331 if l, ok := renderLine(ev); ok {
332 b, _ := json.Marshal(l)
333 fmt.Fprintf(w, "id: %d\ndata: %s\n\n", ev.Seq, b)
334 }
335 }
336 fl.Flush()
337 if len(evs) == 0 {
338 v, err := s.Runner.Run(ctx, id)
339 if err != nil || v.State == nil || v.State.Status != engine.StatusRunning {
340 fmt.Fprint(w, "event: end\ndata: {}\n\n")
341 fl.Flush()
342 return
343 }
344 }
345 select {
346 case <-ctx.Done():
347 return
348 case <-time.After(time.Second):
349 }
350 }
351}
352
353func (s *Server) file(w http.ResponseWriter, r *http.Request, who Who) {
354 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
355 if err := s.Runner.File(r.Context(), r.PathValue("id"), r.PathValue("path"), w); err != nil {
356 http.Error(w, err.Error(), http.StatusNotFound)
357 }
358}
359
360func (s *Server) back(w http.ResponseWriter, r *http.Request, id string) {
361 http.Redirect(w, r, "/runs/"+url.PathEscape(id), http.StatusSeeOther)
362}
363
364func (s *Server) gate(w http.ResponseWriter, r *http.Request, who Who) {
365 id := r.PathValue("id")
366 option, _ := strconv.Atoi(r.FormValue("option"))
367 dismiss := r.FormValue("dismiss") != ""
368 if err := s.Runner.Gate(r.Context(), id, option, dismiss, who.Actor(s.OwnerDid)); err != nil {
369 http.Error(w, err.Error(), http.StatusConflict)
370 return
371 }
372 waitBriefly(r.Context())
373 s.back(w, r, id)
374}
375
376func (s *Server) action(w http.ResponseWriter, r *http.Request, who Who) {
377 child, err := s.Runner.Action(r.Context(), r.PathValue("id"), r.PathValue("action"), who.Actor(s.OwnerDid))
378 if err != nil {
379 http.Error(w, err.Error(), http.StatusConflict)
380 return
381 }
382 waitBriefly(r.Context())
383 s.back(w, r, child)
384}
385
386func (s *Server) cancel(w http.ResponseWriter, r *http.Request, who Who) {
387 id := r.PathValue("id")
388 if err := s.Runner.Cancel(r.Context(), id); err != nil {
389 http.Error(w, err.Error(), http.StatusConflict)
390 return
391 }
392 waitBriefly(r.Context())
393 s.back(w, r, id)
394}
395
396func (s *Server) trigger(w http.ResponseWriter, r *http.Request, who Who) {
397 repo, workflow, rev := r.FormValue("repo"), r.FormValue("workflow"), strings.TrimSpace(r.FormValue("ref"))
398 if repo == "" { // without JavaScript, the select's "repo|workflow"
399 repo, workflow, _ = strings.Cut(r.FormValue("target"), "|")
400 }
401 if rev == "" {
402 rev = "main"
403 }
404 var wfs []string
405 if workflow != "" {
406 wfs = []string{workflow}
407 }
408 if _, err := s.Runner.Trigger(r.Context(), ident.RepoDID(s.Knot, repo), rev, wfs); err != nil {
409 http.Error(w, err.Error(), http.StatusBadRequest)
410 return
411 }
412 waitBriefly(r.Context())
413 http.Redirect(w, r, "/?repo="+urlEscape(repo), http.StatusSeeOther)
414}
415
416// waitBriefly gives the runner a moment to start a segment, so the page
417// the person lands on already shows it.
418func waitBriefly(ctx context.Context) {
419 select {
420 case <-ctx.Done():
421 case <-time.After(700 * time.Millisecond):
422 }
423}
424
425func (s *Server) apiRuns(w http.ResponseWriter, r *http.Request, who Who) {
426 limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
427 if limit <= 0 || limit > 500 {
428 limit = 50
429 }
430 runs, err := s.Runner.Runs(r.Context(), r.URL.Query().Get("repo"), limit)
431 if err != nil {
432 apiError(w, http.StatusBadGateway, err.Error())
433 return
434 }
435 apiJSON(w, runs)
436}
437
438func (s *Server) apiRun(w http.ResponseWriter, r *http.Request, who Who) {
439 v, err := s.Runner.Run(r.Context(), r.PathValue("id"))
440 if err != nil {
441 apiError(w, http.StatusNotFound, err.Error())
442 return
443 }
444 apiJSON(w, v)
445}
446
447func (s *Server) apiText(w http.ResponseWriter, r *http.Request, who Who) {
448 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
449 if err := s.Runner.Text(r.Context(), r.PathValue("id"), w); err != nil {
450 http.Error(w, err.Error(), http.StatusNotFound)
451 }
452}
453
454func (s *Server) apiTrigger(w http.ResponseWriter, r *http.Request, who Who) {
455 var in struct {
456 Repo string `json:"repo"`
457 Workflow string `json:"workflow"`
458 Ref string `json:"ref"`
459 }
460 if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&in); err != nil || in.Repo == "" {
461 apiError(w, http.StatusBadRequest, "want {repo, workflow?, ref?}")
462 return
463 }
464 if in.Ref == "" {
465 in.Ref = "main"
466 }
467 var wfs []string
468 if in.Workflow != "" {
469 wfs = []string{in.Workflow}
470 }
471 p, err := s.Runner.Trigger(r.Context(), ident.RepoDID(s.Knot, in.Repo), in.Ref, wfs)
472 if err != nil {
473 apiError(w, http.StatusBadRequest, err.Error())
474 return
475 }
476 apiJSON(w, map[string]string{"pipeline": p})
477}
478
479// apiAction lets agents run manual actions, except danger ones.
480func (s *Server) apiAction(w http.ResponseWriter, r *http.Request, who Who) {
481 id, action := r.PathValue("id"), r.PathValue("action")
482 if !who.Owner {
483 v, err := s.Runner.Run(r.Context(), id)
484 if err != nil {
485 apiError(w, http.StatusNotFound, err.Error())
486 return
487 }
488 if slices.Contains(v.Danger, action) {
489 apiError(w, http.StatusForbidden, action+" is a danger action: it needs the owner in the appview")
490 return
491 }
492 }
493 child, err := s.Runner.Action(r.Context(), id, action, who.Actor(s.OwnerDid))
494 if err != nil {
495 apiError(w, http.StatusConflict, err.Error())
496 return
497 }
498 apiJSON(w, map[string]string{"run": child})
499}
500
501func urlEscape(s string) string { return url.QueryEscape(s) }
502
503var funcs = template.FuncMap{
504 "short": func(s string) string {
505 if len(s) > 12 {
506 return s[:12]
507 }
508 return s
509 },
510 "ago": func(ts string) string {
511 t, err := time.Parse(time.RFC3339Nano, ts)
512 if err != nil {
513 return ts
514 }
515 d := time.Since(t).Round(time.Second)
516 switch {
517 case d < time.Minute:
518 return fmt.Sprintf("%ds ago", int(d.Seconds()))
519 case d < time.Hour:
520 return fmt.Sprintf("%dm ago", int(d.Minutes()))
521 case d < 48*time.Hour:
522 return fmt.Sprintf("%dh ago", int(d.Hours()))
523 }
524 return t.Format("2006-01-02")
525 },
526 "inc": func(i int) int { return i + 1 },
527 "label": func(o lex.Option, v runner.RunView) string { return optionLabel(o, v) },
528 "danger": func(o lex.Option, v runner.RunView) bool { return slices.Contains(v.Danger, o.Action) },
529}
530
531func optionLabel(o lex.Option, v runner.RunView) string {
532 if o.Label != "" {
533 return o.Label
534 }
535 for _, m := range v.Manual {
536 if m.Name == o.Action {
537 return m.Label
538 }
539 }
540 return o.Action
541}