gate_test.go

  1package engine
  2
  3import (
  4	"context"
  5	"os"
  6	"path/filepath"
  7	"slices"
  8	"strings"
  9	"syscall"
 10	"testing"
 11	"time"
 12
 13	"git.kilimanjaro.io/ci/internal/lex"
 14)
 15
 16// create starts a run of f's manifest in runs/<id> (sharing f.root).
 17func (f *fixture) create(runs, id string, meta Meta) *Engine {
 18	f.t.Helper()
 19	if meta.Run == "" {
 20		meta = Meta{Run: id, Workflow: "w", Repo: "repo", Kind: "manual", Host: "local", Root: f.root}
 21	}
 22	e, err := Create(f.m, Dir(filepath.Join(runs, id)), meta, Bash{}, f.opts)
 23	if err != nil {
 24		f.t.Fatal(err)
 25	}
 26	return e
 27}
 28
 29func reopen(t *testing.T, d Dir, opts Options) *Engine {
 30	t.Helper()
 31	e, err := Open(d, Bash{}, opts)
 32	if err != nil {
 33		t.Fatal(err)
 34	}
 35	return e
 36}
 37
 38func runE(t *testing.T, e *Engine) *State {
 39	t.Helper()
 40	st, err := e.Run(context.Background())
 41	if err != nil {
 42		t.Fatal(err)
 43	}
 44	return st
 45}
 46
 47func approve(i int) Gatekeeper {
 48	return GateFunc(func(context.Context, *Engine, *PendingGate) (Choice, error) {
 49		return Choice{Option: i, Actor: "test"}, nil
 50	})
 51}
 52
 53const gatedWF = `
 54engine: devenv
 55steps: [plan, deploy, health]
 56actions:
 57  plan:   { command: "true" }
 58  deploy: { command: "echo deployed > $CI_STATE/prev", gate: { prompt: "Deploy?" }, concurrency: { group: fleet, supersede: true } }
 59  health:
 60    command: |
 61      n=$(cat $CI_STATE/n 2>/dev/null || echo 0); echo $((n+1)) > $CI_STATE/n
 62      if [ "$n" = 0 ]; then
 63        printf '%s' '{"version":1,"status":"failure","summary":"503","next":[{"action":"rollback"},{"action":"health","label":"Check again"}]}' > $CI_RESULT
 64      fi
 65  rollback: { command: "cat $CI_STATE/prev", manual: true, style: danger, gate: { prompt: "Roll back?" } }
 66`
 67
 68func TestPauseAndResume(t *testing.T) {
 69	f := newFixture(t, gatedWF)
 70	runs := t.TempDir()
 71	e := f.create(runs, "a", Meta{})
 72	st, err := e.Run(context.Background())
 73	e.Log.Close()
 74	if err != nil {
 75		t.Fatal(err)
 76	}
 77	if st.Status != StatusPaused || st.Gate == nil || st.Gate.Prompt != "Deploy?" || !slices.Equal(executed(st), []string{"plan"}) {
 78		t.Fatalf("not paused at deploy: %s %v %+v", st.Status, executed(st), st.Gate)
 79	}
 80
 81	// a new process reopens the run and approves
 82	e = reopen(t, Dir(filepath.Join(runs, "a")), Options{})
 83	st, err = e.Resume(context.Background(), Choice{Option: 0, Actor: "local:me"})
 84	if err != nil {
 85		t.Fatal(err)
 86	}
 87	// health fails the first time and offers a choice: paused again
 88	if st.Status != StatusPaused || len(st.Gate.Options) != 2 || st.Gate.From != "health" {
 89		t.Fatalf("want choice gate after health: %s %+v", st.Status, st.Gate)
 90	}
 91	e.Log.Close()
 92
 93	// "Check again" reruns health, which passes now
 94	e = reopen(t, Dir(filepath.Join(runs, "a")), Options{})
 95	st, err = e.Resume(context.Background(), Choice{Option: 1})
 96	e.Log.Close()
 97	if err != nil {
 98		t.Fatal(err)
 99	}
100	if st.Status != StatusSuccess || !slices.Equal(executed(st), []string{"plan", "deploy", "health", "health"}) {
101		t.Fatalf("%s %v %+v", st.Status, executed(st), st.End)
102	}
103
104	// gate events record waiting and approved, with the actor
105	b, _ := os.ReadFile(filepath.Join(runs, "a", "events.ndjson"))
106	if !strings.Contains(string(b), `"state":"approved","choice":"deploy","actor":"local:me"`) {
107		t.Errorf("gate decision not recorded:\n%s", b)
108	}
109
110	// resuming a finished run is an error
111	e = reopen(t, Dir(filepath.Join(runs, "a")), Options{})
112	if _, err := e.Resume(context.Background(), Choice{}); err == nil {
113		t.Error("resumed a finished run")
114	}
115	e.Log.Close()
116}
117
118func TestGatekeeperInline(t *testing.T) {
119	f := newFixture(t, gatedWF)
120	f.opts.Gates = approve(0) // deploy, then rollback at the health choice
121	e := f.create(t.TempDir(), "a", Meta{})
122	st, err := e.Run(context.Background())
123	e.Log.Close()
124	if err != nil {
125		t.Fatal(err)
126	}
127	// rollback is off-path: the run ends as a failure after it
128	if st.Status != StatusFailure || !slices.Equal(executed(st), []string{"plan", "deploy", "health", "rollback"}) {
129		t.Fatalf("%s %v %+v", st.Status, executed(st), st.End)
130	}
131}
132
133func TestDetachPausesEvenWithGatekeeper(t *testing.T) {
134	f := newFixture(t, gatedWF)
135	f.opts.Gates = approve(0)
136	f.opts.Detach = true
137	e := f.create(t.TempDir(), "a", Meta{})
138	st, _ := e.Run(context.Background())
139	e.Log.Close()
140	if st.Status != StatusPaused {
141		t.Fatalf("status %s", st.Status)
142	}
143}
144
145func TestDismissAndExpire(t *testing.T) {
146	f := newFixture(t, gatedWF)
147	runs := t.TempDir()
148	e := f.create(runs, "a", Meta{})
149	runE(t, e)
150	e.Log.Close()
151	e = reopen(t, Dir(filepath.Join(runs, "a")), Options{})
152	if _, err := e.Resume(context.Background(), Choice{Expire: true}); err == nil {
153		t.Error("expired a gate before its deadline")
154	}
155	st, err := e.Resume(context.Background(), Choice{Dismiss: true, Actor: "me"})
156	e.Log.Close()
157	if err != nil || st.Status != StatusCancelled || st.End.Reason != lex.ReasonDismiss {
158		t.Fatalf("dismiss: %v %+v", err, st)
159	}
160
161	// a gate past its deadline expires whatever the choice
162	f = newFixture(t, strings.Replace(gatedWF, `prompt: "Deploy?"`, `prompt: "Deploy?", timeout: 1ms`, 1))
163	e = f.create(runs, "b", Meta{})
164	runE(t, e)
165	e.Log.Close()
166	time.Sleep(5 * time.Millisecond)
167	e = reopen(t, Dir(filepath.Join(runs, "b")), Options{})
168	st, err = e.Resume(context.Background(), Choice{Option: 0})
169	e.Log.Close()
170	if err != nil || st.Status != StatusCancelled || st.End.Reason != lex.ReasonExpired || len(st.History) != 1 {
171		t.Fatalf("expire: %v %+v", err, st)
172	}
173}
174
175func TestSupersede(t *testing.T) {
176	f := newFixture(t, gatedWF)
177	runs := t.TempDir()
178	old := f.create(runs, "a", Meta{})
179	runE(t, old)
180	old.Log.Close()
181	time.Sleep(2 * time.Millisecond) // Created must differ
182	// another workflow waiting in the same group is not touched
183	other := f.create(runs, "c", Meta{Run: "c", Workflow: "other", Repo: "repo", Kind: "manual", Host: "local", Root: f.root})
184	runE(t, other)
185	other.Log.Close()
186	time.Sleep(2 * time.Millisecond)
187	newer := f.create(runs, "b", Meta{})
188	st, _ := newer.Run(context.Background())
189	newer.Log.Close()
190	if st.Status != StatusPaused {
191		t.Fatalf("newer: %s", st.Status)
192	}
193	if s, _ := Load(Dir(filepath.Join(runs, "a"))); s.Status != StatusCancelled || !strings.Contains(s.End.Detail, "superseded by run b") {
194		t.Errorf("older run: %+v", s.End)
195	}
196	if s, _ := Load(Dir(filepath.Join(runs, "c"))); s.Status != StatusPaused {
197		t.Errorf("other workflow: %s", s.Status)
198	}
199}
200
201func TestConcurrencyLockWaits(t *testing.T) {
202	f := newFixture(t, wf("deploy", "deploy: true"))
203	f.m.Actions["deploy"] = func() lex.Action {
204		a := f.m.Actions["deploy"]
205		a.Concurrency = &lex.Concurrency{Group: "fleet"}
206		return a
207	}()
208	runs := t.TempDir()
209	locks := filepath.Join(runs, ".locks")
210	if err := os.MkdirAll(locks, 0o755); err != nil {
211		t.Fatal(err)
212	}
213	held, err := os.OpenFile(filepath.Join(locks, "fleet.lock"), os.O_CREATE|os.O_RDWR, 0o644)
214	if err != nil {
215		t.Fatal(err)
216	}
217	if _, err := held.WriteString("other-run\n"); err != nil {
218		t.Fatal(err)
219	}
220	if err := syscall.Flock(int(held.Fd()), syscall.LOCK_EX); err != nil {
221		t.Fatal(err)
222	}
223	e := f.create(runs, "a", Meta{})
224	defer e.Log.Close()
225	done := make(chan *State)
226	go func() { st, _ := e.Run(context.Background()); done <- st }()
227	time.Sleep(300 * time.Millisecond)
228	select {
229	case st := <-done:
230		t.Fatalf("ran while the lock was held: %+v", st)
231	default:
232	}
233	held.Close() // releases the lock
234	st := <-done
235	if st.Status != StatusSuccess {
236		t.Fatalf("%+v", st.End)
237	}
238	b, _ := os.ReadFile(filepath.Join(runs, "a", "events.ndjson"))
239	if !strings.Contains(string(b), "waiting for concurrency group fleet (held by run other-run)") {
240		t.Errorf("no waiting message:\n%s", b)
241	}
242}
243
244func TestManualChildRun(t *testing.T) {
245	f := newFixture(t, gatedWF)
246	f.opts.Gates = approve(0)
247	runs := t.TempDir()
248	parent := f.create(runs, "a", Meta{})
249	parent.Opts.Gates = GateFunc(func(_ context.Context, _ *Engine, g *PendingGate) (Choice, error) {
250		if g.From == "health" {
251			return Choice{Option: 1}, nil // check again
252		}
253		return Choice{Option: 0}, nil
254	})
255	pst, _ := parent.Run(context.Background())
256	parent.Log.Close()
257	if pst.Status != StatusSuccess {
258		t.Fatalf("parent %+v", pst.End)
259	}
260
261	// later: roll back against the finished run
262	meta := pst.Meta
263	meta.Run, meta.Parent = "b", filepath.Join(runs, "a")
264	child, err := Create(f.m, Dir(filepath.Join(runs, "b")), meta, Bash{}, Options{Gates: approve(0)})
265	if err != nil {
266		t.Fatal(err)
267	}
268	st, err := child.RunFrom(context.Background(), "rollback")
269	child.Log.Close()
270	if err != nil {
271		t.Fatal(err)
272	}
273	if st.Status != StatusSuccess || !slices.Equal(executed(st), []string{"rollback"}) {
274		t.Fatalf("child %s %v %+v", st.Status, executed(st), st.End)
275	}
276	// it saw the parent's state; the parent is unchanged
277	b, _ := os.ReadFile(filepath.Join(runs, "b", "events.ndjson"))
278	if !strings.Contains(string(b), `"content":"deployed"`) {
279		t.Errorf("child did not read the parent's $CI_STATE:\n%s", b)
280	}
281	if again, _ := Load(Dir(filepath.Join(runs, "a"))); again.Status != StatusSuccess || len(again.History) != len(pst.History) {
282		t.Errorf("parent changed: %+v", again)
283	}
284}