api.go

  1package runner
  2
  3import (
  4	"bytes"
  5	"context"
  6	"encoding/json"
  7	"errors"
  8	"fmt"
  9	"io"
 10	"maps"
 11	"net/http"
 12	"os"
 13	"path/filepath"
 14	"slices"
 15	"strings"
 16	"time"
 17
 18	"github.com/gorilla/websocket"
 19
 20	"git.kilimanjaro.io/ci/internal/engine"
 21	"git.kilimanjaro.io/ci/internal/eventlog"
 22	"git.kilimanjaro.io/ci/internal/lex"
 23	"git.kilimanjaro.io/ci/internal/manifest"
 24	"git.kilimanjaro.io/ci/internal/runlog"
 25	"git.kilimanjaro.io/ci/internal/tid"
 26)
 27
 28// Handler serves the runner's API. Everything but /healthz needs token
 29// (when set); the appview holds it and decides who may do what.
 30//
 31//	GET  /events                              records stream (spindle-compatible)
 32//	GET  /logs/{knot}/{rkey}/{name}           a workflow's log, upstream LogLine messages
 33//	POST /xrpc/sh.tangled.ci.triggerPipeline  manual pipeline
 34//	GET  /api/runs[?repo=&limit=]             runs, newest first
 35//	GET  /api/runs/{id}                       run row + state.json
 36//	GET  /api/runs/{id}/events[?after=N]      events.ndjson lines after seq N
 37//	GET  /api/runs/{id}/log.txt               plain-text log
 38//	GET  /api/runs/{id}/files/{path...}       results/ and artifacts/ files
 39//	POST /api/runs/{id}/gate                  {"option":N|"action":"x"|"dismiss":true, "actor":"..."}
 40//	POST /api/runs/{id}/actions/{action}      start a manual action run {"actor":"..."}
 41//	POST /api/runs/{id}/cancel                stop a running segment / dismiss a gate
 42//	PUT  /api/runs/{id}/import                store a reported local run (tar.gz)
 43//	POST|DELETE /api/locks/{group}?holder=    lease a concurrency group (local runs)
 44//	GET  /healthz
 45func (r *Runner) Handler(token string) http.Handler {
 46	mux := http.NewServeMux()
 47	mux.Handle("GET /events", r.Log)
 48	mux.HandleFunc("GET /logs/{knot}/{rkey}/{name}", r.serveLogs)
 49	mux.HandleFunc("POST /xrpc/sh.tangled.ci.triggerPipeline", r.serveTrigger)
 50	mux.HandleFunc("GET /api/runs", r.serveRuns)
 51	mux.HandleFunc("GET /api/runs/{id}", r.serveRun)
 52	mux.HandleFunc("GET /api/runs/{id}/events", r.serveEvents)
 53	mux.HandleFunc("GET /api/runs/{id}/log.txt", r.serveText)
 54	mux.HandleFunc("GET /api/runs/{id}/files/{path...}", r.serveFile)
 55	mux.HandleFunc("POST /api/runs/{id}/gate", r.serveGate)
 56	mux.HandleFunc("POST /api/runs/{id}/actions/{action}", r.serveAction)
 57	mux.HandleFunc("POST /api/runs/{id}/cancel", r.serveCancel)
 58	mux.HandleFunc("PUT /api/runs/{id}/import", r.serveImport)
 59	mux.HandleFunc("POST /api/locks/{group}", r.serveLock)
 60	mux.HandleFunc("DELETE /api/locks/{group}", r.serveLock)
 61	authed := eventlog.RequireToken(token, mux)
 62	top := http.NewServeMux()
 63	top.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
 64		if _, err := r.Log.List(0, 1); err != nil {
 65			http.Error(w, err.Error(), http.StatusServiceUnavailable)
 66			return
 67		}
 68		fmt.Fprintln(w, "ok")
 69	})
 70	top.Handle("/", authed)
 71	return top
 72}
 73
 74func writeJSON(w http.ResponseWriter, code int, v any) {
 75	w.Header().Set("Content-Type", "application/json")
 76	w.WriteHeader(code)
 77	_ = json.NewEncoder(w).Encode(v)
 78}
 79
 80func fail(w http.ResponseWriter, code int, err error) {
 81	writeJSON(w, code, map[string]string{"error": err.Error()})
 82}
 83
 84// RunView is a run as the API returns it.
 85type RunView struct {
 86	Run
 87	State *engine.State `json:"state,omitempty"`
 88	// Manual are the workflow's manual actions (buttons on the run page).
 89	Manual []ManualAction `json:"manual,omitempty"`
 90	// Danger lists every action with style danger (gate options too).
 91	Danger []string `json:"danger,omitempty"`
 92}
 93
 94// ManualAction is a manual action of a run's workflow.
 95type ManualAction struct {
 96	Name  string `json:"name"`
 97	Label string `json:"label"`
 98	Style string `json:"style,omitempty"`
 99	Gated bool   `json:"gated,omitempty"`
100}
101
102func (r *Runner) view(x Run) RunView {
103	v := RunView{Run: x}
104	if st, err := engine.Load(engine.Dir(x.Dir)); err == nil {
105		v.State = st
106		if raw, err := os.ReadFile(engine.Dir(x.Dir).ManifestFile()); err == nil {
107			if m, err := manifest.Parse(st.Meta.Workflow, raw); err == nil {
108				for _, name := range slices.Sorted(maps.Keys(m.Actions)) {
109					if m.Actions[name].Style == "danger" {
110						v.Danger = append(v.Danger, name)
111					}
112					if a := m.Actions[name]; a.Manual {
113						v.Manual = append(v.Manual, ManualAction{Name: name, Label: m.Label(name), Style: a.Style, Gated: a.Gate != nil})
114					}
115				}
116			}
117		}
118	}
119	return v
120}
121
122func (r *Runner) serveRuns(w http.ResponseWriter, req *http.Request) {
123	limit := 50
124	if n, err := fmt.Sscan(req.URL.Query().Get("limit"), &limit); n != 1 || err != nil || limit <= 0 || limit > 500 {
125		limit = 50
126	}
127	runs, err := r.ListRuns(req.URL.Query().Get("repo"), limit)
128	if err != nil {
129		fail(w, 500, err)
130		return
131	}
132	out := make([]RunView, 0, len(runs))
133	for _, x := range runs {
134		out = append(out, r.view(x))
135	}
136	writeJSON(w, 200, out)
137}
138
139func (r *Runner) run(w http.ResponseWriter, req *http.Request) (Run, bool) {
140	x, err := r.GetRun(req.PathValue("id"))
141	if err != nil {
142		fail(w, 404, fmt.Errorf("no run %s", req.PathValue("id")))
143		return Run{}, false
144	}
145	return x, true
146}
147
148func (r *Runner) serveRun(w http.ResponseWriter, req *http.Request) {
149	if x, ok := r.run(w, req); ok {
150		writeJSON(w, 200, r.view(x))
151	}
152}
153
154func (r *Runner) serveEvents(w http.ResponseWriter, req *http.Request) {
155	x, ok := r.run(w, req)
156	if !ok {
157		return
158	}
159	after := 0
160	_, _ = fmt.Sscan(req.URL.Query().Get("after"), &after)
161	f, err := os.Open(engine.Dir(x.Dir).Events())
162	if err != nil {
163		fail(w, 500, err)
164		return
165	}
166	defer f.Close()
167	w.Header().Set("Content-Type", "application/x-ndjson")
168	_ = runlog.Scan(f, func(ev lex.RunEvent) error {
169		if ev.Seq > after {
170			b, _ := json.Marshal(ev)
171			_, err := w.Write(append(b, '\n'))
172			return err
173		}
174		return nil
175	})
176}
177
178// serveText renders the log as plain text: what an agent reads (ci wait).
179func (r *Runner) serveText(w http.ResponseWriter, req *http.Request) {
180	x, ok := r.run(w, req)
181	if !ok {
182		return
183	}
184	f, err := os.Open(engine.Dir(x.Dir).Events())
185	if err != nil {
186		fail(w, 500, err)
187		return
188	}
189	defer f.Close()
190	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
191	_ = runlog.Scan(f, func(ev lex.RunEvent) error {
192		_, err := io.WriteString(w, TextLine(ev))
193		return err
194	})
195}
196
197// TextLine renders an event as plain text ("" for events not worth a line).
198func TextLine(ev lex.RunEvent) string {
199	switch ev.Kind {
200	case lex.KindStart:
201		return fmt.Sprintf("== %s (#%d): %s\n", ev.Action, ev.Attempt, firstLine(ev.Command))
202	case lex.KindLog:
203		return ev.Content + "\n"
204	case lex.KindEnd:
205		code := 0
206		if ev.ExitCode != nil {
207			code = *ev.ExitCode
208		}
209		return fmt.Sprintf("== %s: %s (exit %d)\n", ev.Action, ev.Status, code)
210	case lex.KindResult:
211		if ev.Result != nil && ev.Result.Summary != "" {
212			return "   " + strings.ReplaceAll(strings.TrimRight(ev.Result.Summary, "\n"), "\n", "\n   ") + "\n"
213		}
214	case lex.KindGate:
215		if g := ev.Gate; g != nil {
216			return fmt.Sprintf("== gate %s: %s\n", g.State, orDefault(g.Prompt, g.Choice))
217		}
218	case lex.KindRun:
219		if ev.Status != engine.StatusRunning {
220			return fmt.Sprintf("== run %s %s\n", ev.Status, ev.Content)
221		}
222	}
223	return ""
224}
225
226func firstLine(s string) string {
227	s = strings.TrimSpace(s)
228	if i := strings.IndexByte(s, '\n'); i >= 0 {
229		return s[:i] + " …"
230	}
231	return s
232}
233
234func (r *Runner) serveFile(w http.ResponseWriter, req *http.Request) {
235	x, ok := r.run(w, req)
236	if !ok {
237		return
238	}
239	p := filepath.Clean(req.PathValue("path"))
240	if (!strings.HasPrefix(p, "results/") && !strings.HasPrefix(p, "artifacts/")) || strings.Contains(p, "..") {
241		fail(w, 404, errors.New("not found"))
242		return
243	}
244	full := filepath.Join(x.Dir, p)
245	real, err := filepath.EvalSymlinks(full)
246	if err != nil || !strings.HasPrefix(real, filepath.Join(x.Dir, filepath.Dir(p))) {
247		fail(w, 404, errors.New("not found"))
248		return
249	}
250	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
251	http.ServeFile(w, req, real)
252}
253
254type gateAnswer struct {
255	Option  int    `json:"option"` // 1-based
256	Action  string `json:"action"`
257	Dismiss bool   `json:"dismiss"`
258	Actor   string `json:"actor"`
259}
260
261func (r *Runner) serveGate(w http.ResponseWriter, req *http.Request) {
262	x, ok := r.run(w, req)
263	if !ok {
264		return
265	}
266	var a gateAnswer
267	if err := json.NewDecoder(io.LimitReader(req.Body, 1<<16)).Decode(&a); err != nil {
268		fail(w, 400, err)
269		return
270	}
271	if err := r.AnswerGate(req.Context(), x, a); err != nil {
272		fail(w, 409, err)
273		return
274	}
275	writeJSON(w, 202, map[string]string{"run": x.ID})
276}
277
278// AnswerGate starts the segment that answers a paused run's gate.
279func (r *Runner) AnswerGate(ctx context.Context, x Run, a gateAnswer) error {
280	st, err := engine.Load(engine.Dir(x.Dir))
281	if err != nil {
282		return err
283	}
284	if x.Status != engine.StatusPaused || st.Gate == nil {
285		return fmt.Errorf("run %s is %s, not waiting at a gate", x.ID, x.Status)
286	}
287	seg := Segment{Actor: a.Actor, Dismiss: a.Dismiss}
288	if !a.Dismiss {
289		switch {
290		case a.Option >= 1 && a.Option <= len(st.Gate.Options):
291			seg.Choose = a.Option
292		case a.Action != "":
293			for i, o := range st.Gate.Options {
294				if o.Action == a.Action {
295					seg.Choose = i + 1
296					break
297				}
298			}
299		}
300		if seg.Choose == 0 {
301			return errors.New("no such option")
302		}
303	}
304	if err := r.updateRun(x.ID, `status = ?`, engine.StatusRunning); err != nil {
305		return err
306	}
307	return r.launch(context.WithoutCancel(ctx), x.ID, seg)
308}
309
310func (r *Runner) serveAction(w http.ResponseWriter, req *http.Request) {
311	x, ok := r.run(w, req)
312	if !ok {
313		return
314	}
315	var body struct {
316		Actor string `json:"actor"`
317	}
318	_ = json.NewDecoder(io.LimitReader(req.Body, 1<<16)).Decode(&body)
319	id, err := r.ManualAction(req.Context(), x, req.PathValue("action"), body.Actor)
320	if err != nil {
321		fail(w, 409, err)
322		return
323	}
324	writeJSON(w, 202, map[string]string{"run": id})
325}
326
327// ManualAction starts a child run of x at a manual action (DESIGN.md §9):
328// it shares x's checkouts and $CI_STATE and uses the manifest x ran with.
329func (r *Runner) ManualAction(ctx context.Context, x Run, action, actor string) (string, error) {
330	parent := x
331	if x.Parent != "" {
332		if p, err := r.GetRun(x.Parent); err == nil {
333			parent = p
334		}
335	}
336	pst, err := engine.Load(engine.Dir(parent.Dir))
337	if err != nil {
338		return "", err
339	}
340	raw, err := os.ReadFile(engine.Dir(parent.Dir).ManifestFile())
341	if err != nil {
342		return "", err
343	}
344	m, err := manifest.Parse(pst.Meta.Workflow, raw)
345	if err != nil {
346		return "", err
347	}
348	a, ok := m.Actions[action]
349	if !ok || !a.Manual {
350		return "", fmt.Errorf("%s is not a manual action of %s", action, m.Name)
351	}
352	id := parent.ID + "-" + action + "-" + tid.New()
353	meta := pst.Meta
354	meta.Run, meta.Parent = id, parent.Dir
355	dir := engine.Dir(filepath.Join(r.Cfg.RunsDir, id))
356	if meta.Host == "local" { // a reported local run: check its commits out here
357		if err := r.relayout(ctx, &meta, m.Repos, dir); err != nil {
358			_ = os.RemoveAll(dir.Path())
359			return "", err
360		}
361	}
362	e, err := engine.Create(m, dir, meta, nil, engine.Options{})
363	if err != nil {
364		return "", err
365	}
366	e.Log.Close()
367	t := now()
368	if err := r.insertRun(Run{ID: id, Pipeline: parent.Pipeline, Rkey: parent.Rkey, Workflow: parent.Workflow, Repo: parent.Repo,
369		Dir: dir.Path(), Parent: parent.ID, Status: lex.StatusPending, Created: t, Updated: t}); err != nil {
370		return "", err
371	}
372	return id, r.launch(context.WithoutCancel(ctx), id, Segment{From: action, Actor: actor})
373}
374
375func (r *Runner) serveCancel(w http.ResponseWriter, req *http.Request) {
376	x, ok := r.run(w, req)
377	if !ok {
378		return
379	}
380	switch x.Status {
381	case engine.StatusPaused:
382		if err := r.AnswerGate(req.Context(), x, gateAnswer{Dismiss: true, Actor: "cancel"}); err != nil {
383			fail(w, 409, err)
384			return
385		}
386	case engine.StatusRunning, lex.StatusPending:
387		if x.handle == "" || r.Launcher.Stop(x.handle) != nil {
388			fail(w, 409, errors.New("nothing to stop"))
389			return
390		}
391	default:
392		fail(w, 409, fmt.Errorf("run %s is %s", x.ID, x.Status))
393		return
394	}
395	writeJSON(w, 202, map[string]string{"run": x.ID})
396}
397
398func (r *Runner) serveTrigger(w http.ResponseWriter, req *http.Request) {
399	var in lex.TriggerPipelineInput
400	if err := json.NewDecoder(io.LimitReader(req.Body, 1<<20)).Decode(&in); err != nil || in.Trigger == nil {
401		fail(w, 400, fmt.Errorf("InvalidRequest: %v", err))
402		return
403	}
404	ref := ""
405	if in.Trigger.Ref != nil {
406		ref = *in.Trigger.Ref
407	}
408	uri, err := r.StartPipeline(req.Context(), Trigger{Kind: "manual", RepoDid: in.Repo, Sha: in.Trigger.Sha, Ref: ref,
409		Inputs: in.Trigger.Inputs, Workflows: in.Workflows})
410	if err != nil {
411		fail(w, 400, err)
412		return
413	}
414	if uri == "" {
415		fail(w, 400, errors.New("no matching workflows"))
416		return
417	}
418	writeJSON(w, 200, lex.TriggerPipelineOutput{Pipeline: uri})
419}
420
421var upgrader = websocket.Upgrader{ReadBufferSize: 1024, WriteBufferSize: 1024}
422
423// serveLogs streams a workflow's log as upstream's /logs does: LogLine
424// messages (control start/end per action execution, data per line), replay
425// then tail, closed when the run ends.
426func (r *Runner) serveLogs(w http.ResponseWriter, req *http.Request) {
427	x, err := r.RunByWorkflow(req.PathValue("rkey"), req.PathValue("name"))
428	if err != nil {
429		http.Error(w, "no such workflow", http.StatusNotFound)
430		return
431	}
432	conn, err := upgrader.Upgrade(w, req, nil)
433	if err != nil {
434		return
435	}
436	defer conn.Close()
437	ctx, cancel := context.WithCancel(req.Context())
438	defer cancel()
439	go func() {
440		for {
441			if _, _, err := conn.NextReader(); err != nil {
442				cancel()
443				return
444			}
445		}
446	}()
447	dir := engine.Dir(x.Dir)
448	var off int64
449	lastPing := time.Now()
450	for {
451		n, err := sendLogLines(conn, dir.Events(), off)
452		if err != nil {
453			return
454		}
455		off += n
456		if st, err := engine.Load(dir); err == nil && engine.Terminal(st.Status) && n == 0 {
457			_ = conn.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, "log stream complete"), time.Now().Add(time.Second))
458			return
459		}
460		if time.Since(lastPing) > 30*time.Second {
461			if conn.WriteControl(websocket.PingMessage, nil, time.Now().Add(time.Second)) != nil {
462				return
463			}
464			lastPing = time.Now()
465		}
466		select {
467		case <-ctx.Done():
468			return
469		case <-time.After(300 * time.Millisecond):
470		}
471	}
472}
473
474func sendLogLines(conn *websocket.Conn, path string, off int64) (int64, error) {
475	f, err := os.Open(path)
476	if err != nil {
477		return 0, err
478	}
479	defer f.Close()
480	if _, err := f.Seek(off, io.SeekStart); err != nil {
481		return 0, err
482	}
483	b, err := io.ReadAll(f)
484	if err != nil {
485		return 0, err
486	}
487	end := bytes.LastIndexByte(b, '\n')
488	if end < 0 {
489		return 0, nil
490	}
491	err = runlog.Scan(bytes.NewReader(b[:end+1]), func(ev lex.RunEvent) error {
492		if l, ok := LogLineOf(ev); ok {
493			msg, _ := json.Marshal(l)
494			return conn.WriteMessage(websocket.TextMessage, msg)
495		}
496		return nil
497	})
498	return int64(end + 1), err
499}
500
501// LogLineOf translates a run event into upstream's log message, if it is one.
502func LogLineOf(ev lex.RunEvent) (lex.LogLine, bool) {
503	switch ev.Kind {
504	case lex.KindStart:
505		return lex.LogLine{Kind: "control", Content: ev.Action, Time: ev.Time, StepId: ev.Attempt,
506			StepStatus: "start", StepKind: "user", StepCommand: ev.Command}, true
507	case lex.KindEnd:
508		return lex.LogLine{Kind: "control", Content: ev.Action, Time: ev.Time, StepId: ev.Attempt,
509			StepStatus: "end", StepKind: "user"}, true
510	case lex.KindLog:
511		if ev.Attempt == 0 {
512			return lex.LogLine{}, false // engine messages outside an action
513		}
514		return lex.LogLine{Kind: "data", Content: ev.Content, Time: ev.Time, StepId: ev.Attempt, Stream: ev.Stream}, true
515	}
516	return lex.LogLine{}, false
517}