gate.go
1package engine
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "os"
8 "time"
9
10 "git.kilimanjaro.io/ci/internal/lex"
11 "git.kilimanjaro.io/ci/internal/manifest"
12 "git.kilimanjaro.io/ci/internal/runlog"
13)
14
15// ErrPause tells the engine to leave a gate for later: the run is saved as
16// paused and the process can exit.
17var ErrPause = errors.New("paused at gate")
18
19// Choice answers a gate.
20type Choice struct {
21 Option int // index into the gate's options, when not Dismiss/Expire
22 Dismiss bool // end the run here
23 Expire bool // the gate timed out
24 Actor string
25}
26
27// Gatekeeper decides gates as they are reached. Returning ErrPause (or
28// having no Gatekeeper) pauses the run; it continues with Resume.
29type Gatekeeper interface {
30 Decide(ctx context.Context, e *Engine, g *PendingGate) (Choice, error)
31}
32
33// GateFunc adapts a function to Gatekeeper.
34type GateFunc func(ctx context.Context, e *Engine, g *PendingGate) (Choice, error)
35
36func (f GateFunc) Decide(ctx context.Context, e *Engine, g *PendingGate) (Choice, error) {
37 return f(ctx, e, g)
38}
39
40// reachGate records g, supersedes older runs waiting at the same
41// concurrency group, and asks the Gatekeeper. It returns the next decision,
42// or paused=true when the run should stop here.
43func (e *Engine) reachGate(ctx context.Context, g *PendingGate) (Decision, bool, error) {
44 e.st.Gate = g
45 e.st.Status = StatusPaused
46 e.emit(lex.RunEvent{Kind: lex.KindGate, Gate: e.gateRecord(g, lex.GateWaiting, Choice{})})
47 if err := e.st.Save(e.Dir); err != nil {
48 return Decision{}, false, err
49 }
50 e.supersedeOlder(g)
51
52 var c Choice
53 err := ErrPause
54 if e.Opts.Gates != nil && !e.Opts.Detach {
55 c, err = e.Opts.Gates.Decide(ctx, e, g)
56 }
57 if errors.Is(err, ErrPause) {
58 e.releaseLocks()
59 e.emit(lex.RunEvent{Kind: lex.KindRun, Status: StatusPaused, Content: "waiting: " + gateTitle(g)})
60 return Decision{}, true, e.st.Save(e.Dir)
61 }
62 if err != nil {
63 return Decision{}, false, err
64 }
65 d, err := e.answer(g, c)
66 return d, false, err
67}
68
69// answer records a decision on g and returns what follows.
70func (e *Engine) answer(g *PendingGate, c Choice) (Decision, error) {
71 var d Decision
72 state := lex.GateApproved
73 switch {
74 case c.Expire:
75 state, d = lex.GateExpired, e.router.Expire(g)
76 case c.Dismiss:
77 state, d = lex.GateDismissed, e.router.Dismiss(g)
78 default:
79 var err error
80 if d, err = e.router.Choose(g, c.Option); err != nil {
81 return Decision{}, err
82 }
83 }
84 e.emit(lex.RunEvent{Kind: lex.KindGate, Gate: e.gateRecord(g, state, c)})
85 e.st.Gate = nil
86 e.st.Status = StatusRunning
87 return d, e.st.Save(e.Dir)
88}
89
90func (e *Engine) gateRecord(g *PendingGate, state string, c Choice) *lex.GateRecord {
91 r := &lex.GateRecord{
92 Pipeline: e.st.Meta.Pipeline, Run: e.st.Meta.Run, Workflow: e.st.Meta.Workflow, Seq: e.st.Seq,
93 From: g.From, Prompt: g.Prompt, Summary: g.Summary, Options: g.Options, State: state,
94 ExpiresAt: g.ExpiresAt, CreatedAt: g.CreatedAt, Actor: c.Actor,
95 }
96 if state != lex.GateWaiting {
97 r.DecidedAt = now()
98 }
99 if state == lex.GateApproved && c.Option < len(g.Options) {
100 r.Choice = g.Options[c.Option].Action
101 }
102 return r
103}
104
105func gateTitle(g *PendingGate) string {
106 if g.Prompt != "" {
107 return g.Prompt
108 }
109 return "choose: " + optionNames(g)
110}
111
112// Expired reports whether the gate's deadline has passed.
113func (g *PendingGate) Expired(at time.Time) bool {
114 t, err := time.Parse(time.RFC3339Nano, g.ExpiresAt)
115 return err == nil && at.After(t)
116}
117
118// Open reopens an existing run (to resume it).
119func Open(dir Dir, ex Executor, opts Options) (*Engine, error) {
120 st, err := Load(dir)
121 if err != nil {
122 return nil, err
123 }
124 raw, err := os.ReadFile(dir.ManifestFile())
125 if err != nil {
126 return nil, err
127 }
128 m, err := manifest.Parse(st.Meta.Workflow, raw)
129 if err != nil {
130 return nil, fmt.Errorf("%s: %w", dir.ManifestFile(), err)
131 }
132 log, err := runlog.Open(dir.Events(), st.Meta.Run)
133 if err != nil {
134 return nil, err
135 }
136 return &Engine{
137 M: m, Dir: dir, Exec: ex, Log: log, BaseEnv: osEnviron(), Opts: opts,
138 router: &Router{M: m, Detached: st.Meta.Parent != ""},
139 st: st,
140 }, nil
141}
142
143// Resume answers the gate a paused run is waiting at and continues it. A
144// gate past its deadline expires whatever the choice.
145func (e *Engine) Resume(ctx context.Context, c Choice) (*State, error) {
146 g := e.st.Gate
147 if e.st.Status != StatusPaused || g == nil {
148 return nil, fmt.Errorf("run %s is %s, not waiting at a gate", e.st.Meta.Run, e.st.Status)
149 }
150 if !c.Expire && g.Expired(time.Now()) {
151 c = Choice{Expire: true, Actor: c.Actor}
152 }
153 if c.Expire && !g.Expired(time.Now()) {
154 return nil, fmt.Errorf("the gate expires at %s, not yet", g.ExpiresAt)
155 }
156 if !c.Dismiss && !c.Expire && (c.Option < 0 || c.Option >= len(g.Options)) {
157 return nil, fmt.Errorf("no option %d", c.Option+1)
158 }
159 e.emit(lex.RunEvent{Kind: lex.KindRun, Status: StatusRunning, Content: "resumed"})
160 d, err := e.answer(g, c)
161 if err != nil {
162 return nil, err
163 }
164 return e.drive(ctx, d)
165}
166
167// MarkLost ends a run whose process died without finishing it (the runner
168// found its unit gone): it is recorded as a failure with detail.
169func MarkLost(dir Dir, detail string) error {
170 e, err := Open(dir, nil, Options{})
171 if err != nil {
172 return err
173 }
174 defer e.Log.Close()
175 if Terminal(e.st.Status) || e.st.Status == StatusPaused {
176 return nil
177 }
178 _, err = e.finish(End{StatusFailure, lex.ReasonError, detail}, e.lastAction())
179 return err
180}