Files
Saadi 11013609b1 fix(freebsd-cache): fall back to a compatible cache when manifests move (#41)
The identity step hashed the caller's source hash into the cache prefix and
restore-keys only matched that prefix, so any revision that touched Cargo.toml,
Cargo.lock, build.rs, .cargo, rust-toolchain or packaging/ started from an
empty cache. Releases bump a version in the manifests, and PRs that change a
dependency do too. Issue #33 records the consequence on the aarch64 leg:
compiling ~260 dependency crates takes 4h32m under emulation, and 93 PR runs
were cancelled at the 300-minute limit with 0 passing.

Keep the exact key as the primary match and add a second restore key that pins
only what decides artifact compatibility: the guest toolchain and packages, the
serialized configuration and the profile. Cargo's fingerprints still reject
stale artifacts, so restoring a previous revision only saves work.

Bump the cache namespace to v3 because the key layout changed. Existing v2
entries are not readable through the new prefixes, so the first run after this
merge starts cold, as for any namespace bump.

Refs #33, #39
2026-10-22 15:00:48 -04:00

313 lines
19 KiB
Python

"""Execute the cache action's Bash using a fixture guest and real local rsync.
SSH and remote rsync paths are mapped to temporary directories. This checks the
actual transfer scripts without needing a FreeBSD host or the Actions service.
"""
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import unittest
import yaml
ROOT = Path(__file__).resolve().parents[1]
ACTION = yaml.load((ROOT / "actions/freebsd-cargo-cache/action.yml").read_text(), Loader=yaml.BaseLoader)
IDENTITY = next(step["run"] for step in ACTION["runs"]["steps"] if step.get("id") == "identity")
TRANSFER = next(step["run"] for step in ACTION["runs"]["steps"] if step.get("name") == "Transfer Cargo data across the VM boundary")
@unittest.skipUnless(shutil.which("rsync") and shutil.which("sha256sum"), "rsync and sha256sum required")
class CargoCacheTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory(prefix="freebsd-cache-test-")
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name).resolve()
self.guest = self.root / "guest"
self.cache = self.root / "home/.cache/craft-freebsd-cargo"
self.tools = self.root / "tools"
self.tools.mkdir()
(self.root / "home").mkdir()
self.env = dict(os.environ, PATH=str(self.tools) + os.pathsep + os.environ["PATH"],
HOME=str(self.root / "home"), RUNNER_TEMP=str(self.root / "host"),
FIXTURE_GUEST=str(self.guest), REAL_RSYNC=shutil.which("rsync"),
CACHE_PHASE="restore", CACHE_PROFILE="debug", CACHE_CAN_SAVE="true",
CACHE_CONFIGURATION='{"features":"heif","fonts":"abc"}',
CACHE_SOURCE_HASH="lock-and-build-config", GITHUB_SHA="commit1",
GITHUB_WORKSPACE=str(self.root),
GITHUB_OUTPUT=str(self.root / "outputs"), FIXTURE_COMPILER="rustc 1.99.1",
FIXTURE_ARCH="amd64", FIXTURE_PACKAGES="gtk3-1\nrust-1\n",
FIXTURE_FREEBSD="15.1-RELEASE")
self.tool("ssh", """import os, subprocess, sys
assert os.getcwd() == os.path.dirname(os.environ['FIXTURE_GUEST'])
command = sys.argv[2].replace('/var/tmp/', os.environ['FIXTURE_GUEST'] + '/var/tmp/')
login = os.environ.get('FIXTURE_LOGIN_SHELL', '/bin/sh')
flags = ['-f'] if login.endswith('csh') else []
script = sys.stdin.read().replace('/var/tmp/', os.environ['FIXTURE_GUEST'] + '/var/tmp/') if command == 'sh -eu' else None
sys.exit(subprocess.run([login, *flags, '-c', command], input=script, text=True).returncode)
""")
self.tool("rsync", """import os, subprocess, sys
with open(os.path.join(os.environ["HOME"], "rsync.log"), "a") as log:
log.write(" ".join(sys.argv[1:]) + "\\n")
args = []
items = iter(sys.argv[1:])
for arg in items:
if arg == '-e':
next(items)
elif arg.startswith('--rsync-path='):
continue
elif arg.startswith('freebsd:'):
args.append(os.environ['FIXTURE_GUEST'] + arg[len('freebsd:'):])
else:
args.append(arg)
sys.exit(subprocess.run([os.environ['REAL_RSYNC'], *args]).returncode)
""")
for name, variable in (("rustc", "FIXTURE_COMPILER"), ("uname", "FIXTURE_ARCH"),
("pkg", "FIXTURE_PACKAGES"), ("freebsd-version", "FIXTURE_FREEBSD")):
check_cwd = "assert os.getcwd() == os.environ['GITHUB_WORKSPACE']\n" if name == "rustc" else ""
self.tool(name, f"import os\n{check_cwd}print(os.environ[{variable!r}])\n")
def tool(self, name, script):
path = self.tools / name
path.write_text(f"#!{sys.executable}\n" + script)
path.chmod(0o755)
def run_script(self, script, **env):
return subprocess.run(["bash", "-c", script], cwd=self.root, env=dict(self.env, **env),
text=True, capture_output=True, timeout=20)
def transfer(self, phase, **env):
result = self.run_script(TRANSFER, CACHE_PHASE=phase, **env)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
def write(self, root, path, value):
file = root / path
file.parent.mkdir(parents=True, exist_ok=True)
file.write_text(value)
def identity(self, **env):
(self.root / "outputs").write_text("")
result = self.run_script(IDENTITY, **env)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
return dict(line.split("=", 1) for line in (self.root / "outputs").read_text().splitlines())
def test_key_separates_toolchain_arch_native_packages_features_fonts_lock_and_profile(self):
first = self.identity()
self.assertEqual(first, self.identity())
for env in ({"FIXTURE_COMPILER": "rustc 1.98.1"}, {"FIXTURE_ARCH": "aarch64"},
{"FIXTURE_PACKAGES": "gtk3-2\nrust-1\n"}, {"FIXTURE_FREEBSD": "15.0-RELEASE"},
{"CACHE_CONFIGURATION": '{"features":"","fonts":"abc"}'},
{"CACHE_CONFIGURATION": '{"features":"heif","fonts":"def"}'},
{"CACHE_SOURCE_HASH": "different-lock-or-build-script"}, {"CACHE_PROFILE": "release"}):
with self.subTest(env=env):
self.assertNotEqual(first["prefix"], self.identity(**env)["prefix"])
updated = self.identity(GITHUB_SHA="commit2")
self.assertEqual(first["prefix"], updated["prefix"])
self.assertNotEqual(first["key"], updated["key"])
def test_only_the_source_hash_pins_an_exact_revision(self):
first = self.identity()
self.assertTrue(first["key"].startswith(first["prefix"]))
self.assertTrue(first["prefix"].startswith(first["fallback-prefix"]))
# A revision whose manifests moved (every release bumps a version) still
# falls back to the previous revision's compiled dependencies.
changed = self.identity(CACHE_SOURCE_HASH="different-lock-or-build-script")
self.assertEqual(first["fallback-prefix"], changed["fallback-prefix"])
self.assertNotEqual(first["key"], changed["key"])
# Anything that changes artifact compatibility must not fall back.
for env in ({"FIXTURE_COMPILER": "rustc 1.98.1"}, {"FIXTURE_ARCH": "aarch64"},
{"FIXTURE_PACKAGES": "gtk3-2\nrust-1\n"}, {"FIXTURE_FREEBSD": "15.0-RELEASE"},
{"CACHE_CONFIGURATION": '{"features":"heif","fonts":"def"}'},
{"CACHE_PROFILE": "release"}):
with self.subTest(env=env):
self.assertNotEqual(first["fallback-prefix"], self.identity(**env)["fallback-prefix"])
def test_cold_restore_is_valid_and_warm_restore_loads_only_selected_profile(self):
self.transfer("restore")
self.write(self.cache, "registry/src/dependency/lib.rs", "source")
self.write(self.cache, "git/db/repository/objects/pack/data", "git-object")
self.write(self.cache, "target/debug/deps/libdependency.rlib", "dev-artifact")
self.write(self.cache, "target/release/deps/libdependency.rlib", "release-artifact")
self.transfer("restore")
self.assertEqual((self.guest / "var/tmp/craft-cargo/registry/src/dependency/lib.rs").read_text(), "source")
self.assertEqual((self.guest / "var/tmp/craft-target/debug/deps/libdependency.rlib").read_text(), "dev-artifact")
self.assertFalse((self.guest / "var/tmp/craft-target/release/deps/libdependency.rlib").exists())
def test_package_checkpoint_and_save_keep_both_profiles_without_credentials(self):
self.transfer("restore")
self.write(self.guest, "var/tmp/craft-target/debug/deps/test-app", "test-binary")
self.write(self.guest, "var/tmp/craft-target/debug/incremental/state", "discard")
self.write(self.guest, "var/tmp/craft-cargo/credentials.toml", "do-not-cache")
self.write(self.guest, "var/tmp/craft-cargo/config.toml", "do-not-cache")
self.write(self.guest, "var/tmp/craft-cargo/registry/cache/dependency.crate", "crate")
self.write(self.cache, "target/release/deps/libdependency.rlib", "cached-release")
self.transfer("package")
self.assertFalse((self.guest / "var/tmp/craft-target/debug").exists())
self.assertEqual((self.cache / "target/debug/deps/test-app").read_text(), "test-binary")
self.assertFalse((self.cache / "target/debug/incremental").exists())
self.assertEqual((self.guest / "var/tmp/craft-target/release/deps/libdependency.rlib").read_text(), "cached-release")
self.write(self.guest, "var/tmp/craft-target/release/app", "new-release")
self.transfer("save")
self.assertEqual((self.cache / "target/debug/deps/test-app").read_text(), "test-binary")
self.assertEqual((self.cache / "target/release/app").read_text(), "new-release")
self.assertEqual((self.cache / "registry/cache/dependency.crate").read_text(), "crate")
self.assertFalse(any("credentials" in str(f) or f.name == "config.toml" for f in self.cache.rglob("*")))
def test_release_profile_round_trip_preserves_compiled_output(self):
self.write(self.cache, "target/release/deps/libdependency.rlib", "cached-release")
self.transfer("restore", CACHE_PROFILE="release")
self.assertEqual((self.guest / "var/tmp/craft-target/release/deps/libdependency.rlib").read_text(), "cached-release")
self.write(self.guest, "var/tmp/craft-target/release/app", "release")
self.transfer("save", CACHE_PROFILE="release")
self.assertEqual((self.cache / "target/release/app").read_text(), "release")
def test_pr_never_copies_guest_content_back_to_the_cache(self):
self.transfer("restore")
self.write(self.guest, "var/tmp/craft-target/debug/deps/untrusted", "PR-content")
self.transfer("package", CACHE_CAN_SAVE="false")
self.transfer("save", CACHE_CAN_SAVE="false")
self.assertFalse((self.cache / "target/debug/deps/untrusted").exists())
save = next(step for step in ACTION["runs"]["steps"] if step.get("uses", "").startswith("actions/cache/save@"))
self.assertIn("github.event_name != 'pull_request'", save["if"])
self.assertIn("github.event_name != 'pull_request_target'", save["if"])
def test_reported_large_profiles_are_saved_and_restored_without_pruning(self):
# Represent profiles >2 GiB without allocating gigabytes in a unit test.
# A local size-pruning implementation would discard these components.
self.tool("du", "print('3145728\\tfixture')\n")
self.transfer("restore")
for profile in ("debug", "release"):
self.write(self.guest, f"var/tmp/craft-target/{profile}/deps/app", profile * 16384)
self.write(self.guest, "var/tmp/craft-cargo/registry/src/dependency/lib.rs", "source")
self.transfer("save", CACHE_MAX_KIB="2097152")
for profile in ("debug", "release"):
self.assertEqual((self.cache / f"target/{profile}/deps/app").read_text(), profile * 16384)
self.assertEqual((self.cache / "registry/src/dependency/lib.rs").read_text(), "source")
# Restore both profiles in separate invocations, as the real VM does.
shutil.rmtree(self.guest / "var/tmp/craft-target")
self.transfer("restore", CACHE_MAX_KIB="2097152")
self.assertTrue((self.guest / "var/tmp/craft-target/debug/deps/app").is_file())
self.transfer("restore", CACHE_PROFILE="release", CACHE_MAX_KIB="2097152")
self.assertTrue((self.guest / "var/tmp/craft-target/release/deps/app").is_file())
self.assertTrue((self.cache / "target/debug/deps/app").is_file())
def test_failed_export_discards_partial_host_profile(self):
self.transfer("restore")
self.write(self.guest, "var/tmp/craft-target/debug/deps/app", "built")
self.tool("rsync", "import pathlib, sys\np = pathlib.Path(sys.argv[-1])\np.mkdir(parents=True, exist_ok=True)\n(p / 'partial').write_text('incomplete')\nsys.exit(23)\n")
result = self.run_script(TRANSFER, CACHE_PHASE="package")
self.assertEqual(result.returncode, 23, result)
self.assertFalse((self.cache / "target/debug").exists())
def test_save_without_registry_or_git_still_exports_compiled_profile(self):
self.write(self.guest, "var/tmp/craft-target/release/app", "built")
self.transfer("save", CACHE_PROFILE="release")
self.assertEqual((self.cache / "target/release/app").read_text(), "built")
@unittest.skipUnless(shutil.which("csh") or shutil.which("tcsh"), "csh/tcsh required")
def test_csh_login_shell_supports_identity_checkpoint_and_save(self):
self.env["FIXTURE_LOGIN_SHELL"] = shutil.which("csh") or shutil.which("tcsh")
self.assertTrue(self.identity()["key"])
self.test_package_checkpoint_and_save_keep_both_profiles_without_credentials()
def test_incremental_dependency_sources_survive_but_profile_state_is_excluded(self):
self.transfer("restore")
paths = ("registry/src/crate/incremental/lib.rs", "git/checkouts/repo/incremental/lib.rs")
for path in paths:
self.write(self.guest, "var/tmp/craft-cargo/" + path, "source")
self.write(self.guest, "var/tmp/craft-target/debug/incremental/state", "discard")
self.write(self.guest, "var/tmp/craft-target/debug/build/dependency/incremental/lib.rs", "keep")
self.transfer("save")
for path in paths:
self.assertEqual((self.cache / path).read_text(), "source")
self.assertFalse((self.cache / "target/debug/incremental").exists())
self.assertEqual((self.cache / "target/debug/build/dependency/incremental/lib.rs").read_text(), "keep")
def test_untouched_empty_guest_profile_preserves_host_artifacts(self):
self.write(self.cache, "target/release/app", "cached-release")
self.transfer("restore")
self.assertFalse(any((self.guest / "var/tmp/craft-target/release").iterdir()))
self.transfer("save")
self.assertEqual((self.cache / "target/release/app").read_text(), "cached-release")
def test_transport_failure_is_visible(self):
self.tool("rsync", "import sys\nsys.exit(23)\n")
result = self.run_script(TRANSFER)
self.assertEqual(result.returncode, 23, result)
class WorkflowCacheIntegrationTests(unittest.TestCase):
def test_restore_tries_the_exact_revision_then_a_compatible_one(self):
restore = next(step for step in ACTION["runs"]["steps"]
if step.get("uses", "").startswith("actions/cache/restore@"))
keys = [line.strip() for line in restore["with"]["restore-keys"].splitlines() if line.strip()]
self.assertEqual(keys, ["${{ steps.identity.outputs.prefix }}",
"${{ steps.identity.outputs.fallback-prefix }}"])
save = next(step for step in ACTION["runs"]["steps"]
if step.get("uses", "").startswith("actions/cache/save@"))
# Saving always writes the exact-revision entry the caller passed back in.
self.assertEqual(save["with"]["key"], "${{ inputs.key }}")
def test_host_cache_stays_outside_the_vm_shells_synced_work_tree(self):
# vmactions syncs ~/work, not just GITHUB_WORKSPACE; RUNNER_TEMP lives
# inside it, so placing a cache there would double-copy the artifacts.
for step in ACTION["runs"]["steps"]:
if step.get("uses", "").startswith("actions/cache/"):
self.assertEqual(step["with"]["path"], "~/.cache/craft-freebsd-cargo")
self.assertIn('cache="$HOME/.cache/craft-freebsd-cargo"', TRANSFER)
def test_rustup_installs_outside_the_forwarded_cargo_data_directory(self):
for filename in ("freebsd.yml", "release.yml"):
with self.subTest(workflow=filename):
data = yaml.load((ROOT / ".github/workflows" / filename).read_text(), Loader=yaml.BaseLoader)
vm = next(step["with"] for step in data["jobs"]["freebsd"]["steps"] if "prepare" in step.get("with", {}))
before_install = vm["prepare"].replace("${{ matrix.arch }}", "x86_64").split("pkg install", 1)[0]
result = subprocess.run(["sh", "-eu", "-c", before_install + '\ntest "${CARGO_HOME+x}" != x\ntest "${CARGO_TARGET_DIR+x}" != x'],
env=dict(os.environ, CARGO_HOME="/var/tmp/craft-cargo", CARGO_TARGET_DIR="/var/tmp/craft-target"),
text=True, capture_output=True, timeout=10)
self.assertEqual(result.returncode, 0, result.stderr)
def test_both_workflows_restore_before_build_save_after_and_keep_release_coverage(self):
for filename in ("freebsd.yml", "release.yml"):
with self.subTest(workflow=filename):
data = yaml.load((ROOT / ".github/workflows" / filename).read_text(), Loader=yaml.BaseLoader)
job = data["jobs"]["freebsd"]
steps = job["steps"]
index = lambda name: next(i for i, step in enumerate(steps) if step.get("name") == name)
vm = next(step for step in steps if step.get("uses", "").startswith("vmactions/freebsd-vm@"))
self.assertEqual(vm["with"]["copyback"], "false")
for variable in ("CARGO_HOME", "CARGO_TARGET_DIR", "CARGO_INCREMENTAL", "CARGO_BUILD_JOBS"):
self.assertIn(variable, vm["with"]["envs"].split())
cache_steps = [step for step in steps if "/freebsd-cargo-cache@" in step.get("uses", "")]
restore = next(step for step in cache_steps if step.get("id") == "cargo-cache")
save = next(step for step in cache_steps if step["with"].get("phase") == "save")
build = next(step for step in steps if step.get("shell") == "freebsd {0}" and ".cargo/env" in step["run"])
self.assertLess(steps.index(vm), steps.index(restore))
self.assertLess(steps.index(restore), steps.index(build))
self.assertLess(steps.index(build), steps.index(save))
self.assertNotIn("always()", save["if"])
for cache_step in cache_steps:
self.assertEqual(cache_step.get("continue-on-error"), "true")
self.assertEqual(cache_step.get("timeout-minutes"), "10")
self.assertIn("steps.cargo-cache.outputs.key != ''", save["if"])
self.assertIn("steps.cargo-cache.outcome", build["run"])
self.assertIn("pull_request", save["if"])
self.assertIn("toJSON(inputs)", restore["with"]["configuration"])
if filename == "release.yml":
self.assertEqual(job["strategy"]["matrix"]["arch"], "${{ fromJSON(inputs.freebsd-architectures) }}")
self.assertEqual(restore["with"]["profile"], "release")
self.assertNotIn('rm -rf "dist/release/$name" /var/tmp/craft-target', build["run"])
self.assertLess(index("Build and package"), index("Copy release archives from the VM"))
upload = next(step for step in steps if step.get("uses", "").startswith("actions/upload-artifact@"))
self.assertLess(index("Copy release archives from the VM"), steps.index(upload))
self.assertLess(steps.index(upload), steps.index(save))
self.assertTrue(any(step.get("uses", "").startswith("actions/upload-artifact@") for step in steps))
if __name__ == "__main__":
unittest.main()