prune_test.go

  1package runner
  2
  3import (
  4	"os"
  5	"path/filepath"
  6	"testing"
  7	"time"
  8
  9	"git.kilimanjaro.io/ci/internal/engine"
 10	"git.kilimanjaro.io/ci/internal/eventlog"
 11)
 12
 13func newPruneRunner(t *testing.T, cfg Config) *Runner {
 14	t.Helper()
 15	root := t.TempDir()
 16	cfg.RunsDir = filepath.Join(root, "runs")
 17	cfg.CacheDir = filepath.Join(root, "cache")
 18	log, err := eventlog.Open(filepath.Join(root, "runner.db"))
 19	if err != nil {
 20		t.Fatal(err)
 21	}
 22	t.Cleanup(func() { log.Close() })
 23	r, err := New(cfg, log, nil, nil)
 24	if err != nil {
 25		t.Fatal(err)
 26	}
 27	return r
 28}
 29
 30// addRun makes a run directory with a record, a checkout (with a .devenv/)
 31// and a row last updated age ago.
 32func addRun(t *testing.T, r *Runner, id, status, parent string, age time.Duration) string {
 33	t.Helper()
 34	dir := engine.Dir(filepath.Join(r.Cfg.RunsDir, id))
 35	if err := dir.Create(); err != nil {
 36		t.Fatal(err)
 37	}
 38	write := func(p, s string) {
 39		t.Helper()
 40		if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
 41			t.Fatal(err)
 42		}
 43		if err := os.WriteFile(p, []byte(s), 0o644); err != nil {
 44			t.Fatal(err)
 45		}
 46	}
 47	write(dir.StateFile(), "{}")
 48	write(dir.Events(), "")
 49	write(dir.ManifestFile(), "")
 50	write(filepath.Join(dir.State(), "prev-systems.json"), "{}")
 51	write(filepath.Join(dir.Results(), "r"), "r")
 52	write(filepath.Join(dir.Artifacts(), "a"), "a")
 53	write(filepath.Join(dir.Path(), "segment-1.log"), "log")
 54	write(filepath.Join(dir.Path(), "repo", ".devenv", "gc", "shell"), "root")
 55	updated := time.Now().Add(-age).UTC().Format(time.RFC3339Nano)
 56	if _, err := r.db().Exec(`insert into runs (id, pipeline, rkey, workflow, repo, dir, parent, status, created, updated)
 57		values (?, 'p', ?, 'w', 'repo', ?, ?, ?, ?, ?)`, id, id, dir.Path(), parent, status, updated, updated); err != nil {
 58		t.Fatal(err)
 59	}
 60	return dir.Path()
 61}
 62
 63func exists(p string) bool { _, err := os.Stat(p); return err == nil }
 64
 65func TestPrune(t *testing.T) {
 66	day := 24 * time.Hour
 67	r := newPruneRunner(t, Config{SlimAfter: 3 * day, DeleteAfter: 60 * day})
 68	fresh := addRun(t, r, "fresh", "success", "", day)
 69	old := addRun(t, r, "old", "failure", "", 5*day)
 70	ancient := addRun(t, r, "ancient", "success", "", 90*day)
 71	paused := addRun(t, r, "paused", "paused", "", 90*day)
 72	running := addRun(t, r, "running", "running", "", 90*day)
 73	parent := addRun(t, r, "parent", "success", "", 90*day)
 74	addRun(t, r, "child", "running", "parent", day)
 75
 76	slimmed, deleted := r.Prune(time.Now())
 77	if slimmed != 1 || deleted != 1 {
 78		t.Fatalf("slimmed %d, deleted %d; want 1 and 1", slimmed, deleted)
 79	}
 80
 81	// fresh: untouched
 82	if !exists(filepath.Join(fresh, "repo", ".devenv", "gc", "shell")) {
 83		t.Error("a fresh run was slimmed")
 84	}
 85	// old: checkout gone, record kept (including state/ for a rollback)
 86	if exists(filepath.Join(old, "repo")) {
 87		t.Error("an old run kept its checkout")
 88	}
 89	for _, p := range []string{"state.json", "events.ndjson", "manifest.yml", "segment-1.log",
 90		"state/prev-systems.json", "results/r", "artifacts/a"} {
 91		if !exists(filepath.Join(old, p)) {
 92			t.Errorf("a slimmed run lost %s", p)
 93		}
 94	}
 95	// ancient: directory and row gone
 96	if exists(ancient) {
 97		t.Error("an ancient run kept its directory")
 98	}
 99	if _, err := r.GetRun("ancient"); err == nil {
100		t.Error("an ancient run kept its row")
101	}
102	// never touched: unfinished runs, and a run with an unfinished child
103	for name, dir := range map[string]string{"paused": paused, "running": running, "a parent with a live child": parent} {
104		if !exists(filepath.Join(dir, "repo", ".devenv")) {
105			t.Errorf("%s was pruned", name)
106		}
107	}
108	// idempotent
109	if s, d := r.Prune(time.Now()); s != 0 || d != 0 {
110		t.Errorf("second pass slimmed %d, deleted %d", s, d)
111	}
112}
113
114func TestPruneDisabledAndSymlink(t *testing.T) {
115	day := 24 * time.Hour
116	r := newPruneRunner(t, Config{SlimAfter: -1, DeleteAfter: -1})
117	dir := addRun(t, r, "old", "success", "", 400*day)
118	if s, d := r.Prune(time.Now()); s != 0 || d != 0 || !exists(filepath.Join(dir, "repo")) {
119		t.Fatalf("negative retention still pruned: %d, %d", s, d)
120	}
121
122	// a working-copy run: the main checkout is a symlink to the developer's tree
123	r = newPruneRunner(t, Config{SlimAfter: day, DeleteAfter: 2 * day})
124	tree := t.TempDir()
125	os.WriteFile(filepath.Join(tree, "mine"), []byte("x"), 0o644)
126	dir = addRun(t, r, "wc", "success", "", 5*day)
127	os.RemoveAll(filepath.Join(dir, "repo"))
128	os.Symlink(tree, filepath.Join(dir, "repo"))
129	r.Prune(time.Now())
130	if !exists(filepath.Join(tree, "mine")) {
131		t.Fatal("pruning followed a symlink into a working tree")
132	}
133}
134
135func TestPruneOutsideRunsDir(t *testing.T) {
136	r := newPruneRunner(t, Config{})
137	outside := t.TempDir()
138	os.WriteFile(filepath.Join(outside, "keep"), []byte("x"), 0o644)
139	old := time.Now().Add(-400 * 24 * time.Hour).UTC().Format(time.RFC3339Nano)
140	r.db().Exec(`insert into runs (id, pipeline, rkey, workflow, repo, dir, status, created, updated)
141		values ('x', 'p', 'x', 'w', 'r', ?, 'success', ?, ?)`, outside, old, old)
142	r.Prune(time.Now())
143	if !exists(filepath.Join(outside, "keep")) {
144		t.Fatal("pruned a directory outside the runs dir")
145	}
146}