| #!/usr/bin/env python3 |
| # SPDX-FileCopyrightText: The Eigen Authors |
| # SPDX-License-Identifier: MPL-2.0 |
| |
| """Prune a GitLab Runner local cache directory. |
| |
| For self-hosted runners without a distributed cache backend |
| (`[runners.cache]`), where `Checking cache for <key>` is followed by |
| `No URL provided ... a local version of cache will be extracted`. Those |
| archives live on the runner's disk and GitLab Runner never removes one: a |
| `cache:key` change and the "Clear runner caches" button both orphan the old |
| archive rather than reclaiming it. Runners backed by a distributed cache need |
| none of this; the object store's lifecycle policy covers them. |
| |
| Two passes, either usable alone: |
| |
| --stale-index-below N drops superseded cache generations. GitLab appends |
| `-<index>-protected` / `-<index>-non_protected` to every key, where the index |
| is the project's "Clear runner caches" counter, so a job can only read the |
| current index and anything below it is unreadable by construction. Read the |
| current index off any job log's `Checking cache for <key>-<index>-<protection>` |
| line. |
| |
| --max-gb caps the total, dropping the oldest archive by mtime until it fits. |
| Every job that restores a pool also rewrites it (`cache:policy` defaults to |
| pull-push), so mtime tracks last use and oldest-first is an LRU, not a FIFO. |
| |
| --report prints what is on disk, by generation, and changes nothing. |
| |
| Usage, as the user that owns the cache directory: |
| |
| prune_runner_cache.py --root /var/lib/gitlab-runner/cache --report |
| prune_runner_cache.py --root /var/lib/gitlab-runner/cache \\ |
| --stale-index-below 11 --max-gb 120 |
| |
| With the docker executor the archives live inside the runner's cache volume |
| unless `[runners] cache_dir` names a host path that is also listed in |
| `[runners.docker] volumes`; point --root at that path, or at the volume's |
| Mountpoint from `docker volume inspect`. |
| """ |
| |
| import argparse |
| import os |
| import re |
| import sys |
| import time |
| |
| DEFAULT_PATTERNS = ("cache.zip", "cache.tgz", "cache.tar.zst") |
| INDEX = re.compile(r"-(\d+)-(?:protected|non_protected)$") |
| |
| |
| def entries(root, patterns): |
| for dirpath, _, filenames in os.walk(root): |
| for name in filenames: |
| if name in patterns: |
| path = os.path.join(dirpath, name) |
| try: |
| st = os.stat(path) |
| except OSError: |
| continue |
| yield path, st.st_size, st.st_mtime |
| |
| |
| def human(n): |
| for unit, scale in (("GB", 1e9), ("MB", 1e6), ("kB", 1e3)): |
| if n >= scale: |
| return "%.1f %s" % (n / scale, unit) |
| return "%d B" % n |
| |
| |
| def cache_index(path): |
| """GitLab's cache generation, from the key directory holding the archive.""" |
| match = INDEX.search(os.path.basename(os.path.dirname(path))) |
| return int(match.group(1)) if match else None |
| |
| |
| def remove(path, size, mtime, args, why): |
| rel = os.path.relpath(path, args.root) |
| age_days = (time.time() - mtime) / 86400 |
| print("%s %s (%s, %.1f d, %s)" % ("would remove" if args.dry_run else "removing", |
| rel, human(size), age_days, why)) |
| if args.dry_run: |
| return |
| try: |
| os.remove(path) |
| except OSError as exc: |
| print(" failed: %s" % exc, file=sys.stderr) |
| return |
| parent = os.path.dirname(path) |
| while os.path.abspath(parent) != os.path.abspath(args.root): |
| try: |
| os.rmdir(parent) |
| except OSError: |
| break |
| parent = os.path.dirname(parent) |
| |
| |
| def main(): |
| p = argparse.ArgumentParser(description=__doc__, |
| formatter_class=argparse.RawDescriptionHelpFormatter) |
| p.add_argument("--root", required=True, help="cache directory to prune") |
| p.add_argument("--max-gb", type=float, help="keep total archive bytes below this") |
| p.add_argument("--stale-index-below", type=int, metavar="N", |
| help="delete archives whose key carries a cache index below N; " |
| "N is the index current jobs use") |
| p.add_argument("--report", action="store_true", |
| help="print a per-generation breakdown and exit") |
| p.add_argument("--min-age-minutes", type=float, default=30.0, |
| help="never delete an archive written this recently; avoids racing " |
| "a job that is still uploading (default: 30)") |
| p.add_argument("--pattern", action="append", |
| help="archive file name to consider (repeatable; default: %s)" |
| % ", ".join(DEFAULT_PATTERNS)) |
| p.add_argument("--dry-run", action="store_true") |
| args = p.parse_args() |
| |
| patterns = tuple(args.pattern) if args.pattern else DEFAULT_PATTERNS |
| if not os.path.isdir(args.root): |
| sys.exit("not a directory: %s" % args.root) |
| if not args.report and args.max_gb is None and args.stale_index_below is None: |
| sys.exit("nothing to do: pass --report, --max-gb or --stale-index-below") |
| |
| found = sorted(entries(args.root, patterns), key=lambda e: e[2]) |
| total = sum(size for _, size, _ in found) |
| print("%d archives, %s" % (len(found), human(total))) |
| |
| if args.report: |
| by_gen = {} |
| for path, size, mtime in found: |
| gen = cache_index(path) |
| count, nbytes, newest = by_gen.get(gen, (0, 0, 0)) |
| by_gen[gen] = (count + 1, nbytes + size, max(newest, mtime)) |
| for gen in sorted(by_gen, key=lambda g: (g is None, g)): |
| count, nbytes, newest = by_gen[gen] |
| print(" index %-5s %4d archives %9s newest %s" |
| % ("none" if gen is None else gen, count, human(nbytes), |
| time.strftime("%Y-%m-%d", time.localtime(newest)))) |
| return |
| |
| cutoff = time.time() - args.min_age_minutes * 60 |
| freed = 0 |
| |
| if args.stale_index_below is not None: |
| for entry in list(found): |
| path, size, mtime = entry |
| gen = cache_index(path) |
| if gen is None or gen >= args.stale_index_below or mtime > cutoff: |
| continue |
| remove(path, size, mtime, args, "superseded generation %d" % gen) |
| freed += size |
| found.remove(entry) |
| print("%s %s from superseded generations" |
| % ("would free" if args.dry_run else "freed", human(freed))) |
| |
| if args.max_gb is None: |
| return |
| |
| cap = int(args.max_gb * 1e9) |
| print("cap %s, %s in use" % (human(cap), human(total - freed))) |
| for path, size, mtime in found: |
| if total - freed <= cap: |
| break |
| if mtime > cutoff: |
| continue |
| remove(path, size, mtime, args, "least recently used") |
| freed += size |
| |
| print("%s %s in total; %s remains" |
| % ("would free" if args.dry_run else "freed", human(freed), human(total - freed))) |
| if total - freed > cap: |
| print("still over cap: every remaining archive is newer than " |
| "--min-age-minutes", file=sys.stderr) |
| |
| |
| if __name__ == "__main__": |
| main() |