follow.go

  1package runner
  2
  3import (
  4	"bufio"
  5	"bytes"
  6	"context"
  7	"encoding/json"
  8	"fmt"
  9	"io"
 10	"os"
 11	"os/exec"
 12	"time"
 13
 14	"git.kilimanjaro.io/ci/internal/engine"
 15	"git.kilimanjaro.io/ci/internal/lex"
 16)
 17
 18// follow tails a run's events while its segment runs, publishing records,
 19// then settles the run once the segment's process is gone.
 20func (r *Runner) follow(ctx context.Context, run, handle string) {
 21	r.mu.Lock()
 22	if r.tailing[run] {
 23		r.mu.Unlock()
 24		return
 25	}
 26	r.tailing[run] = true
 27	r.mu.Unlock()
 28	defer func() {
 29		r.mu.Lock()
 30		delete(r.tailing, run)
 31		r.mu.Unlock()
 32	}()
 33
 34	x, err := r.GetRun(run)
 35	if err != nil {
 36		r.Logger.Error("follow", "run", run, "err", err)
 37		return
 38	}
 39	for {
 40		alive := handle != "" && r.Launcher.Alive(handle)
 41		if err := r.translate(&x); err != nil {
 42			r.Logger.Error("translate events", "run", run, "err", err)
 43		}
 44		if !alive {
 45			break
 46		}
 47		select {
 48		case <-ctx.Done():
 49			return
 50		case <-time.After(250 * time.Millisecond):
 51		}
 52	}
 53	r.settle(x)
 54}
 55
 56// translate publishes the run's new events (from its stored offset).
 57func (r *Runner) translate(x *Run) error {
 58	f, err := os.Open(engine.Dir(x.Dir).Events())
 59	if err != nil {
 60		return err
 61	}
 62	defer f.Close()
 63	if _, err := f.Seek(x.offset, io.SeekStart); err != nil {
 64		return err
 65	}
 66	b, err := io.ReadAll(f)
 67	if err != nil {
 68		return err
 69	}
 70	end := bytes.LastIndexByte(b, '\n')
 71	if end < 0 {
 72		return nil
 73	}
 74	sc := bufio.NewScanner(bytes.NewReader(b[:end+1]))
 75	sc.Buffer(make([]byte, 64*1024), 16*1024*1024)
 76	for sc.Scan() {
 77		var ev lex.RunEvent
 78		if err := json.Unmarshal(sc.Bytes(), &ev); err != nil {
 79			continue
 80		}
 81		switch {
 82		case ev.Kind == lex.KindGate && ev.Gate != nil:
 83			if _, err := r.Log.Append(lex.GateNSID, "", ev.Gate); err != nil {
 84				return err
 85			}
 86		case ev.Kind == lex.KindTransition && ev.Transition != nil:
 87			if _, err := r.Log.Append(lex.TransitionNSID, "", ev.Transition); err != nil {
 88				return err
 89			}
 90		}
 91	}
 92	x.offset += int64(end + 1)
 93	return r.updateRun(x.ID, `offset = ?`, x.offset)
 94}
 95
 96// settle records how a segment left its run: paused at a gate, finished, or
 97// lost (the process died without saying).
 98func (r *Runner) settle(x Run) {
 99	defer func() { <-r.slots }()
100	st, err := engine.Load(engine.Dir(x.Dir))
101	if err != nil {
102		r.Logger.Error("settle", "run", x.ID, "err", err)
103		return
104	}
105	if st.Status == engine.StatusRunning {
106		if err := engine.MarkLost(engine.Dir(x.Dir), "the run's process ended without finishing it (see segment logs)"); err != nil {
107			r.Logger.Error("mark lost", "run", x.ID, "err", err)
108		}
109		if err := r.translate(&x); err != nil {
110			r.Logger.Error("translate", "run", x.ID, "err", err)
111		}
112		if st, err = engine.Load(engine.Dir(x.Dir)); err != nil {
113			return
114		}
115	}
116	if err := r.updateRun(x.ID, `status = ?`, st.Status); err != nil {
117		r.Logger.Error("update run", "run", x.ID, "err", err)
118	}
119	r.Logger.Info("segment ended", "run", x.ID, "status", st.Status)
120	switch {
121	case st.Status == engine.StatusPaused && st.Gate != nil:
122		r.notify(Notification{Kind: "gate", Run: x, Status: st.Status,
123			Title:   fmt.Sprintf("%s/%s: waiting for you", x.Repo, x.Workflow),
124			Message: orDefault(st.Gate.Prompt, "Choose the next step") + summaryLine(st.Gate.Summary)})
125	case engine.Terminal(st.Status):
126		if x.Parent == "" {
127			status, msg := tangledStatus(st)
128			r.status(x.Pipeline, x.Workflow, status, msg)
129		}
130		detail := ""
131		if st.End != nil {
132			detail = st.End.Detail
133		}
134		r.notify(Notification{Kind: "finished", Run: x, Status: st.Status,
135			Title: fmt.Sprintf("%s/%s: %s", x.Repo, x.Workflow, st.Status), Message: detail})
136	}
137}
138
139func summaryLine(s string) string {
140	if s == "" {
141		return ""
142	}
143	line, _, _ := bytes.Cut([]byte(s), []byte("\n"))
144	return "\n" + string(line)
145}
146
147// tangledStatus maps a finished run onto sh.tangled.pipeline.status.
148func tangledStatus(st *engine.State) (string, string) {
149	detail := ""
150	if st.End != nil {
151		detail = st.End.Detail
152	}
153	switch st.Status {
154	case engine.StatusSuccess:
155		return lex.StatusSuccess, ""
156	case engine.StatusCancelled:
157		if st.End != nil && st.End.Reason == lex.ReasonExpired {
158			return lex.StatusTimeout, detail
159		}
160		return lex.StatusCancelled, detail
161	}
162	return lex.StatusFailed, detail
163}
164
165func (r *Runner) notify(n Notification) {
166	if r.Cfg.Notify != nil {
167		r.Cfg.Notify(n)
168	}
169}
170
171// Reconcile picks up runs after a restart: it follows live segments again,
172// settles finished ones, starts runs that never started, and expires gates.
173func (r *Runner) Reconcile(ctx context.Context) error {
174	rows, err := r.db().Query(`select ` + runCols + ` from runs where status in ('pending', 'running')`)
175	if err != nil {
176		return err
177	}
178	var runs []Run
179	for rows.Next() {
180		x, err := scanRun(rows)
181		if err != nil {
182			rows.Close()
183			return err
184		}
185		runs = append(runs, x)
186	}
187	rows.Close()
188	for _, x := range runs {
189		if x.handle == "" {
190			if err := r.launch(ctx, x.ID, Segment{}); err != nil {
191				r.Logger.Error("start pending run", "run", x.ID, "err", err)
192			}
193			continue
194		}
195		select {
196		case r.slots <- struct{}{}: // follow releases it in settle
197		case <-ctx.Done():
198			return ctx.Err()
199		}
200		go r.follow(context.WithoutCancel(ctx), x.ID, x.handle)
201	}
202	return nil
203}
204
205// ExpireGates answers expired gates of paused runs (on a timer).
206func (r *Runner) ExpireGates(ctx context.Context) {
207	rows, err := r.db().Query(`select ` + runCols + ` from runs where status = 'paused'`)
208	if err != nil {
209		return
210	}
211	var due []string
212	for rows.Next() {
213		x, err := scanRun(rows)
214		if err != nil {
215			break
216		}
217		if st, err := engine.Load(engine.Dir(x.Dir)); err == nil && st.Gate != nil && st.Gate.Expired(time.Now()) {
218			due = append(due, x.ID)
219		}
220	}
221	rows.Close()
222	for _, id := range due {
223		if err := r.launch(ctx, id, Segment{Expire: true, Actor: "runner"}); err != nil {
224			r.Logger.Error("expire gate", "run", id, "err", err)
225		}
226	}
227}
228
229// Loop runs the gate expiry timer and the retention of finished runs until ctx ends.
230func (r *Runner) Loop(ctx context.Context) {
231	go r.pruneLoop(ctx)
232	for {
233		select {
234		case <-ctx.Done():
235			return
236		case <-time.After(30 * time.Second):
237			r.ExpireGates(ctx)
238		}
239	}
240}
241
242func gitOut(ctx context.Context, gitDir string, args ...string) (string, error) {
243	cmd := exec.CommandContext(ctx, "git", append([]string{"--git-dir", gitDir}, args...)...)
244	var stderr bytes.Buffer
245	cmd.Stderr = &stderr
246	out, err := cmd.Output()
247	if err != nil {
248		return "", fmt.Errorf("git %v: %v: %s", args, err, bytes.TrimSpace(stderr.Bytes()))
249	}
250	return string(out), nil
251}