Back to home page

EIC code displayed by LXR

 
 

    


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         # Deliberately small pages exercise pagination with ordinary fixtures.
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     # Transfer must work on hosts without ccache or other build tools.
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             # A completed upload after listing must not become a candidate.
0289             client.objects[VARIANT + "snapshots/3-1-" + "a" * 32 + ".tar"] = b"new"
0290         if reads == 2:
0291             # Overlapping publishers can move the pointer backwards, too.
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