router_test.go
1package engine
2
3import (
4 "encoding/json"
5 "strings"
6 "testing"
7
8 "git.kilimanjaro.io/ci/internal/lex"
9 "git.kilimanjaro.io/ci/internal/manifest"
10)
11
12const routerWF = `
13engine: devenv
14steps: [test, plan, deploy, health-check, record]
15actions:
16 test: { command: x }
17 plan: { command: x }
18 deploy: { command: x, gate: { prompt: "Deploy?" } }
19 health-check: { command: x }
20 record: { command: x }
21 artifacts: { command: x }
22 rollback: { command: x, gate: { prompt: "Roll back?", timeout: 1h } }
23 notify: { command: x }
24`
25
26func newRouter(t *testing.T) *Router {
27 t.Helper()
28 m, err := manifest.Parse("w", []byte(routerWF))
29 if err != nil {
30 t.Fatal(err)
31 }
32 return &Router{M: m}
33}
34
35// ran simulates the engine executing p: it moves the cursor.
36func ran(r *Router, st *State, p Pending) {
37 if i, ok := r.M.IsStep(p.Action); ok {
38 st.Cursor = i + 1
39 }
40}
41
42func res(status string, next string) lex.Result {
43 r := lex.Result{Version: 1, Status: status}
44 if next != "" {
45 var n lex.Next
46 if err := json.Unmarshal([]byte(next), &n); err != nil {
47 panic(err)
48 }
49 r.Next = &n
50 }
51 return r
52}
53
54func mustRun(t *testing.T, d Decision, action, then string) Pending {
55 t.Helper()
56 if d.Run == nil || d.Run.Action != action || d.Run.Then != then {
57 t.Fatalf("want run %s (then %s), got %s", action, then, d)
58 }
59 return *d.Run
60}
61
62func mustGate(t *testing.T, d Decision, actions ...string) *PendingGate {
63 t.Helper()
64 if d.Gate == nil {
65 t.Fatalf("want gate %v, got %s", actions, d)
66 }
67 var got []string
68 for _, o := range d.Gate.Options {
69 got = append(got, o.Action)
70 }
71 if strings.Join(got, ",") != strings.Join(actions, ",") {
72 t.Fatalf("gate options %v, want %v", got, actions)
73 }
74 return d.Gate
75}
76
77func mustEnd(t *testing.T, d Decision, status string) *End {
78 t.Helper()
79 if d.End == nil || d.End.Status != status {
80 t.Fatalf("want end %s, got %s", status, d)
81 }
82 return d.End
83}
84
85func TestHappyPathWithDeclaredGate(t *testing.T) {
86 r := newRouter(t)
87 st := &State{}
88 p := mustRun(t, r.Start(), "test", "continue")
89 ran(r, st, p)
90 p = mustRun(t, r.After(st, p, res("success", ""), "r1"), "plan", "continue")
91 ran(r, st, p)
92 g := mustGate(t, r.After(st, p, res("warning", ""), "r2"), "deploy")
93 if g.From != "" || g.Prompt != "Deploy?" {
94 t.Errorf("declared gate: %+v", g)
95 }
96 d, err := r.Choose(g, 0)
97 if err != nil {
98 t.Fatal(err)
99 }
100 p = mustRun(t, d, "deploy", "continue")
101 if p.Reason != lex.ReasonChoice {
102 t.Errorf("reason %s", p.Reason)
103 }
104 ran(r, st, p)
105 p = mustRun(t, r.After(st, p, res("success", ""), "r3"), "health-check", "continue")
106 ran(r, st, p)
107 p = mustRun(t, r.After(st, p, res("success", ""), "r4"), "record", "continue")
108 ran(r, st, p)
109 e := mustEnd(t, r.After(st, p, res("success", ""), "r5"), StatusSuccess)
110 if e.Reason != lex.ReasonDefault {
111 t.Errorf("reason %s", e.Reason)
112 }
113}
114
115func TestFailureStops(t *testing.T) {
116 r := newRouter(t)
117 st := &State{}
118 p := mustRun(t, r.Start(), "test", "continue")
119 ran(r, st, p)
120 mustEnd(t, r.After(st, p, res("failure", ""), "r1"), StatusFailure)
121}
122
123func TestInsertedStepResumesHappyPath(t *testing.T) {
124 r := newRouter(t)
125 st := &State{}
126 p := mustRun(t, r.Start(), "test", "continue")
127 ran(r, st, p)
128 p = mustRun(t, r.After(st, p, res("success", `{"action":"artifacts","then":"continue","inputs":{"k":1}}`), "results/001-test.json"), "artifacts", "continue")
129 if p.TriggerResult != "results/001-test.json" || string(p.Inputs) != `{"k":1}` || p.From != "test" {
130 t.Errorf("pending %+v", p)
131 }
132 ran(r, st, p)
133 mustRun(t, r.After(st, p, res("success", ""), "r2"), "plan", "continue")
134}
135
136func TestOffPathDefaultsToStop(t *testing.T) {
137 r := newRouter(t)
138 st := &State{}
139 p := mustRun(t, r.Start(), "test", "continue")
140 ran(r, st, p)
141 p = mustRun(t, r.After(st, p, res("success", `"notify"`), "r1"), "notify", "stop")
142 ran(r, st, p)
143 e := mustEnd(t, r.After(st, p, res("success", ""), "r2"), StatusFailure)
144 if !strings.Contains(e.Detail, "before plan") {
145 t.Errorf("detail %q", e.Detail)
146 }
147}
148
149func TestJumpForwardSkipsAndBackRepeats(t *testing.T) {
150 r := newRouter(t)
151 st := &State{}
152 p := mustRun(t, r.Start(), "test", "continue")
153 ran(r, st, p)
154 // jump forward to record: a happy-path step, so then defaults to continue
155 p = mustRun(t, r.After(st, p, res("success", `"record"`), "r1"), "record", "continue")
156 ran(r, st, p)
157 mustEnd(t, r.After(st, p, res("success", ""), "r2"), StatusSuccess)
158
159 // jump back from health-check to plan repeats plan and what follows
160 st = &State{Cursor: 4}
161 p = Pending{Action: "health-check", Then: "continue"}
162 p = mustRun(t, r.After(st, p, res("success", `"plan"`), "r3"), "plan", "continue")
163 ran(r, st, p)
164 mustGate(t, r.After(st, p, res("success", ""), "r4"), "deploy")
165}
166
167func TestResultGates(t *testing.T) {
168 r := newRouter(t)
169 st := &State{Cursor: 4}
170 hc := Pending{Action: "health-check", Then: "continue"}
171
172 // a declared gate on the routed-to action gates even without gated:true
173 g := mustGate(t, r.After(st, hc, res("failure", `{"action":"rollback","then":"stop"}`), "r"), "rollback")
174 if g.From != "health-check" || g.FromStatus != "failure" || g.Prompt != "Roll back?" {
175 t.Errorf("gate %+v", g)
176 }
177
178 // gated:true on an ungated action
179 rr := res("warning", `"notify"`)
180 rr.Gated = true
181 mustGate(t, r.After(st, hc, rr, "r"), "notify")
182
183 // more than one option is always a choice
184 g = mustGate(t, r.After(st, hc, res("failure", `[{"action":"rollback"},{"action":"health-check","label":"Check again"}]`), "r"), "rollback", "health-check")
185 if g.Options[0].Then != "stop" || g.Options[1].Then != "continue" {
186 t.Errorf("default thens: %+v", g.Options)
187 }
188
189 // choosing runs the option without gating again
190 d, _ := r.Choose(g, 1)
191 mustRun(t, d, "health-check", "continue")
192 if _, err := r.Choose(g, 5); err == nil {
193 t.Error("choose out of range")
194 }
195}
196
197func TestGateTimeout(t *testing.T) {
198 r := newRouter(t)
199 g := mustGate(t, r.After(&State{}, Pending{Action: "health-check"}, res("failure", `"rollback"`), "r"), "rollback")
200 if g.ExpiresAt == "" || g.CreatedAt == "" || g.ExpiresAt <= g.CreatedAt {
201 t.Errorf("times %+v", g)
202 }
203}
204
205func TestDismiss(t *testing.T) {
206 r := newRouter(t)
207 declared := mustGate(t, r.reach(Pending{Action: "deploy", Then: "continue"}), "deploy")
208 mustEnd(t, r.Dismiss(declared), StatusCancelled)
209
210 raised := mustGate(t, r.After(&State{}, Pending{Action: "health-check"}, res("failure", `"rollback"`), "r"), "rollback")
211 mustEnd(t, r.Dismiss(raised), StatusFailure)
212
213 warn := mustGate(t, r.After(&State{}, Pending{Action: "health-check"}, res("warning", `[{"action":"rollback"},{"action":"notify"}]`), "r"), "rollback", "notify")
214 mustEnd(t, r.Dismiss(warn), StatusSuccess)
215
216 mustEnd(t, r.Expire(declared), StatusCancelled)
217}
218
219func TestRollbackAfterLastStepIsFailure(t *testing.T) {
220 // health-check is the last step here; its failure routes to rollback
221 m := newRouter(t).M
222 m.Steps = []string{"test", "health-check"}
223 r := &Router{M: m}
224 st := &State{Cursor: 2}
225 p := mustRun(t, r.After(st, Pending{Action: "health-check", Then: "continue"}, res("failure", `"notify"`), "r"), "notify", "stop")
226 e := mustEnd(t, r.After(st, p, res("success", ""), "r2"), StatusFailure)
227 if !strings.Contains(e.Detail, "diverted") {
228 t.Errorf("detail %q", e.Detail)
229 }
230 // a happy-path step that stops at the end is a success
231 mustEnd(t, r.After(st, Pending{Action: "health-check", Then: "stop"}, res("success", ""), "r3"), StatusSuccess)
232}
233
234func TestExplicitStop(t *testing.T) {
235 r := newRouter(t)
236 mustEnd(t, r.After(&State{}, Pending{Action: "test"}, res("success", `"stop"`), "r"), StatusSuccess)
237 mustEnd(t, r.After(&State{}, Pending{Action: "test"}, res("warning", `"stop"`), "r"), StatusSuccess)
238 mustEnd(t, r.After(&State{}, Pending{Action: "test"}, res("failure", `"stop"`), "r"), StatusFailure)
239}
240
241func TestFailureWithNextRoutes(t *testing.T) {
242 // a failing result that names a next action is routed, not stopped
243 r := newRouter(t)
244 mustRun(t, r.After(&State{}, Pending{Action: "test"}, res("failure", `"notify"`), "r"), "notify", "stop")
245}
246
247func TestBeginAppliesDeclaredGate(t *testing.T) {
248 r := newRouter(t)
249 mustGate(t, r.Begin("rollback"), "rollback")
250 mustRun(t, r.Begin("notify"), "notify", "stop")
251 mustRun(t, r.Begin("plan"), "plan", "continue")
252}