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}