From 27b18dbfc6ff9bc45d7f93cd6aa1eea3284a22bb Mon Sep 17 00:00:00 2001 From: Derek Date: Thu, 3 Sep 2026 09:42:21 +1000 Subject: [PATCH] fix(developer-rust): let the build cache live on a dedicated volume hyperi-rust-cache-prune refuses a pool outside the platform cache directory, and a systemd unit never loads the profile that would set XDG_CACHE_HOME. On a box that keeps its caches on their own volume, both prune units would exit 1 on every run. Both tools now take --cache-root, and the role passes rust_cache_root to each so they agree. Empty keeps today's behaviour: ~/.cache, or ~/Library/Caches. The confinement is kept rather than loosened -- it still refuses a pool outside the root, that root is just no longer assumed to be under home. --- .../roles/developer-rust/defaults/main.yml | 6 +++ .../files/hyperi-rust-cache-prune | 29 +++++++++-- .../developer-rust/files/hyperi-rust-setup | 52 +++++++++++++++---- ansible/roles/developer-rust/tasks/rust.yml | 1 + .../hyperi-rust-cache-prune-guard.service.j2 | 2 +- .../hyperi-rust-cache-prune.service.j2 | 2 +- .../io.hyperi.rust-cache-prune-guard.plist.j2 | 4 ++ .../io.hyperi.rust-cache-prune.plist.j2 | 4 ++ 8 files changed, 85 insertions(+), 15 deletions(-) diff --git a/ansible/roles/developer-rust/defaults/main.yml b/ansible/roles/developer-rust/defaults/main.yml index 2772c0f..0178593 100644 --- a/ansible/roles/developer-rust/defaults/main.yml +++ b/ansible/roles/developer-rust/defaults/main.yml @@ -23,6 +23,12 @@ rust_cache_manage: true # leaves the per-project layout alone, so this stays safe to leave on. rust_cache_central_build_dir: true +# Where the pooled build cache lives. Empty means the platform cache directory +# (~/.cache, or ~/Library/Caches on macOS). Set it to a path on a box with a +# dedicated cache volume, e.g. /cache -- both hyperi-rust-setup and the prune +# are given the same value, so the prune still refuses a pool outside it. +rust_cache_root: "" + rust_cache_sccache_max: "20G" rust_cache_ccache_max: "10G" diff --git a/ansible/roles/developer-rust/files/hyperi-rust-cache-prune b/ansible/roles/developer-rust/files/hyperi-rust-cache-prune index 89dc1ae..1469fb8 100644 --- a/ansible/roles/developer-rust/files/hyperi-rust-cache-prune +++ b/ansible/roles/developer-rust/files/hyperi-rust-cache-prune @@ -213,8 +213,19 @@ def human(size: int) -> str: return f"{value:.1f}T" -def cache_root() -> Path: - """The platform's cache directory -- must match hyperi-rust-setup exactly.""" +def cache_root(override: Path | None = None) -> Path: + """Where caches live -- must match what hyperi-rust-setup was given. + + A box with a dedicated cache volume puts the pool on that disk, outside home, + which would otherwise be refused. The override names the same directory + `hyperi-rust-setup --cache-root` was given. + + It is a flag rather than an environment read because a systemd unit does not + load the user's profile, so an XDG_CACHE_HOME set there is absent when the + timer fires. + """ + if override is not None: + return override if sys.platform == "darwin": return Path.home() / "Library" / "Caches" xdg = os.environ.get("XDG_CACHE_HOME") @@ -520,6 +531,16 @@ def main() -> int: "free (e.g. 20%% or 80G) -- how the hourly guard invokes it" ), ) + parser.add_argument( + "--cache-root", + type=Path, + default=None, + metavar="DIR", + help=( + "the directory the pool is expected to live under, for a box whose cache " + "is on its own volume -- the same value hyperi-rust-setup was given" + ), + ) parser.add_argument( "--pool", type=Path, @@ -549,10 +570,10 @@ def main() -> int: # and keeps working. if args.pool is None: pool = pool.resolve() - root = cache_root().resolve() + root = cache_root(args.cache_root).resolve() if pool == root or not pool.is_relative_to(root): print(f"refusing to prune {pool}: outside the cache root {root}.") - print("Point build.build-dir inside the cache root, or pass --pool to override.") + print("Pass --cache-root for a cache on its own volume, or --pool to name it.") return 1 print(f"hyperi-rust-cache-prune: {pool}") diff --git a/ansible/roles/developer-rust/files/hyperi-rust-setup b/ansible/roles/developer-rust/files/hyperi-rust-setup index e8af4cf..a7c2124 100644 --- a/ansible/roles/developer-rust/files/hyperi-rust-setup +++ b/ansible/roles/developer-rust/files/hyperi-rust-setup @@ -273,17 +273,25 @@ def cargo_version() -> tuple[int, int] | None: return (int(match.group(1)), int(match.group(2))) if match else None -def cache_root(family: str) -> Path: - """The platform's cache directory -- the one place a user expects to be evictable.""" +def cache_root(family: str, override: Path | None = None) -> Path: + """Where caches live -- the platform default, or a directory named by the caller. + + The override exists for a box with a dedicated cache volume, where the pool + belongs on that disk rather than on the one holding home. Whatever is chosen + has to be given to hyperi-rust-cache-prune as well, since it will not evict + a pool outside the root it was told about. + """ + if override is not None: + return override if family == "macos": return Path.home() / "Library" / "Caches" xdg = os.environ.get("XDG_CACHE_HOME") return Path(xdg) if xdg else Path.home() / ".cache" -def build_dir_path(family: str) -> Path: +def build_dir_path(family: str, override: Path | None = None) -> Path: """Where intermediate build artefacts are pooled, and what the prune tool bounds.""" - return cache_root(family) / "hyperi-rust-build" + return cache_root(family, override) / "hyperi-rust-build" def shell_env_file(family: str) -> Path: @@ -512,7 +520,9 @@ def install_cargo_sweep(dry_run: bool, rep: Reporter) -> None: # --------------------------------------------------------------------------- -def resolve_build_dir(family: str, wanted: bool, rep: Reporter) -> Path | None: +def resolve_build_dir( + family: str, wanted: bool, rep: Reporter, *, cache_root_override: Path | None = None +) -> Path | None: """Decide whether the central build directory can be configured on this box.""" if not wanted: return None @@ -525,7 +535,7 @@ def resolve_build_dir(family: str, wanted: bool, rep: Reporter) -> Path | None: have = ".".join(str(part) for part in version) rep.info(f"cargo {have} predates build-dir (needs {want}) -- keeping per-project target/") return None - return build_dir_path(family) + return build_dir_path(family, cache_root_override) def build_config( @@ -597,14 +607,21 @@ def build_config( def write_cargo_config( - family: str, dry_run: bool, rep: Reporter, *, central_build_dir: bool + family: str, + dry_run: bool, + rep: Reporter, + *, + central_build_dir: bool, + cache_root_override: Path | None = None, ) -> None: home = cargo_home() config = home / "config.toml" existing = config.read_text(encoding="utf-8") if config.is_file() else "" carried = carry_over_sections(existing, rep) if existing else Carried("", "") - build_dir = resolve_build_dir(family, central_build_dir, rep) + build_dir = resolve_build_dir( + family, central_build_dir, rep, cache_root_override=cache_root_override + ) content, managed = build_config(family, rep, build_dir=build_dir, carried=carried) if managed == 0: @@ -981,6 +998,17 @@ def main() -> int: "so the same source built under a different root still hits" ), ) + parser.add_argument( + "--cache-root", + type=Path, + default=None, + metavar="DIR", + help=( + "put the pooled build cache under this directory instead of the platform " + "cache dir -- for a box with a dedicated cache volume. Give the same " + "value to hyperi-rust-cache-prune" + ), + ) parser.add_argument( "--no-build-dir", dest="central_build_dir", @@ -1028,7 +1056,13 @@ def main() -> int: install_cargo_sweep(args.check, rep) rep.step("Global Cargo config") - write_cargo_config(family, args.check, rep, central_build_dir=args.central_build_dir) + write_cargo_config( + family, + args.check, + rep, + central_build_dir=args.central_build_dir, + cache_root_override=args.cache_root, + ) rep.step("Cache caps") write_env_caps( diff --git a/ansible/roles/developer-rust/tasks/rust.yml b/ansible/roles/developer-rust/tasks/rust.yml index 5d25c9b..e2534aa 100644 --- a/ansible/roles/developer-rust/tasks/rust.yml +++ b/ansible/roles/developer-rust/tasks/rust.yml @@ -515,6 +515,7 @@ --sccache-size {{ rust_cache_sccache_max }} --ccache-size {{ rust_cache_ccache_max }} --sccache-basedirs {{ rust_cache_sccache_basedirs | quote }} + {{ ('--cache-root ' ~ (rust_cache_root | quote)) if rust_cache_root else '' }} {{ '' if rust_cache_central_build_dir else '--no-build-dir' }} become: "{{ ansible_facts['distribution'] != 'MacOSX' }}" become_user: "{{ actual_user if ansible_facts['distribution'] != 'MacOSX' else omit }}" diff --git a/ansible/roles/developer-rust/templates/hyperi-rust-cache-prune-guard.service.j2 b/ansible/roles/developer-rust/templates/hyperi-rust-cache-prune-guard.service.j2 index 67a662e..d179598 100644 --- a/ansible/roles/developer-rust/templates/hyperi-rust-cache-prune-guard.service.j2 +++ b/ansible/roles/developer-rust/templates/hyperi-rust-cache-prune-guard.service.j2 @@ -6,7 +6,7 @@ Type=oneshot User={{ actual_user }} # Same prune with the same ceiling, gated on free space. Above the floor it # exits after one statvfs without walking the pool. -ExecStart=/usr/local/bin/hyperi-rust-cache-prune --yes --max-size {{ rust_cache_build_dir_max }} --max-age-days {{ rust_cache_max_age_days }} --if-free-below {{ rust_cache_prune_free_floor }} +ExecStart=/usr/local/bin/hyperi-rust-cache-prune --yes --max-size {{ rust_cache_build_dir_max }} --max-age-days {{ rust_cache_max_age_days }}{{ ' --cache-root ' ~ rust_cache_root if rust_cache_root else '' }} --if-free-below {{ rust_cache_prune_free_floor }} # Walking a large pool is IO-bound; it must never compete with an active build. Nice=19 IOSchedulingClass=idle diff --git a/ansible/roles/developer-rust/templates/hyperi-rust-cache-prune.service.j2 b/ansible/roles/developer-rust/templates/hyperi-rust-cache-prune.service.j2 index 46710e0..60e0c4b 100644 --- a/ansible/roles/developer-rust/templates/hyperi-rust-cache-prune.service.j2 +++ b/ansible/roles/developer-rust/templates/hyperi-rust-cache-prune.service.j2 @@ -5,7 +5,7 @@ Description=Bound the pooled Rust build cache Type=oneshot User={{ actual_user }} # Runs entirely inside the user's own cache directory, so it needs no sudo. -ExecStart=/usr/local/bin/hyperi-rust-cache-prune --yes --max-size {{ rust_cache_build_dir_max }} --max-age-days {{ rust_cache_max_age_days }} +ExecStart=/usr/local/bin/hyperi-rust-cache-prune --yes --max-size {{ rust_cache_build_dir_max }} --max-age-days {{ rust_cache_max_age_days }}{{ ' --cache-root ' ~ rust_cache_root if rust_cache_root else '' }} # Walking a large pool is IO-bound; it must never compete with an active build. Nice=19 IOSchedulingClass=idle diff --git a/ansible/roles/developer-rust/templates/io.hyperi.rust-cache-prune-guard.plist.j2 b/ansible/roles/developer-rust/templates/io.hyperi.rust-cache-prune-guard.plist.j2 index e985b41..a02341e 100644 --- a/ansible/roles/developer-rust/templates/io.hyperi.rust-cache-prune-guard.plist.j2 +++ b/ansible/roles/developer-rust/templates/io.hyperi.rust-cache-prune-guard.plist.j2 @@ -13,6 +13,10 @@ {{ rust_cache_build_dir_max }} --max-age-days {{ rust_cache_max_age_days }} +{% if rust_cache_root %} + --cache-root + {{ rust_cache_root }} +{% endif %} --if-free-below {{ rust_cache_prune_free_floor }} diff --git a/ansible/roles/developer-rust/templates/io.hyperi.rust-cache-prune.plist.j2 b/ansible/roles/developer-rust/templates/io.hyperi.rust-cache-prune.plist.j2 index 70f8f9b..4390e0f 100644 --- a/ansible/roles/developer-rust/templates/io.hyperi.rust-cache-prune.plist.j2 +++ b/ansible/roles/developer-rust/templates/io.hyperi.rust-cache-prune.plist.j2 @@ -14,6 +14,10 @@ {{ rust_cache_build_dir_max }} --max-age-days {{ rust_cache_max_age_days }} +{% if rust_cache_root %} + --cache-root + {{ rust_cache_root }} +{% endif %}