engine.go

  1package engine
  2
  3import (
  4	"context"
  5	"encoding/json"
  6	"errors"
  7	"fmt"
  8	"os"
  9	"path/filepath"
 10
 11	"git.kilimanjaro.io/ci/internal/lex"
 12	"git.kilimanjaro.io/ci/internal/manifest"
 13	"git.kilimanjaro.io/ci/internal/runlog"
 14)
 15
 16// DefaultMaxTransitions is the loop guard (DESIGN.md ยง8 rule 4).
 17const DefaultMaxTransitions = 50
 18
 19// Options change how a run executes.
 20type Options struct {
 21	// Stubs maps an action to a result file used instead of running it.
 22	Stubs map[string]string
 23	// Until stops the run before this action would run.
 24	Until string
 25	// NoFollow runs the first action only and reports what would follow.
 26	NoFollow bool
 27	// MaxTransitions overrides DefaultMaxTransitions.
 28	MaxTransitions int
 29	// Gates decides gates as they are reached; nil pauses at every gate.
 30	Gates Gatekeeper
 31	// Detach pauses at every gate even with a Gatekeeper (the runner's
 32	// mode; locally `ci run --detach`).
 33	Detach bool
 34	// LocksDir holds concurrency locks; default <runs>/.locks.
 35	LocksDir string
 36	// Locker overrides the file locks in LocksDir (the runner's, for local
 37	// runs that must not overlap CI deploys).
 38	Locker Locker
 39}
 40
 41// Engine runs one workflow run.
 42type Engine struct {
 43	M       *manifest.Manifest
 44	Dir     Dir
 45	Exec    Executor
 46	Log     *runlog.Writer
 47	BaseEnv []string
 48	Opts    Options
 49
 50	router *Router
 51	st     *State
 52	locks  map[string]func()
 53}
 54
 55// Create starts a new run in dir: it writes the manifest copy, state.json and
 56// the first event. Close the engine's Log when done.
 57func Create(m *manifest.Manifest, dir Dir, meta Meta, ex Executor, opts Options) (*Engine, error) {
 58	if err := dir.Create(); err != nil {
 59		return nil, err
 60	}
 61	if _, err := os.Stat(dir.StateFile()); err == nil {
 62		return nil, fmt.Errorf("run %s already exists", dir)
 63	}
 64	if err := os.WriteFile(dir.ManifestFile(), m.Raw, 0o644); err != nil {
 65		return nil, err
 66	}
 67	log, err := runlog.Open(dir.Events(), meta.Run)
 68	if err != nil {
 69		return nil, err
 70	}
 71	t := now()
 72	e := &Engine{
 73		M: m, Dir: dir, Exec: ex, Log: log, BaseEnv: osEnviron(), Opts: opts,
 74		router: &Router{M: m, Detached: meta.Parent != ""},
 75		st:     &State{Version: StateVersion, Meta: meta, Status: StatusRunning, Created: t},
 76	}
 77	for _, c := range meta.Checkouts {
 78		e.emit(lex.RunEvent{Kind: lex.KindCheckout, Repo: c.Repo, Rev: c.Rev, Path: c.Path})
 79	}
 80	detail := fmt.Sprintf("%s %s", m.Name, meta.Repo)
 81	if meta.Sha != "" {
 82		detail += "@" + short(meta.Sha)
 83	}
 84	if meta.Dirty {
 85		detail += " (working copy with changes)"
 86	}
 87	e.emit(lex.RunEvent{Kind: lex.KindRun, Status: StatusRunning, Content: detail})
 88	if err := e.st.Save(dir); err != nil {
 89		return nil, err
 90	}
 91	return e, nil
 92}
 93
 94func short(sha string) string {
 95	if len(sha) > 12 {
 96		return sha[:12]
 97	}
 98	return sha
 99}
100
101// State returns the run's current state.
102func (e *Engine) State() *State { return e.st }
103
104// Run runs the workflow from its first step.
105func (e *Engine) Run(ctx context.Context) (*State, error) {
106	return e.drive(ctx, e.router.Start())
107}
108
109// RunFrom runs the workflow starting at action, as if a person had chosen
110// it; routing then follows that action's result.
111func (e *Engine) RunFrom(ctx context.Context, action string) (*State, error) {
112	if _, ok := e.M.Actions[action]; !ok {
113		return nil, fmt.Errorf("no action %q in %s", action, e.M.Name)
114	}
115	return e.drive(ctx, e.router.Begin(action))
116}
117
118func (e *Engine) maxTransitions() int {
119	if e.Opts.MaxTransitions > 0 {
120		return e.Opts.MaxTransitions
121	}
122	return DefaultMaxTransitions
123}
124
125// drive follows decisions until the run ends.
126func (e *Engine) drive(ctx context.Context, d Decision) (*State, error) {
127	for {
128		if ctx.Err() != nil {
129			return e.finish(End{StatusCancelled, lex.ReasonCancel, "interrupted"}, "")
130		}
131		switch {
132		case d.End != nil:
133			return e.finish(*d.End, e.lastAction())
134		case d.Gate != nil:
135			next, paused, err := e.reachGate(ctx, d.Gate)
136			if err != nil {
137				return e.finish(End{StatusFailure, lex.ReasonError, err.Error()}, e.lastAction())
138			}
139			if paused {
140				return e.st, nil
141			}
142			d = next
143		case d.Run != nil:
144			p := *d.Run
145			if e.st.Seq >= e.maxTransitions() {
146				return e.finish(End{StatusFailure, lex.ReasonLimit,
147					fmt.Sprintf("more than %d transitions; is a result routing in a loop?", e.maxTransitions())}, p.From)
148			}
149			if p.Action == e.Opts.Until {
150				return e.finish(End{StatusCancelled, lex.ReasonUntil, "stopped before " + p.Action + " (--until)"}, p.From)
151			}
152			e.transition(lex.Transition{From: p.From, To: p.Action, Then: p.Then, Reason: p.Reason})
153			res, file, err := e.execute(ctx, p)
154			if err != nil {
155				if ctx.Err() != nil {
156					return e.finish(End{StatusCancelled, lex.ReasonCancel, "interrupted during " + p.Action}, p.Action)
157				}
158				return e.finish(End{StatusFailure, lex.ReasonError, err.Error()}, p.Action)
159			}
160			next := e.router.After(e.st, p, res, file)
161			if e.Opts.NoFollow {
162				status := StatusSuccess
163				if res.Status == lex.Failure {
164					status = StatusFailure
165				}
166				return e.finish(End{status, lex.ReasonStop, "not followed (--no-follow): would " + next.String()}, p.Action)
167			}
168			d = next
169		default:
170			return e.finish(End{StatusFailure, lex.ReasonError, "router returned no decision"}, e.lastAction())
171		}
172	}
173}
174
175func (e *Engine) lastAction() string {
176	if x := e.st.LastExec(); x != nil {
177		return x.Action
178	}
179	return ""
180}
181
182func (e *Engine) transition(t lex.Transition) {
183	e.st.Seq++
184	t.Pipeline, t.Run, t.Workflow, t.Seq, t.CreatedAt = e.st.Meta.Pipeline, e.st.Meta.Run, e.st.Meta.Workflow, e.st.Seq, now()
185	e.emit(lex.RunEvent{Kind: lex.KindTransition, Transition: &t})
186}
187
188func (e *Engine) emit(ev lex.RunEvent) {
189	if err := e.Log.Emit(ev); err != nil {
190		fmt.Fprintf(os.Stderr, "ci: writing %s: %v\n", e.Dir.Events(), err)
191	}
192}
193
194// finish ends the run.
195func (e *Engine) finish(end End, from string) (*State, error) {
196	e.releaseLocks()
197	e.transition(lex.Transition{From: from, Reason: end.Reason, Detail: end.Detail})
198	e.st.End = &end
199	e.st.Status = end.Status
200	e.st.Gate = nil
201	e.emit(lex.RunEvent{Kind: lex.KindRun, Status: end.Status, Content: end.Detail})
202	return e.st, e.st.Save(e.Dir)
203}
204
205// execute runs one action and returns its (validated) result and the
206// result file's path relative to the run dir. An error means the run can't
207// go on (cancellation, an engine fault); a failing action is a result.
208func (e *Engine) execute(ctx context.Context, p Pending) (lex.Result, string, error) {
209	e.st.Execs++
210	n := e.st.Execs
211	if i, ok := e.M.IsStep(p.Action); ok {
212		e.st.Cursor = i + 1
213	}
214	a := e.M.Actions[p.Action]
215	root := e.st.Meta.Root
216	dir := filepath.Clean(filepath.Join(root, a.Dir))
217	base := fmt.Sprintf("%03d-%s", n, p.Action)
218	resultRel := filepath.Join("results", base+".json")
219	resultFile := filepath.Join(e.Dir.Path(), resultRel)
220	inputsFile := filepath.Join(e.Dir.Results(), base+".inputs.json")
221	inputs := p.Inputs
222	if len(inputs) == 0 {
223		inputs = json.RawMessage(`{}`)
224	}
225	if err := os.WriteFile(inputsFile, append(inputs, '\n'), 0o644); err != nil {
226		return lex.Result{}, "", err
227	}
228	triggerFile := ""
229	if p.TriggerResult != "" {
230		triggerFile = filepath.Join(e.Dir.Path(), p.TriggerResult)
231	}
232	_ = os.Remove(resultFile)
233
234	x := Exec{N: n, Action: p.Action, Then: p.Then, Reason: p.Reason, Result: resultRel, Started: now()}
235	e.st.History = append(e.st.History, x)
236	if err := e.st.Save(e.Dir); err != nil {
237		return lex.Result{}, "", err
238	}
239	if a.Concurrency != nil {
240		if err := e.acquire(ctx, a.Concurrency.Group); err != nil {
241			return lex.Result{}, "", errors.Join(err, e.recordExec(x, StatusCancelled, -1))
242		}
243	}
244	e.emit(lex.RunEvent{Kind: lex.KindStart, Action: p.Action, Attempt: n, Command: a.Command})
245
246	code, runErr := 0, error(nil)
247	if stub, ok := e.Opts.Stubs[p.Action]; ok {
248		x.Stubbed = true
249		code, runErr = e.stub(stub, resultFile, p.Action, n)
250	} else {
251		stdout := e.Log.Lines(p.Action, n, "stdout")
252		stderr := e.Log.Lines(p.Action, n, "stderr")
253		code, runErr = e.Exec.Exec(ctx, Spec{
254			Root: root, Dir: dir, Command: a.Command,
255			Env:    e.actionEnv(p, n, resultFile, inputsFile, triggerFile),
256			Stdout: stdout, Stderr: stderr,
257		})
258		stdout.Close()
259		stderr.Close()
260	}
261	if runErr != nil && ctx.Err() != nil {
262		if err := e.recordExec(x, StatusCancelled, -1); err != nil {
263			return lex.Result{}, "", errors.Join(runErr, err)
264		}
265		return lex.Result{}, "", runErr
266	}
267
268	var res lex.Result
269	if runErr != nil {
270		res = lex.Result{Version: lex.ResultVersion, Status: lex.Failure, Summary: "could not run: " + runErr.Error()}
271		code = -1
272	} else {
273		var err error
274		res, err = ReadResult(e.M, resultFile, code, e.Dir.Artifacts())
275		if err != nil {
276			res = ContractFailure(err)
277		}
278	}
279	if err := mergeOutputs(filepath.Join(e.stateDir(), "outputs.json"), res.Outputs); err != nil {
280		res = ContractFailure(fmt.Errorf("outputs: %w", err))
281	}
282	exit := code
283	e.emit(lex.RunEvent{Kind: lex.KindEnd, Action: p.Action, Attempt: n, ExitCode: &exit, Status: res.Status})
284	e.emit(lex.RunEvent{Kind: lex.KindResult, Action: p.Action, Attempt: n, Result: &res})
285	if err := e.recordExec(x, res.Status, code); err != nil {
286		return lex.Result{}, "", err
287	}
288	return res, resultRel, nil
289}
290
291func (e *Engine) recordExec(x Exec, status string, code int) error {
292	x.Status, x.ExitCode, x.Finished = status, code, now()
293	e.st.History[len(e.st.History)-1] = x
294	return e.st.Save(e.Dir)
295}
296
297// stub stands in for an action: the fixture becomes its result, and its
298// exit code follows the fixture's status.
299func (e *Engine) stub(fixture, resultFile, action string, n int) (int, error) {
300	b, err := os.ReadFile(fixture)
301	if err != nil {
302		return -1, fmt.Errorf("stub for %s: %w", action, err)
303	}
304	if err := os.WriteFile(resultFile, b, 0o644); err != nil {
305		return -1, err
306	}
307	e.emit(lex.RunEvent{Kind: lex.KindLog, Action: action, Attempt: n, Stream: "stderr",
308		Content: "ci: stubbed with " + fixture})
309	var probe struct {
310		Status string `json:"status"`
311	}
312	if err := json.Unmarshal(b, &probe); err == nil && probe.Status == lex.Failure {
313		return 1, nil
314	}
315	return 0, nil
316}
317
318// ErrNoRun is returned when a run directory has no state.
319var ErrNoRun = errors.New("no such run")