vole
Clone
Read only, no credentials:
https://rodent.software/git/vole.git
Write over SSH with a registered key:
git clone ssh://git@0.0.0.0:2222/vole.git
A free and open-source forge that lives in your Git repo.
Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys
keenan · 17m ago
4b8c9e1d
| 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. |
| 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" |
| 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. |
| 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 | |
| 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 | |
| 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. |
| 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 | } |
| 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. |
| 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) { |
| 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. |
| 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 |
| 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. |
| 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 { |
| 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. |
| 288 | func 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. |
| 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 | } |
| 350 |