router.go
1package engine
2
3import (
4 "encoding/json"
5 "fmt"
6 "strings"
7 "time"
8
9 "git.kilimanjaro.io/ci/internal/lex"
10 "git.kilimanjaro.io/ci/internal/manifest"
11)
12
13// Pending is an action about to run, and what happens after it succeeds.
14type Pending struct {
15 Action string `json:"action"`
16 Then string `json:"then"`
17 Reason string `json:"reason"`
18 Inputs json.RawMessage `json:"inputs,omitempty"`
19 // TriggerResult is the result file (relative to the run dir) of the
20 // action whose result routed here, if any.
21 TriggerResult string `json:"triggerResult,omitempty"`
22 // From is the action that just finished, for the transition record.
23 From string `json:"from,omitempty"`
24}
25
26// PendingGate is a decision waiting for a human.
27type PendingGate struct {
28 Options []lex.Option `json:"options"`
29 Prompt string `json:"prompt,omitempty"`
30 Summary string `json:"summary,omitempty"`
31 // From is the action whose result raised the gate. Empty for a declared
32 // gate reached by default routing: dismissing that cancels the run.
33 From string `json:"from,omitempty"`
34 FromStatus string `json:"fromStatus,omitempty"`
35 TriggerResult string `json:"triggerResult,omitempty"`
36 CreatedAt string `json:"createdAt"`
37 ExpiresAt string `json:"expiresAt"`
38}
39
40// End is how a run finished.
41type End struct {
42 Status string `json:"status"`
43 Reason string `json:"reason"`
44 Detail string `json:"detail,omitempty"`
45}
46
47// Decision is what the router says happens next: exactly one field is set.
48type Decision struct {
49 Run *Pending
50 Gate *PendingGate
51 End *End
52}
53
54func (d Decision) String() string {
55 switch {
56 case d.Run != nil:
57 return fmt.Sprintf("run %s (then %s, %s)", d.Run.Action, d.Run.Then, d.Run.Reason)
58 case d.Gate != nil:
59 var opts []string
60 for _, o := range d.Gate.Options {
61 opts = append(opts, o.Action)
62 }
63 return fmt.Sprintf("gate: %s", strings.Join(opts, " | "))
64 case d.End != nil:
65 s := fmt.Sprintf("end %s (%s)", d.End.Status, d.End.Reason)
66 if d.End.Detail != "" {
67 s += ": " + d.End.Detail
68 }
69 return s
70 }
71 return "nothing"
72}
73
74// Router implements the routing rules of DESIGN.md ยง8 for one manifest.
75type Router struct {
76 M *manifest.Manifest
77 Now func() time.Time
78 // Detached is set for a manual action run against an earlier run: it
79 // runs the action (and whatever its result routes to) and ends with
80 // that action's status instead of resuming the happy path.
81 Detached bool
82}
83
84func (r *Router) now() time.Time {
85 if r.Now != nil {
86 return r.Now()
87 }
88 return time.Now()
89}
90
91// defaultThen is what happens after action succeeds when nobody said:
92// happy-path steps continue, anything else stops.
93func (r *Router) defaultThen(action string) string {
94 if _, ok := r.M.IsStep(action); ok {
95 return lex.ThenContinue
96 }
97 return lex.ThenStop
98}
99
100// Start is the first decision of a run: the first step.
101func (r *Router) Start() Decision {
102 return r.reach(Pending{Action: r.M.Steps[0], Then: lex.ThenContinue, Reason: lex.ReasonStart})
103}
104
105// Begin starts a run at an arbitrary action (ci action), as if a person had
106// chosen it: a declared gate still applies.
107func (r *Router) Begin(action string) Decision {
108 then := r.defaultThen(action)
109 if r.Detached {
110 then = lex.ThenStop
111 }
112 return r.reach(Pending{Action: action, Then: then, Reason: lex.ReasonManual})
113}
114
115// reach routes to p without a result asking for it, applying the action's
116// declared gate.
117func (r *Router) reach(p Pending) Decision {
118 a := r.M.Actions[p.Action]
119 if a.Gate == nil {
120 return Decision{Run: &p}
121 }
122 prompt := a.Gate.Prompt
123 if prompt == "" {
124 prompt = fmt.Sprintf("Run %s?", r.M.Label(p.Action))
125 }
126 return Decision{Gate: r.gate(PendingGate{
127 Options: []lex.Option{{Action: p.Action, Then: p.Then}},
128 Prompt: prompt,
129 })}
130}
131
132// gate stamps g with its creation and expiry times. The timeout is the
133// longest declared gate timeout among the options, or the default.
134func (r *Router) gate(g PendingGate) *PendingGate {
135 var timeout time.Duration
136 for _, o := range g.Options {
137 if r.M.Actions[o.Action].Gate != nil {
138 timeout = max(timeout, r.M.GateTimeout(o.Action))
139 }
140 }
141 if timeout == 0 {
142 timeout = manifest.DefaultGateTimeout
143 }
144 t := r.now().UTC()
145 g.CreatedAt = t.Format(time.RFC3339Nano)
146 g.ExpiresAt = t.Add(timeout).Format(time.RFC3339Nano)
147 return &g
148}
149
150// After decides what follows finished, given its result. st.Cursor must
151// already reflect finished having run.
152func (r *Router) After(st *State, finished Pending, res lex.Result, resultFile string) Decision {
153 if res.Next != nil {
154 if res.Next.Stop {
155 if res.Status == lex.Failure {
156 return Decision{End: &End{StatusFailure, lex.ReasonStop, finished.Action + " failed and stopped the run"}}
157 }
158 return Decision{End: &End{StatusSuccess, lex.ReasonStop, finished.Action + " ended the run"}}
159 }
160 opts := make([]lex.Option, len(res.Next.Options))
161 for i, o := range res.Next.Options {
162 if o.Then == "" {
163 o.Then = r.defaultThen(o.Action)
164 }
165 opts[i] = o
166 }
167 if r.needsGate(opts, res.Gated) {
168 prompt, _, _ := strings.Cut(strings.TrimSpace(res.Summary), "\n")
169 if len(opts) == 1 {
170 switch g := r.M.Actions[opts[0].Action].Gate; {
171 case opts[0].Prompt != "":
172 prompt = opts[0].Prompt
173 case g != nil && g.Prompt != "":
174 prompt = g.Prompt
175 default:
176 prompt = fmt.Sprintf("Run %s?", r.M.Label(opts[0].Action))
177 }
178 }
179 return Decision{Gate: r.gate(PendingGate{
180 Options: opts,
181 Prompt: prompt,
182 Summary: res.Summary,
183 From: finished.Action,
184 FromStatus: res.Status,
185 TriggerResult: resultFile,
186 })}
187 }
188 o := opts[0]
189 return Decision{Run: &Pending{
190 Action: o.Action, Then: o.Then, Reason: lex.ReasonNext, Inputs: o.Inputs,
191 TriggerResult: resultFile, From: finished.Action,
192 }}
193 }
194
195 if res.Status == lex.Failure {
196 return Decision{End: &End{StatusFailure, lex.ReasonFailure, finished.Action + " failed"}}
197 }
198 return r.continueOrStop(st, finished)
199}
200
201// continueOrStop applies finished's `then` after it succeeded.
202func (r *Router) continueOrStop(st *State, finished Pending) Decision {
203 if finished.Then == lex.ThenStop {
204 if r.Detached {
205 return Decision{End: &End{StatusSuccess, lex.ReasonStop, finished.Action + " done"}}
206 }
207 if _, onPath := r.M.IsStep(finished.Action); !onPath {
208 // an off-path action ending the run means the happy path was
209 // diverted (a rollback), even if its last step had started
210 detail := "diverted: stopped after " + finished.Action
211 if st.Cursor < len(r.M.Steps) {
212 detail += ", before " + r.M.Steps[st.Cursor]
213 }
214 return Decision{End: &End{StatusFailure, lex.ReasonStop, detail}}
215 }
216 if st.Cursor >= len(r.M.Steps) {
217 return Decision{End: &End{StatusSuccess, lex.ReasonStop, "stopped after " + finished.Action + " at the end of the happy path"}}
218 }
219 return Decision{End: &End{StatusFailure, lex.ReasonStop,
220 fmt.Sprintf("stopped after %s, before %s", finished.Action, r.M.Steps[st.Cursor])}}
221 }
222 if st.Cursor >= len(r.M.Steps) {
223 return Decision{End: &End{StatusSuccess, lex.ReasonDefault, "completed the happy path"}}
224 }
225 return r.reach(Pending{
226 Action: r.M.Steps[st.Cursor], Then: lex.ThenContinue, Reason: lex.ReasonDefault, From: finished.Action,
227 })
228}
229
230// needsGate: a declared gate on any option, a result asking for one, or a
231// choice between options. Gates are monotonic: a result can add one, never
232// remove one.
233func (r *Router) needsGate(opts []lex.Option, gated bool) bool {
234 if gated || len(opts) > 1 {
235 return true
236 }
237 for _, o := range opts {
238 if r.M.Actions[o.Action].Gate != nil {
239 return true
240 }
241 }
242 return false
243}
244
245// Choose answers a gate with option i. The chosen action runs without
246// further gating: the person has just approved it.
247func (r *Router) Choose(g *PendingGate, i int) (Decision, error) {
248 if i < 0 || i >= len(g.Options) {
249 return Decision{}, fmt.Errorf("no option %d", i)
250 }
251 o := g.Options[i]
252 return Decision{Run: &Pending{
253 Action: o.Action, Then: o.Then, Reason: lex.ReasonChoice, Inputs: o.Inputs,
254 TriggerResult: g.TriggerResult, From: g.From,
255 }}, nil
256}
257
258// Dismiss ends the run at a gate: a declared gate reached by default
259// routing cancels the run; a gate raised by a result ends the run with that
260// result's status.
261func (r *Router) Dismiss(g *PendingGate) Decision {
262 if g.From == "" {
263 return Decision{End: &End{StatusCancelled, lex.ReasonDismiss, "dismissed " + optionNames(g)}}
264 }
265 status := StatusSuccess
266 if g.FromStatus == lex.Failure {
267 status = StatusFailure
268 }
269 return Decision{End: &End{status, lex.ReasonDismiss, "dismissed " + optionNames(g) + " after " + g.From}}
270}
271
272// Expire ends the run when a gate times out.
273func (r *Router) Expire(g *PendingGate) Decision {
274 return Decision{End: &End{StatusCancelled, lex.ReasonExpired, "gate expired: " + optionNames(g)}}
275}
276
277func optionNames(g *PendingGate) string {
278 var n []string
279 for _, o := range g.Options {
280 n = append(n, o.Action)
281 }
282 return strings.Join(n, ", ")
283}