Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/vole.git

Write over SSH with a registered key:

git clone ssh://git@rodent.software:2222/vole.git

A free and open-source forge that lives in your Git repo.

Code

395 commit(s) on main

update readme Keenan Nemetz · 24m ago 32290114
history blame raw 11.6 KiB
1// Package cache keeps the directories a job reuses between runs: downloaded
2// dependencies and compiled output that are expensive to recreate.
3//
4// The runner owns the directory, namespaces it, and caps it. It does not decide
5// what a cache is: it mounts one directory at Mount and sets no variable that
6// points at it. Which tool uses it — and therefore whether a job shares state with
7// the job before it — is the repository's decision, expressed in the image's
8// `env:` or in the step itself. A runner that guessed would be making a claim
9// about a toolchain it does not test, and a job that wants no shared state would
10// have to defend against a default it never asked for.
11//
12// Each job sees exactly one directory. Namespaces are whole directories so
13// eviction never leaves half a module cache behind: a namespace is either there or
14// it is not. A job holds its namespace while it runs and eviction takes the same
15// lock, so a build is never reading a tree the runner is deleting: the trimmer
16// passes over what is in use and takes it on a later pass.
17package cache
18
19import (
20 "crypto/sha256"
21 "encoding/hex"
22 "fmt"
23 "io/fs"
24 "os"
25 "path/filepath"
26 "sort"
27 "strconv"
28 "strings"
29 "sync"
30 "time"
31)
32
33// Mount is where the runner mounts a job's namespace inside the container. It is
34// fixed rather than configurable because the variables below name subdirectories
35// of it, and those are what the tools actually read.
36const Mount = "/cache"
37
38// DefaultBytes is the cap a runner uses when none is configured: five gigabytes
39// holds a race-enabled Go cache for a project this size several times over.
40const DefaultBytes int64 = 5 << 30
41
42// Namespace is the directory one job's cache lives in. The parts are ordered so
43// that the two things worth separating are separated first: a repository never
44// sees another repository's cache, and an untrusted contributor branch never sees
45// the cache a protected branch builds into.
46func Namespace(root, repo, trust, image string) string {
47 return filepath.Join(root, segment(repo), segment(trust), segment(image))
48}
49
50// ImageKey names the environment a cache was built for. A definition is keyed by
51// its digest, so changing it starts a new cache; a registry reference is keyed by
52// the reference plus a short hash, so two references cannot collide after
53// sanitising.
54func ImageKey(imageSHA, imageRef string) string {
55 if imageSHA != "" {
56 sha := imageSHA
57 if len(sha) > 16 {
58 sha = sha[:16]
59 }
60 return "file-" + segment(sha)
61 }
62 sum := sha256.Sum256([]byte(imageRef))
63 name := imageRef
64 if len(name) > 48 {
65 name = name[:48]
66 }
67 return "ref-" + segment(name) + "-" + hex.EncodeToString(sum[:4])
68}
69
70// The namespace locks. A job holds its namespace for as long as a container may be
71// inside it, and eviction takes the same lock, so either a tree is not in use or it
72// is one eviction cannot have.
73//
74// The lock files live under <root>/.locks, mirroring the namespace, rather than
75// beside or inside it. Inside would mean removing a namespace removes the lock its
76// holder is using; a sibling directory would sit in the cache tree, where a sweep
77// walking it could mistake bookkeeping for a cache and where a `--no-cache` run
78// would look like it had left something behind. A lock file is never deleted for
79// the same reason gitcache's are not: removing it races with the next holder
80// opening it, and a dead process releases the kernel lock when its descriptors
81// close.
82func lockPath(root, ns string) (string, error) {
83 rel, err := filepath.Rel(root, ns)
84 if err != nil {
85 return "", fmt.Errorf("lock %s: it is not under %s: %w", ns, root, err)
86 }
87 if rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
88 return "", fmt.Errorf("lock %s: it is not under %s", ns, root)
89 }
90 return filepath.Join(root, ".locks", rel+".lock"), nil
91}
92
93// Lock shares the namespace with any other job that has it. Two jobs of the same
94// repository, trust and image may run at once — their tool caches are built for
95// that — and what they cannot survive is the tree being deleted underneath them.
96func Lock(root, ns string) (func(), error) {
97 release, _, err := holdNamespace(root, ns, false, true)
98 return release, err
99}
100
101// LockExclusive owns the namespace, waiting for any job already inside it. A runner
102// that does not reuse its cache takes this because it empties the namespace when
103// the job ends, and that must not land on somebody else's build.
104func LockExclusive(root, ns string) (func(), error) {
105 release, _, err := holdNamespace(root, ns, true, true)
106 return release, err
107}
108
109// TryLockExclusive takes the namespace exclusively or reports false when a job has
110// it. Eviction uses this: a namespace in use is left for a later pass rather than
111// waited on, and a lock file that cannot be opened is treated the same way, because
112// skipping a deletion is always the safe answer.
113func TryLockExclusive(root, ns string) (func(), bool) {
114 release, held, err := holdNamespace(root, ns, true, false)
115 if err != nil || !held {
116 return nil, false
117 }
118 return release, true
119}
120
121func holdNamespace(root, ns string, exclusive, wait bool) (func(), bool, error) {
122 path, err := lockPath(root, ns)
123 if err != nil {
124 return nil, false, err
125 }
126 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
127 return nil, false, fmt.Errorf("prepare the lock for %s: %w", ns, err)
128 }
129 f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o644)
130 if err != nil {
131 return nil, false, fmt.Errorf("open the lock for %s: %w", ns, err)
132 }
133 unlock, held, err := lockFile(f, exclusive, wait)
134 if err != nil || !held {
135 _ = f.Close()
136 return nil, false, err
137 }
138 var once sync.Once
139 return func() { once.Do(func() { unlock(); _ = f.Close() }) }, true, nil
140}
141
142// Trim deletes whole namespaces, least recently written first, until root holds at
143// most maxBytes. It returns how many bytes it removed. A namespace that cannot be
144// removed, or that a job has mounted, is skipped rather than leaving the cache in an
145// unknown state; a skipped namespace still counts against the cap, so the next pass
146// finds it again.
147//
148// "Written" is a file: see measure. Namespaces that were written at the same
149// instant are ordered by path, so two caches of the same age always go in the same
150// order rather than whichever one a sort happened to move first.
151//
152// Whole namespaces rather than files: a Go module cache with one file missing is
153// worse than no Go module cache, because the failure is a checksum error deep in a
154// build rather than a download.
155func Trim(root string, maxBytes int64) (int64, error) {
156 if maxBytes <= 0 {
157 return 0, nil
158 }
159 type namespace struct {
160 path string
161 size int64
162 newest time.Time
163 }
164 var all []namespace
165 var total int64
166 for _, repo := range dirs(root) {
167 for _, trust := range dirs(repo) {
168 for _, image := range dirs(trust) {
169 size, newest := measure(image)
170 all = append(all, namespace{image, size, newest})
171 total += size
172 }
173 }
174 }
175 if total <= maxBytes {
176 return 0, nil
177 }
178 sort.Slice(all, func(i, j int) bool {
179 if all[i].newest.Equal(all[j].newest) {
180 return all[i].path < all[j].path
181 }
182 return all[i].newest.Before(all[j].newest)
183 })
184
185 var removed int64
186 for _, ns := range all {
187 if total-removed <= maxBytes {
188 break
189 }
190 release, held := TryLockExclusive(root, ns.path)
191 if !held {
192 continue // a job has it mounted; the next pass can have it
193 }
194 err := forceRemoveAll(ns.path)
195 release()
196 if err != nil {
197 continue
198 }
199 removed += ns.size
200 }
201 return removed, nil
202}
203
204// Clear removes every namespace under root. It is for a runner that does not reuse
205// the cache: the directory still has to be mounted, because an image may point at
206// it, but nothing from a previous job should be there. A namespace a job has
207// mounted is skipped, so a second runner process sharing this --dir is not emptied
208// under its own build.
209func Clear(root string) error {
210 var first error
211 for _, repo := range dirs(root) {
212 for _, trust := range dirs(repo) {
213 for _, image := range dirs(trust) {
214 release, held := TryLockExclusive(root, image)
215 if !held {
216 continue // a job has it mounted, or the lock cannot be opened
217 }
218 err := forceRemoveAll(image)
219 release()
220 if err != nil && first == nil {
221 first = err
222 }
223 }
224 }
225 }
226 return first
227}
228
229// ParseSize reads a size such as "512M", "5GiB", or a plain byte count. Suffixes
230// are binary, which is what people mean when they type 5G for a cache.
231func ParseSize(s string) (int64, error) {
232 text := strings.ToUpper(strings.TrimSpace(s))
233 if text == "" {
234 return 0, fmt.Errorf("a size is required")
235 }
236 mult := int64(1)
237 for _, suffix := range []struct {
238 text string
239 mult int64
240 }{
241 {"KIB", 1 << 10}, {"MIB", 1 << 20}, {"GIB", 1 << 30}, {"TIB", 1 << 40},
242 {"KB", 1 << 10}, {"MB", 1 << 20}, {"GB", 1 << 30}, {"TB", 1 << 40},
243 {"K", 1 << 10}, {"M", 1 << 20}, {"G", 1 << 30}, {"T", 1 << 40},
244 {"B", 1},
245 } {
246 if strings.HasSuffix(text, suffix.text) {
247 mult = suffix.mult
248 text = strings.TrimSpace(strings.TrimSuffix(text, suffix.text))
249 break
250 }
251 }
252 n, err := strconv.ParseInt(text, 10, 64)
253 if err != nil || n <= 0 {
254 return 0, fmt.Errorf("%q is not a positive size", s)
255 }
256 if n > (1<<62)/mult {
257 return 0, fmt.Errorf("%q is too large", s)
258 }
259 return n * mult, nil
260}
261
262// dirs lists the subdirectories of p, ignoring anything that cannot be read. A
263// cache is best-effort: a permission problem there must not fail a job. Hidden
264// directories are skipped because the runner keeps its lock files under .locks, and
265// bookkeeping must never be measured or evicted as if it were a cache. Namespace
266// parts never start with a dot: segment strips them.
267func dirs(p string) []string {
268 entries, err := os.ReadDir(p)
269 if err != nil {
270 return nil
271 }
272 out := make([]string, 0, len(entries))
273 for _, e := range entries {
274 if e.IsDir() && !strings.HasPrefix(e.Name(), ".") {
275 out = append(out, filepath.Join(p, e.Name()))
276 }
277 }
278 return out
279}
280
281// measure returns a namespace's size and when it was last written. Only files
282// count as writes: a directory's mtime moves when a tree is unpacked, so counting
283// it would age a namespace by the act of extracting it rather than by the work it
284// holds — and two namespaces created in the same breath would tie, after which the
285// eviction order was whatever the sort did with equal keys. An empty namespace has
286// no file to date it, so it falls back to its own mtime and can still be aged
287// against the others.
288func measure(dir string) (size int64, newest time.Time) {
289 var created time.Time
290 _ = filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error {
291 if err != nil {
292 return nil
293 }
294 info, err := d.Info()
295 if err != nil {
296 return nil
297 }
298 if d.IsDir() {
299 if p == dir {
300 created = info.ModTime()
301 }
302 return nil
303 }
304 size += info.Size()
305 if info.ModTime().After(newest) {
306 newest = info.ModTime()
307 }
308 return nil
309 })
310 if newest.IsZero() {
311 newest = created
312 }
313 return size, newest
314}
315
316// forceRemoveAll removes a tree that compilers have made read-only: the Go module
317// cache, for one, chmods its directories to 0555 after extracting them.
318func forceRemoveAll(dir string) error {
319 _ = filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error {
320 if err == nil && d.IsDir() {
321 _ = os.Chmod(p, 0o700)
322 }
323 return nil
324 })
325 return os.RemoveAll(dir)
326}
327
328// segment makes one path component out of a name that came from a repository, a
329// ref, or an image reference.
330func segment(s string) string {
331 clean := strings.Map(func(r rune) rune {
332 switch {
333 case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
334 return r
335 case r == '.', r == '_', r == '-':
336 return r
337 default:
338 return '_'
339 }
340 }, s)
341 clean = strings.Trim(clean, ".")
342 if clean == "" {
343 clean = "unknown"
344 }
345 if len(clean) > 64 {
346 clean = clean[:64]
347 }
348 return clean
349}
350