blob: f9b640dacc5165d9da9f1062aae860b9730b1b06 [file] [edit]
#!/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()