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}