File indexing completed on 2026-10-16 08:46:36
0001 """Exercise snapshot replacement and restoration without remote credentials."""
0002
0003 import io
0004 import json
0005 from pathlib import Path
0006 import sys
0007 import tarfile
0008
0009 import pytest
0010
0011 import ccache_snapshot as snapshot
0012
0013
0014 class MissingObject(Exception):
0015 response = {"Error": {"Code": "NoSuchKey"}}
0016
0017
0018 class FakeS3:
0019 def __init__(self):
0020 self.objects = {}
0021 self.writes = []
0022 self.fail_upload = False
0023
0024 def get_paginator(self, operation):
0025 assert operation == "list_objects_v2"
0026 return self
0027
0028 def paginate(self, *, Bucket, Prefix):
0029 objects = [
0030 {"Key": key, "Size": len(value)}
0031 for key, value in self.objects.items()
0032 if key.startswith(Prefix)
0033 ]
0034
0035 for offset in range(0, len(objects), 2):
0036 yield {"Contents": objects[offset : offset + 2]}
0037
0038 def delete_objects(self, *, Bucket, Delete):
0039 for item in Delete["Objects"]:
0040 del self.objects[item["Key"]]
0041 return {"Deleted": Delete["Objects"]}
0042
0043 def get_object(self, *, Bucket, Key):
0044 if Key not in self.objects:
0045 raise MissingObject()
0046 return {"Body": io.BytesIO(self.objects[Key])}
0047
0048 def download_file(self, bucket, key, filename):
0049 Path(filename).write_bytes(self.objects[key])
0050
0051 def upload_file(self, filename, bucket, key):
0052 if self.fail_upload:
0053 raise OSError("Simulated upload failure")
0054 self.writes.append(key)
0055 self.objects[key] = Path(filename).read_bytes()
0056
0057 def put_object(self, *, Bucket, Key, Body, **kwargs):
0058 self.writes.append(Key)
0059 self.objects[Key] = Body
0060
0061
0062 @pytest.fixture
0063 def client(monkeypatch):
0064
0065 monkeypatch.setenv("PATH", "")
0066 monkeypatch.delenv("GITHUB_STEP_SUMMARY", raising=False)
0067 return FakeS3()
0068
0069
0070 @pytest.fixture
0071 def cache(tmp_path):
0072 directory = tmp_path / "cache"
0073 (directory / "a" / "b").mkdir(parents=True)
0074 (directory / "a" / "b" / "manifestM").write_bytes(b"manifest")
0075 (directory / "a" / "b" / "resultR").write_bytes(b"compiled output")
0076 return directory
0077
0078
0079 def publish(client, cache, run=10, attempt=1):
0080 snapshot.publish(client, "cache", "pilot/", cache, run, 1234, attempt)
0081
0082
0083 def test_round_trip_preserves_manifests_and_results(client, cache, tmp_path):
0084 publish(client, cache)
0085 manifest = snapshot.read_manifest(client, "cache", "pilot/")
0086 assert client.writes == [manifest["key"], "pilot/latest.json"]
0087 restored = tmp_path / "restored"
0088 snapshot.restore(client, "cache", "pilot/", restored)
0089 for filename in ("manifestM", "resultR"):
0090 assert (restored / "a/b" / filename).read_bytes() == (
0091 cache / "a/b" / filename
0092 ).read_bytes()
0093
0094
0095 def test_missing_snapshot_starts_empty(client, tmp_path):
0096 restored = tmp_path / "restored"
0097 snapshot.restore(client, "cache", "pilot/", restored)
0098 assert list(restored.iterdir()) == []
0099
0100
0101 def test_checksum_failure_leaves_cache_empty(client, cache, tmp_path):
0102 publish(client, cache)
0103 manifest = snapshot.read_manifest(client, "cache", "pilot/")
0104 client.objects[manifest["key"]] += b"corruption"
0105 restored = tmp_path / "restored"
0106 with pytest.raises(ValueError, match="checksum"):
0107 snapshot.restore(client, "cache", "pilot/", restored)
0108 assert list(restored.iterdir()) == []
0109
0110
0111 @pytest.mark.parametrize("entry", ["../escape", "/absolute", "symlink", "hardlink"])
0112 def test_unsafe_archive_is_never_installed(client, cache, tmp_path, entry):
0113 publish(client, cache)
0114 archive = tmp_path / "bad.tar"
0115 with tarfile.open(archive, "w:") as output:
0116 output.addfile(tarfile.TarInfo("safe-first"), io.BytesIO())
0117 member = tarfile.TarInfo(entry)
0118 if entry in ("symlink", "hardlink"):
0119 member.type = tarfile.SYMTYPE if entry == "symlink" else tarfile.LNKTYPE
0120 member.linkname = "../escape"
0121 output.addfile(member, io.BytesIO())
0122 manifest = snapshot.read_manifest(client, "cache", "pilot/")
0123 client.objects[manifest["key"]] = archive.read_bytes()
0124 manifest["sha256"] = snapshot.digest(archive)
0125 client.objects["pilot/latest.json"] = json.dumps(manifest).encode()
0126 restored = tmp_path / "restored"
0127 with pytest.raises(ValueError, match="Unsafe"):
0128 snapshot.restore(client, "cache", "pilot/", restored)
0129 assert list(restored.iterdir()) == []
0130 assert not (tmp_path / "escape").exists()
0131
0132
0133 def test_restore_does_not_discard_existing_cache(client, cache):
0134 with pytest.raises(ValueError, match="empty"):
0135 snapshot.restore(client, "cache", "pilot/", cache)
0136 assert (cache / "a/b/manifestM").read_bytes() == b"manifest"
0137
0138
0139 def test_failed_upload_preserves_pointer(client, cache):
0140 publish(client, cache)
0141 previous = client.objects["pilot/latest.json"]
0142 client.fail_upload = True
0143 with pytest.raises(OSError):
0144 publish(client, cache, run=11)
0145 assert client.objects["pilot/latest.json"] == previous
0146
0147
0148 @pytest.mark.parametrize("run,attempt", [(9, 5), (10, 1)])
0149 def test_old_or_duplicate_run_skips_already_published_snapshot(
0150 client, cache, run, attempt
0151 ):
0152 publish(client, cache)
0153 writes = client.writes.copy()
0154 publish(client, cache, run, attempt)
0155 assert client.writes == writes
0156
0157
0158 def test_new_attempt_replaces_snapshot(client, cache):
0159 publish(client, cache)
0160 previous = snapshot.read_manifest(client, "cache", "pilot/")
0161 publish(client, cache, attempt=2)
0162 current = snapshot.read_manifest(client, "cache", "pilot/")
0163 assert current["run_attempt"] == 2
0164 assert current["key"] != previous["key"]
0165 assert previous["key"] in client.objects
0166
0167
0168 def test_access_denied_is_not_treated_as_missing(client, monkeypatch):
0169 class Denied(Exception):
0170 response = {"Error": {"Code": "AccessDenied"}}
0171
0172 def denied(**kwargs):
0173 raise Denied()
0174
0175 monkeypatch.setattr(client, "get_object", denied)
0176 with pytest.raises(Denied):
0177 snapshot.read_manifest(client, "cache", "pilot/")
0178
0179
0180 def test_manifest_cannot_reference_another_namespace(client, cache):
0181 publish(client, cache)
0182 manifest = snapshot.read_manifest(client, "cache", "pilot/")
0183 manifest["key"] = manifest["key"].replace("pilot/", "another/")
0184 client.objects["pilot/latest.json"] = json.dumps(manifest).encode()
0185 with pytest.raises(ValueError, match="Invalid"):
0186 snapshot.read_manifest(client, "cache", "pilot/")
0187
0188
0189 @pytest.mark.parametrize(
0190 "event,ref,opt_in,allowed",
0191 [
0192 ("push", "refs/heads/main", "false", True),
0193 ("push", "refs/heads/feature", "true", False),
0194 ("pull_request", "refs/heads/main", "true", False),
0195 ("merge_group", "refs/heads/main", "true", False),
0196 ("schedule", "refs/heads/main", "false", False),
0197 ("schedule", "refs/heads/main", "true", True),
0198 ("workflow_dispatch", "refs/heads/main", "false", False),
0199 ("workflow_dispatch", "refs/heads/main", "true", True),
0200 ("workflow_dispatch", "refs/heads/feature", "true", False),
0201 ],
0202 )
0203 def test_publication_policy(monkeypatch, event, ref, opt_in, allowed):
0204 monkeypatch.setenv("GITHUB_EVENT_NAME", event)
0205 monkeypatch.setenv("GITHUB_REF", ref)
0206 monkeypatch.setenv("CACHE_ALLOW_MAIN_NON_PUSH", opt_in)
0207 monkeypatch.setattr(sys, "argv", ["snapshot", "publish"])
0208 for key, value in {
0209 "CACHE_BUCKET": "cache",
0210 "CACHE_PREFIX": "pilot/",
0211 "CCACHE_DIR": "/unused",
0212 "GITHUB_RUN_NUMBER": "1",
0213 "GITHUB_RUN_ID": "1",
0214 "GITHUB_RUN_ATTEMPT": "1",
0215 }.items():
0216 monkeypatch.setenv(key, value)
0217 connected, published = [], []
0218 monkeypatch.setattr(
0219 snapshot, "make_client", lambda writable: connected.append(writable)
0220 )
0221 monkeypatch.setattr(snapshot, "publish", lambda *args: published.append(args))
0222 assert snapshot.main() == 0
0223 assert bool(connected) == allowed
0224 assert bool(published) == allowed
0225
0226
0227 def test_cache_failure_does_not_fail_build(monkeypatch):
0228 def unavailable(*args):
0229 raise OSError("Unavailable")
0230
0231 monkeypatch.setattr(snapshot, "make_client", unavailable)
0232 monkeypatch.setattr(sys, "argv", ["snapshot", "restore"])
0233 assert snapshot.main() == 0
0234
0235
0236 ROOT = "acts-sccache/ccache-snapshots/acts-project/acts/"
0237 VARIANT = ROOT + "builds/linux/v1/"
0238
0239
0240 def seed_snapshots(client, cache, variant=VARIANT):
0241 snapshot.publish(client, "cache", variant, cache, 1, 1, 1)
0242 old = snapshot.read_manifest(client, "cache", variant)["key"]
0243 snapshot.publish(client, "cache", variant, cache, 2, 2, 1)
0244 latest = snapshot.read_manifest(client, "cache", variant)["key"]
0245 return old, latest
0246
0247
0248 @pytest.mark.parametrize("dry_run", [False, True])
0249 def test_prune_preserves_latest_and_unrelated_objects(client, cache, dry_run):
0250 old, latest = seed_snapshots(client, cache)
0251 other_old, other_latest = seed_snapshots(client, cache, ROOT + "macos/v1/")
0252 client.objects[ROOT + "builds/linux/v1/snapshots/unrecognized.tar"] = b"keep"
0253 client.objects["another-repository/snapshots/archive.tar"] = b"keep"
0254 before = client.objects.copy()
0255 snapshot.prune(client, "cache", ROOT, dry_run)
0256 expected = before.copy()
0257 if not dry_run:
0258 del expected[old]
0259 del expected[other_old]
0260 assert client.objects == expected
0261 assert latest in client.objects and other_latest in client.objects
0262
0263
0264 @pytest.mark.parametrize("manifest", [None, b"invalid json"])
0265 def test_prune_missing_or_invalid_pointer_preserves_variant(client, cache, manifest):
0266 seed_snapshots(client, cache)
0267 if manifest is None:
0268 del client.objects[VARIANT + "latest.json"]
0269 else:
0270 client.objects[VARIANT + "latest.json"] = manifest
0271 before = client.objects.copy()
0272 with pytest.raises(RuntimeError):
0273 snapshot.prune(client, "cache", ROOT)
0274 assert client.objects == before
0275
0276
0277 def test_prune_rechecks_pointer_and_excludes_later_uploads(client, cache, monkeypatch):
0278 old, latest = seed_snapshots(client, cache)
0279 previous = json.loads(client.objects[VARIANT + "latest.json"])
0280 previous["key"] = old
0281 get = client.get_object
0282 reads = 0
0283
0284 def concurrent_publish(**kwargs):
0285 nonlocal reads
0286 reads += 1
0287 if reads == 1:
0288
0289 client.objects[VARIANT + "snapshots/3-1-" + "a" * 32 + ".tar"] = b"new"
0290 if reads == 2:
0291
0292 client.objects[VARIANT + "latest.json"] = json.dumps(previous).encode()
0293 return get(**kwargs)
0294
0295 monkeypatch.setattr(client, "get_object", concurrent_publish)
0296 before = set(client.objects)
0297 snapshot.prune(client, "cache", ROOT)
0298 assert before <= set(client.objects)
0299 assert len(client.objects) == len(before) + 1
0300 assert old in client.objects and latest in client.objects
0301
0302
0303 def test_prune_batches_deletions(client, cache, monkeypatch):
0304 _, latest = seed_snapshots(client, cache)
0305 for number in range(1005):
0306 client.objects[VARIANT + f"snapshots/3-1-{number:032x}.tar"] = b"old"
0307 delete = client.delete_objects
0308 batches = []
0309
0310 def limited_delete(**kwargs):
0311 batches.append(len(kwargs["Delete"]["Objects"]))
0312 assert batches[-1] <= 1000
0313 return delete(**kwargs)
0314
0315 monkeypatch.setattr(client, "delete_objects", limited_delete)
0316 snapshot.prune(client, "cache", ROOT)
0317 assert len(batches) == 2
0318 assert set(client.objects) == {latest, VARIANT + "latest.json"}
0319
0320
0321 def test_prune_partial_deletion_is_failure(client, cache, monkeypatch, capsys):
0322 old, latest = seed_snapshots(client, cache)
0323
0324 def denied(**kwargs):
0325 return {"Errors": [{"Key": old, "Code": "AccessDenied"}]}
0326
0327 monkeypatch.setattr(client, "delete_objects", denied)
0328 with pytest.raises(RuntimeError):
0329 snapshot.prune(client, "cache", ROOT)
0330 assert old in client.objects and latest in client.objects
0331 assert "deleted 0 archives" in capsys.readouterr().out
0332
0333
0334 @pytest.mark.parametrize(
0335 "repository,ref,event,prefix,allowed",
0336 [
0337 ("acts-project/acts", "refs/heads/main", "schedule", ROOT, True),
0338 ("acts-project/acts", "refs/heads/main", "workflow_dispatch", ROOT, True),
0339 ("acts-project/acts", "refs/heads/main", "push", ROOT, False),
0340 ("acts-project/acts", "refs/heads/main", "pull_request", ROOT, False),
0341 ("acts-project/acts", "refs/heads/feature", "workflow_dispatch", ROOT, False),
0342 ("fork/acts", "refs/heads/main", "schedule", ROOT, False),
0343 ("acts-project/acts", "refs/heads/main", "schedule", "acts-sccache/", False),
0344 ],
0345 )
0346 def test_prune_policy(monkeypatch, repository, ref, event, prefix, allowed):
0347 for key, value in {
0348 "GITHUB_REPOSITORY": repository,
0349 "GITHUB_REF": ref,
0350 "GITHUB_EVENT_NAME": event,
0351 "CACHE_PREFIX": prefix,
0352 "CACHE_BUCKET": "cache",
0353 }.items():
0354 monkeypatch.setenv(key, value)
0355 monkeypatch.setattr(sys, "argv", ["snapshot", "prune", "--dry-run"])
0356 connected, pruned = [], []
0357 monkeypatch.setattr(
0358 snapshot, "make_client", lambda writable: connected.append(writable)
0359 )
0360 monkeypatch.setattr(snapshot, "prune", lambda *args: pruned.append(args))
0361 assert snapshot.main() == (0 if allowed else 1)
0362 assert bool(connected) == allowed
0363 assert bool(pruned) == allowed
0364 if allowed:
0365 assert pruned[0][-1] is True
0366
0367
0368 def test_prune_failure_fails_job(monkeypatch):
0369 for key, value in {
0370 "GITHUB_REPOSITORY": "acts-project/acts",
0371 "GITHUB_REF": "refs/heads/main",
0372 "GITHUB_EVENT_NAME": "schedule",
0373 "CACHE_PREFIX": ROOT,
0374 }.items():
0375 monkeypatch.setenv(key, value)
0376
0377 def unavailable(*args):
0378 raise OSError("Unavailable")
0379
0380 monkeypatch.setattr(snapshot, "make_client", unavailable)
0381 monkeypatch.setattr(sys, "argv", ["snapshot", "prune"])
0382 assert snapshot.main() == 1