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}