prune.go
1package runner
2
3import (
4 "context"
5 "os"
6 "path/filepath"
7 "strings"
8 "time"
9
10 "git.kilimanjaro.io/ci/internal/engine"
11)
12
13// Retention of finished runs. A run directory holds the run's record (small)
14// and its checkouts, whose .devenv/ directories root Nix store paths for as
15// long as they exist; nothing else ever frees them, so finished runs are
16// pruned in two stages:
17//
18// - slimmed after Config.SlimAfter: everything but the record is removed
19// (the checkouts and their GC roots); the run still shows its log,
20// results and artifacts, and a manual action (rollback) can still read
21// its state/ ($CI_STATE);
22// - deleted after Config.DeleteAfter: the directory and the row go.
23//
24// Running, paused and pending runs are never touched, nor is a run with a
25// child that has not finished.
26
27const (
28 // DefaultSlimAfter and DefaultDeleteAfter apply when the Config leaves them zero.
29 DefaultSlimAfter = 3 * 24 * time.Hour
30 DefaultDeleteAfter = 60 * 24 * time.Hour
31)
32
33// kept is what a slimmed run keeps.
34var kept = map[string]bool{
35 "state.json": true,
36 "events.ndjson": true,
37 "manifest.yml": true,
38 "state": true,
39 "artifacts": true,
40 "results": true,
41}
42
43func keptFile(name string) bool {
44 if kept[name] {
45 return true
46 }
47 ok, _ := filepath.Match("segment-*.log", name)
48 return ok
49}
50
51// Prune applies the retention once: it reports how many runs it slimmed and deleted.
52func (r *Runner) Prune(now time.Time) (slimmed, deleted int) {
53 slim, del := r.Cfg.SlimAfter, r.Cfg.DeleteAfter
54 if slim == 0 {
55 slim = DefaultSlimAfter
56 }
57 if del == 0 {
58 del = DefaultDeleteAfter
59 }
60 rows, err := r.db().Query(`select ` + runCols + ` from runs`)
61 if err != nil {
62 r.Logger.Error("prune: list runs", "err", err)
63 return
64 }
65 var runs []Run
66 live := map[string]bool{} // parents of runs that are not finished
67 for rows.Next() {
68 x, err := scanRun(rows)
69 if err != nil {
70 break
71 }
72 if !engine.Terminal(x.Status) && x.Parent != "" {
73 live[x.Parent] = true
74 }
75 runs = append(runs, x)
76 }
77 rows.Close()
78 for _, x := range runs {
79 if !engine.Terminal(x.Status) || live[x.ID] || !r.inRunsDir(x.Dir) {
80 continue
81 }
82 t, err := time.Parse(time.RFC3339Nano, x.Updated)
83 if err != nil {
84 continue
85 }
86 age := now.Sub(t)
87 switch {
88 case del > 0 && age > del:
89 // RemoveAll never follows a symlink (a working-copy run's main
90 // checkout), so a developer's tree is safe.
91 if err := os.RemoveAll(x.Dir); err != nil {
92 r.Logger.Error("prune: delete run", "run", x.ID, "err", err)
93 continue
94 }
95 if _, err := r.db().Exec(`delete from runs where id = ?`, x.ID); err != nil {
96 r.Logger.Error("prune: delete row", "run", x.ID, "err", err)
97 continue
98 }
99 deleted++
100 case slim > 0 && age > slim:
101 if r.slim(x.Dir) {
102 slimmed++
103 }
104 }
105 }
106 return
107}
108
109// slim removes everything in a run directory but its record, and reports
110// whether it removed anything.
111func (r *Runner) slim(dir string) bool {
112 entries, err := os.ReadDir(dir)
113 if err != nil {
114 return false
115 }
116 removed := false
117 for _, e := range entries {
118 if keptFile(e.Name()) {
119 continue
120 }
121 if err := os.RemoveAll(filepath.Join(dir, e.Name())); err != nil {
122 r.Logger.Error("prune: slim run", "dir", dir, "entry", e.Name(), "err", err)
123 continue
124 }
125 removed = true
126 }
127 return removed
128}
129
130// inRunsDir guards the removals: a row's dir must be a direct child of RunsDir.
131func (r *Runner) inRunsDir(dir string) bool {
132 rel, err := filepath.Rel(r.Cfg.RunsDir, dir)
133 return err == nil && rel != "." && !strings.HasPrefix(rel, "..") && !strings.ContainsRune(rel, filepath.Separator)
134}
135
136// pruneLoop prunes at start and then hourly until ctx ends.
137func (r *Runner) pruneLoop(ctx context.Context) {
138 for {
139 if s, d := r.Prune(time.Now()); s+d > 0 {
140 r.Logger.Info("pruned finished runs", "slimmed", s, "deleted", d)
141 }
142 select {
143 case <-ctx.Done():
144 return
145 case <-time.After(time.Hour):
146 }
147 }
148}