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}