runner.go
1// Package runner is the CI host's daemon (DESIGN.md ยง10): spindle-compatible
2// on the outside, a host for the engine on the inside.
3//
4// It follows the bridge's sh.tangled.git.refUpdate stream, reads the pushed
5// commit's .tangled/workflows from a mirror, matches `when:`, records a
6// sh.tangled.pipeline, prepares a run directory per matching workflow, and
7// starts the engine (`ci runner-exec`) in a sandboxed unit per segment. It
8// tails each run's events.ndjson to publish sh.tangled.pipeline.status,
9// run.rtw.ci.gate and run.rtw.ci.transition records on its own /events, and
10// serves logs on /logs in upstream's format. Gates are answered through its
11// API (the appview), which starts the next segment.
12package runner
13
14import (
15 "context"
16 "database/sql"
17 "encoding/json"
18 "errors"
19 "fmt"
20 "log/slog"
21 "os"
22 "path"
23 "path/filepath"
24 "slices"
25 "strings"
26 "sync"
27 "time"
28
29 "git.kilimanjaro.io/ci/internal/checkout"
30 "git.kilimanjaro.io/ci/internal/engine"
31 "git.kilimanjaro.io/ci/internal/eventlog"
32 "git.kilimanjaro.io/ci/internal/ident"
33 "git.kilimanjaro.io/ci/internal/lex"
34 "git.kilimanjaro.io/ci/internal/manifest"
35 "git.kilimanjaro.io/ci/internal/tid"
36)
37
38// Config configures a Runner.
39type Config struct {
40 RunsDir string // run directories
41 CacheDir string // mirrors and per-repo HOMEs
42 LocksDir string // concurrency locks
43 // Knot is the forge host: pipelines are at://did:web:<Knot>/sh.tangled.pipeline/<rkey>.
44 Knot string
45 // OwnerDid owns the repos (sh.tangled.pipeline#triggerRepo.did).
46 OwnerDid string
47 // CloneURL is a template with {repo}, e.g. [email protected]:{repo}.
48 CloneURL string
49 // MaxConcurrent limits segments running at once (default 2).
50 MaxConcurrent int
51 // SlimAfter and DeleteAfter are the retention of finished runs (see
52 // prune.go): after SlimAfter a run keeps only its record, after DeleteAfter
53 // it is deleted. Zero means DefaultSlimAfter / DefaultDeleteAfter; negative
54 // turns that stage off.
55 SlimAfter, DeleteAfter time.Duration
56 // Notify is called for gates and finished runs (ntfy), may be nil.
57 Notify func(Notification)
58}
59
60// Runner is the daemon.
61type Runner struct {
62 Cfg Config
63 Log *eventlog.Log
64 Launcher Launcher
65 Logger *slog.Logger
66
67 src *checkout.Source
68 slots chan struct{}
69 mu sync.Mutex
70 // tailing marks runs whose events are being followed.
71 tailing map[string]bool
72 leases *leases
73}
74
75// New prepares a runner and its tables.
76func New(cfg Config, log *eventlog.Log, l Launcher, logger *slog.Logger) (*Runner, error) {
77 if cfg.MaxConcurrent <= 0 {
78 cfg.MaxConcurrent = 2
79 }
80 if cfg.LocksDir == "" {
81 cfg.LocksDir = filepath.Join(cfg.RunsDir, ".locks")
82 }
83 if logger == nil {
84 logger = slog.Default()
85 }
86 for _, d := range []string{cfg.RunsDir, cfg.CacheDir, cfg.LocksDir} {
87 if err := os.MkdirAll(d, 0o755); err != nil {
88 return nil, err
89 }
90 }
91 r := &Runner{
92 Cfg: cfg, Log: log, Launcher: l, Logger: logger,
93 src: &checkout.Source{MirrorsDir: filepath.Join(cfg.CacheDir, "mirrors"), URL: checkout.URLFunc(cfg.CloneURL, "")},
94 slots: make(chan struct{}, cfg.MaxConcurrent),
95 tailing: map[string]bool{},
96 }
97 if _, err := log.DB().Exec(`
98 create table if not exists cursors (source text primary key, cursor integer not null);
99 create table if not exists runs (
100 id text primary key,
101 pipeline text not null,
102 rkey text not null,
103 workflow text not null,
104 repo text not null,
105 dir text not null,
106 parent text not null default '',
107 status text not null,
108 offset integer not null default 0,
109 segment integer not null default 0,
110 handle text not null default '',
111 created text not null,
112 updated text not null
113 );
114 create index if not exists runs_rkey on runs (rkey, workflow);`); err != nil {
115 return nil, err
116 }
117 return r, nil
118}
119
120// Run is a row of the runs table.
121type Run struct {
122 ID string `json:"id"`
123 Pipeline string `json:"pipeline"`
124 Rkey string `json:"rkey"`
125 Workflow string `json:"workflow"`
126 Repo string `json:"repo"`
127 Dir string `json:"dir"`
128 Parent string `json:"parent,omitempty"`
129 Status string `json:"status"`
130 Created string `json:"created"`
131 Updated string `json:"updated"`
132 offset int64
133 segment int
134 handle string
135}
136
137func (r *Runner) db() *sql.DB { return r.Log.DB() }
138
139func (r *Runner) cursor(source string) int64 {
140 var c int64
141 _ = r.db().QueryRow(`select cursor from cursors where source = ?`, source).Scan(&c)
142 return c
143}
144
145func (r *Runner) setCursor(source string, c int64) error {
146 _, err := r.db().Exec(`insert into cursors (source, cursor) values (?, ?) on conflict(source) do update set cursor = excluded.cursor`, source, c)
147 return err
148}
149
150const runCols = `id, pipeline, rkey, workflow, repo, dir, parent, status, offset, segment, handle, created, updated`
151
152func scanRun(sc interface{ Scan(...any) error }) (Run, error) {
153 var x Run
154 err := sc.Scan(&x.ID, &x.Pipeline, &x.Rkey, &x.Workflow, &x.Repo, &x.Dir, &x.Parent, &x.Status, &x.offset, &x.segment, &x.handle, &x.Created, &x.Updated)
155 return x, err
156}
157
158// GetRun returns one run.
159func (r *Runner) GetRun(id string) (Run, error) {
160 return scanRun(r.db().QueryRow(`select `+runCols+` from runs where id = ?`, id))
161}
162
163// RunByWorkflow finds a pipeline's run of a workflow (for /logs).
164func (r *Runner) RunByWorkflow(rkey, workflow string) (Run, error) {
165 return scanRun(r.db().QueryRow(`select `+runCols+` from runs where rkey = ? and workflow = ? and parent = '' order by created limit 1`, rkey, workflow))
166}
167
168// ListRuns returns the most recent runs, newest first, optionally of a repo.
169func (r *Runner) ListRuns(repo string, limit int) ([]Run, error) {
170 q := `select ` + runCols + ` from runs`
171 args := []any{}
172 if repo != "" {
173 q += ` where repo = ?`
174 args = append(args, repo)
175 }
176 q += ` order by created desc limit ?`
177 args = append(args, limit)
178 rows, err := r.db().Query(q, args...)
179 if err != nil {
180 return nil, err
181 }
182 defer rows.Close()
183 var out []Run
184 for rows.Next() {
185 x, err := scanRun(rows)
186 if err != nil {
187 return nil, err
188 }
189 out = append(out, x)
190 }
191 return out, rows.Err()
192}
193
194func (r *Runner) insertRun(x Run) error {
195 _, err := r.db().Exec(`insert into runs (`+runCols+`) values (?, ?, ?, ?, ?, ?, ?, ?, 0, 0, '', ?, ?)`,
196 x.ID, x.Pipeline, x.Rkey, x.Workflow, x.Repo, x.Dir, x.Parent, x.Status, x.Created, x.Updated)
197 return err
198}
199
200func (r *Runner) updateRun(id string, set string, args ...any) error {
201 args = append(args, now(), id)
202 _, err := r.db().Exec(`update runs set `+set+`, updated = ? where id = ?`, args...)
203 return err
204}
205
206func now() string { return time.Now().UTC().Format(time.RFC3339Nano) }
207
208// pipelineURI is a pipeline's AT-URI, as the spindle forms it.
209func (r *Runner) pipelineURI(rkey string) string {
210 return fmt.Sprintf("at://%s/%s/%s", ident.ServiceDID(r.Cfg.Knot), lex.PipelineNSID, rkey)
211}
212
213// Follow consumes the bridge's stream until ctx ends.
214func (r *Runner) Follow(ctx context.Context, url, token string) error {
215 source := "bridge:" + url
216 return eventlog.Subscribe(ctx, url, r.cursor(source), token, func(ev lex.Event) error {
217 if ev.Nsid == lex.GitRefUpdateNSID {
218 var u lex.GitRefUpdate
219 if err := json.Unmarshal(ev.Event, &u); err != nil {
220 r.Logger.Error("bad ref update", "rkey", ev.Rkey, "err", err)
221 } else if err := r.OnRefUpdate(ctx, u); err != nil {
222 // a push we can't process is logged, not retried forever
223 r.Logger.Error("ref update", "repo", u.Repo, "ref", u.Ref, "err", err)
224 }
225 }
226 return r.setCursor(source, ev.Created)
227 })
228}
229
230// Trigger says what started a pipeline.
231type Trigger struct {
232 Kind string // push | manual
233 RepoDid string
234 Ref string
235 OldSha string
236 Sha string
237 ChangedFiles []string
238 Inputs []*lex.PipelinePair
239 Workflows []string // manual: only these (default all)
240}
241
242// OnRefUpdate starts the workflows a push matches.
243func (r *Runner) OnRefUpdate(ctx context.Context, u lex.GitRefUpdate) error {
244 if u.NewSha == "" || strings.Trim(u.NewSha, "0") == "" {
245 return nil // a deleted ref
246 }
247 for _, o := range u.PushOptions {
248 if o == "skip-ci" || o == "ci-skip" {
249 r.Logger.Info("push asked to skip ci", "repo", u.Repo, "ref", u.Ref)
250 return nil
251 }
252 }
253 _, err := r.StartPipeline(ctx, Trigger{Kind: "push", RepoDid: u.Repo, Ref: u.Ref, OldSha: u.OldSha, Sha: u.NewSha, ChangedFiles: u.ChangedFiles})
254 return err
255}
256
257// StartPipeline reads the workflows at the trigger's commit, records a
258// pipeline for those that match, and starts a run of each. It returns the
259// pipeline's AT-URI, or "" if no workflow matched.
260func (r *Runner) StartPipeline(ctx context.Context, t Trigger) (string, error) {
261 host, repo, err := ident.ParseRepoDID(t.RepoDid)
262 if err != nil {
263 return "", err
264 }
265 if host != r.Cfg.Knot {
266 return "", fmt.Errorf("repo %s is not on this runner's forge %s", t.RepoDid, r.Cfg.Knot)
267 }
268 mirror, err := r.src.Mirror(ctx, repo)
269 if err != nil {
270 return "", err
271 }
272 if t.Sha, err = checkout.Resolve(ctx, mirror, t.Sha); err != nil {
273 return "", err
274 }
275 defaultBranch := "main"
276 if out, err := gitOut(ctx, mirror, "symbolic-ref", "--short", "HEAD"); err == nil {
277 defaultBranch = strings.TrimSpace(out)
278 }
279 files, err := workflowFiles(ctx, mirror, t.Sha)
280 if err != nil {
281 return "", err
282 }
283 var matched []*manifest.Manifest
284 var invalid []invalidWorkflow
285 for _, f := range files {
286 raw, err := gitOut(ctx, mirror, "show", t.Sha+":"+f)
287 if err != nil {
288 return "", err
289 }
290 m, err := manifest.Parse(manifest.NameOf(f), []byte(raw))
291 if errors.Is(err, manifest.ErrOtherEngine) {
292 continue
293 }
294 if err != nil {
295 // an invalid workflow doesn't stop the others; it is logged and
296 // listed as failed in the pipeline so its author sees why
297 r.Logger.Warn("invalid workflow", "repo", repo, "file", f, "err", err)
298 invalid = append(invalid, invalidWorkflow{manifest.NameOf(f), raw, err})
299 continue
300 }
301 if len(t.Workflows) > 0 && !slices.Contains(t.Workflows, m.Name) {
302 continue
303 }
304 if t.Kind == "manual" || m.Matches(manifest.Event{Kind: t.Kind, Ref: t.Ref, ChangedFiles: t.ChangedFiles}) {
305 matched = append(matched, m)
306 }
307 }
308 if len(matched) == 0 && len(invalid) == 0 {
309 r.Logger.Info("no workflow matched", "repo", repo, "ref", t.Ref, "sha", t.Sha)
310 return "", nil
311 }
312
313 rkey := tid.New()
314 uri := r.pipelineURI(rkey)
315 repoName := repo
316 tm := &lex.PipelineTriggerMetadata{
317 Kind: t.Kind,
318 Repo: &lex.PipelineTriggerRepo{Knot: r.Cfg.Knot, Did: r.Cfg.OwnerDid, RepoDid: &t.RepoDid, Repo: &repoName, DefaultBranch: defaultBranch},
319 }
320 switch t.Kind {
321 case "push":
322 tm.Push = &lex.PipelinePushTriggerData{Ref: t.Ref, NewSha: t.Sha, OldSha: t.OldSha}
323 default:
324 ref := t.Ref
325 tm.Manual = &lex.PipelineManualTriggerData{Sha: t.Sha, Inputs: t.Inputs}
326 if ref != "" {
327 tm.Manual.Ref = &ref
328 }
329 }
330 p := lex.Pipeline{LexiconTypeID: lex.PipelineNSID, TriggerMetadata: tm}
331 for _, m := range matched {
332 p.Workflows = append(p.Workflows, &lex.PipelineWorkflow{
333 Name: m.Name, Engine: lex.EngineDevenv, Clone: &lex.PipelineCloneOpts{Depth: 0}, Raw: string(m.Raw),
334 })
335 }
336 for _, w := range invalid {
337 p.Workflows = append(p.Workflows, &lex.PipelineWorkflow{
338 Name: w.name, Engine: lex.EngineDevenv, Clone: &lex.PipelineCloneOpts{}, Raw: w.raw,
339 })
340 }
341 if _, err := r.Log.Append(lex.PipelineNSID, rkey, p); err != nil {
342 return "", err
343 }
344 for _, w := range invalid {
345 r.status(uri, w.name, lex.StatusFailed, "invalid workflow: "+w.err.Error())
346 }
347 r.Logger.Info("pipeline", "uri", uri, "repo", repo, "sha", t.Sha, "workflows", len(matched))
348 for _, m := range matched {
349 meta := engine.Meta{
350 Run: rkey + "-" + m.Name, Workflow: m.Name, Repo: repo, RepoDid: t.RepoDid, Knot: r.Cfg.Knot,
351 Sha: t.Sha, Ref: t.Ref, DefaultBranch: defaultBranch, Kind: t.Kind, Pipeline: uri, Host: "runner",
352 }
353 if err := r.prepare(ctx, m, meta, mirror, rkey); err != nil {
354 r.Logger.Error("prepare run", "run", meta.Run, "err", err)
355 r.status(uri, m.Name, lex.StatusFailed, "could not prepare the run: "+err.Error())
356 }
357 }
358 return uri, nil
359}
360
361// prepare lays out a run directory and creates the run, then starts it.
362func (r *Runner) prepare(ctx context.Context, m *manifest.Manifest, meta engine.Meta, mirror, rkey string) error {
363 dir := engine.Dir(filepath.Join(r.Cfg.RunsDir, meta.Run))
364 meta.Root = filepath.Join(dir.Path(), meta.Repo)
365 if err := dir.Create(); err != nil {
366 return err
367 }
368 origin, _ := r.src.URL(meta.Repo)
369 checked, err := checkout.Layout(ctx, r.src, dir.Path(), checkout.Main{Repo: meta.Repo, From: mirror, Sha: meta.Sha, Origin: origin}, m.Repos, nil)
370 if err != nil {
371 return err
372 }
373 for _, c := range checked {
374 meta.Checkouts = append(meta.Checkouts, engine.Checkout{Repo: c.Repo, Path: c.Path, Rev: c.Rev, Env: c.Env})
375 }
376 e, err := engine.Create(m, dir, meta, nil, engine.Options{})
377 if err != nil {
378 return err
379 }
380 e.Log.Close()
381 t := now()
382 if err := r.insertRun(Run{ID: meta.Run, Pipeline: meta.Pipeline, Rkey: rkey, Workflow: m.Name, Repo: meta.Repo,
383 Dir: dir.Path(), Status: lex.StatusPending, Created: t, Updated: t}); err != nil {
384 return err
385 }
386 r.status(meta.Pipeline, m.Name, lex.StatusPending, "")
387 return r.launch(ctx, meta.Run, Segment{})
388}
389
390// status appends a sh.tangled.pipeline.status record.
391func (r *Runner) status(pipeline, workflow, status, errMsg string) {
392 s := lex.PipelineStatus{LexiconTypeID: lex.PipelineStatusNSID, Pipeline: pipeline, Workflow: workflow, Status: status, CreatedAt: now()}
393 if errMsg != "" {
394 s.Error = &errMsg
395 }
396 if _, err := r.Log.Append(lex.PipelineStatusNSID, "", s); err != nil {
397 r.Logger.Error("append status", "err", err)
398 }
399}
400
401type invalidWorkflow struct {
402 name, raw string
403 err error
404}
405
406func workflowFiles(ctx context.Context, gitDir, sha string) ([]string, error) {
407 out, err := gitOut(ctx, gitDir, "ls-tree", "--name-only", sha+":"+manifest.Dir)
408 if err != nil {
409 return nil, nil // no .tangled/workflows at that commit
410 }
411 var files []string
412 for _, f := range strings.Fields(out) {
413 if ext := path.Ext(f); ext == ".yml" || ext == ".yaml" {
414 files = append(files, manifest.Dir+"/"+f)
415 }
416 }
417 slices.Sort(files)
418 return files, nil
419}