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.
|
3b39a84c
Keenan Nemetz
14d ago
|
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 | |
|
a29ce1f7
Keenan Nemetz
3d ago
|
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. | |
|
3b39a84c
Keenan Nemetz
14d ago
|
17 | package cache |
| 18 | ||
| 19 | import ( | |
| 20 | "crypto/sha256" | |
| 21 | "encoding/hex" | |
| 22 | "fmt" | |
| 23 | "io/fs" | |
| 24 | "os" | |
| 25 | "path/filepath" | |
| 26 | "sort" | |
| 27 | "strconv" | |
| 28 | "strings" | |
|
a29ce1f7
Keenan Nemetz
3d ago
|
29 | "sync" |
|
3b39a84c
Keenan Nemetz
14d ago
|
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. | |
| 36 | const 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. | |
| 40 | const 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. | |
| 46 | func 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. | |
| 54 | func 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 | ||
|
a29ce1f7
Keenan Nemetz
3d ago
|
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. | |
| 82 | func 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. | |
| 96 | func 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. | |
| 104 | func 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. | |
| 113 | func 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 | ||
| 121 | func 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 | ||
|
3b39a84c
Keenan Nemetz
14d ago
|
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 | |
|
a29ce1f7
Keenan Nemetz
3d ago
|
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. | |
|
3b39a84c
Keenan Nemetz
14d ago
|
147 | // |
|
e6f0d8b9
Keenan Nemetz
22h ago
|
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 | // | |
|
3b39a84c
Keenan Nemetz
14d ago
|
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. | |
| 155 | func 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 | } | |
|
e6f0d8b9
Keenan Nemetz
22h ago
|
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 | }) | |
|
3b39a84c
Keenan Nemetz
14d ago
|
184 | |
| 185 | var removed int64 | |
| 186 | for _, ns := range all { | |
| 187 | if total-removed <= maxBytes { | |
| 188 | break | |
| 189 | } | |
|
a29ce1f7
Keenan Nemetz
3d ago
|
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 { | |
|
3b39a84c
Keenan Nemetz
14d ago
|
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 | |
|
a29ce1f7
Keenan Nemetz
3d ago
|
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. | |
|
3b39a84c
Keenan Nemetz
14d ago
|
209 | func 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) { | |
|
a29ce1f7
Keenan Nemetz
3d ago
|
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 { | |
|
3b39a84c
Keenan Nemetz
14d ago
|
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. | |
| 231 | func 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 | |
|
a29ce1f7
Keenan Nemetz
3d ago
|
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. | |
|
3b39a84c
Keenan Nemetz
14d ago
|
267 | func 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 { | |
|
a29ce1f7
Keenan Nemetz
3d ago
|
274 | if e.IsDir() && !strings.HasPrefix(e.Name(), ".") { |
|
3b39a84c
Keenan Nemetz
14d ago
|
275 | out = append(out, filepath.Join(p, e.Name())) |
| 276 | } | |
| 277 | } | |
| 278 | return out | |
| 279 | } | |
| 280 | ||
|
e6f0d8b9
Keenan Nemetz
22h ago
|
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. | |
|
3b39a84c
Keenan Nemetz
14d ago
|
288 | func measure(dir string) (size int64, newest time.Time) { |
|
e6f0d8b9
Keenan Nemetz
22h ago
|
289 | var created time.Time |
|
3b39a84c
Keenan Nemetz
14d ago
|
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 | } | |
|
e6f0d8b9
Keenan Nemetz
22h ago
|
298 | if d.IsDir() { |
| 299 | if p == dir { | |
| 300 | created = info.ModTime() | |
| 301 | } | |
| 302 | return nil | |
| 303 | } | |
| 304 | size += info.Size() | |
|
3b39a84c
Keenan Nemetz
14d ago
|
305 | if info.ModTime().After(newest) { |
| 306 | newest = info.ModTime() | |
| 307 | } | |
| 308 | return nil | |
| 309 | }) | |
|
e6f0d8b9
Keenan Nemetz
22h ago
|
310 | if newest.IsZero() { |
| 311 | newest = created | |
| 312 | } | |
|
3b39a84c
Keenan Nemetz
14d ago
|
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. | |
| 318 | func 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. | |
| 330 | func 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 | } |