diff --git a/experiments/perception/worker/observatory_portable/migrate_claim_transport_v3.py b/experiments/perception/worker/observatory_portable/migrate_claim_transport_v3.py index 9c1deaf..8b333f3 100644 --- a/experiments/perception/worker/observatory_portable/migrate_claim_transport_v3.py +++ b/experiments/perception/worker/observatory_portable/migrate_claim_transport_v3.py @@ -234,9 +234,15 @@ def build_transport(engine: Engine, name: str, parent: str, script_sha: str) -> "name": name + "-claim-v3-layer", } ), - {"Image": "sha256:" + parent, "HostConfig": {"NetworkMode": "none"}}, + {"Image": "sha256:" + parent, "Entrypoint": ["/bin/true"], "Cmd": [], + "HostConfig": {"NetworkMode": "none", "CapDrop": ["ALL"], "PidsLimit": 32, + "SecurityOpt": ["no-new-privileges"]}}, )["Id"] try: + # Initialize the layer with a network-free CPU no-op, never the agent CMD. + engine.request("POST", f"/containers/{temporary}/start") + if engine.request("POST", f"/containers/{temporary}/wait")["StatusCode"] != 0: + raise RuntimeError("offline image-layer initialization failed") archive = engine.request( "GET", f"/containers/{temporary}/archive?" + urlencode({"path": SOURCE}), raw=True ) @@ -249,6 +255,9 @@ def build_transport(engine: Engine, name: str, parent: str, script_sha: str) -> replacement = patch_source(original) member.name = Path(SOURCE).name member.size = len(replacement) + # Same-length v2 -> v3 bytes must not retain the original file timestamp: + # metadata-based snapshotters can otherwise omit the changed content. + member.mtime = max(int(time.time()), int(member.mtime) + 1) member.pax_headers = {} data = io.BytesIO() with tarfile.open(fileobj=data, mode="w") as output: @@ -266,7 +275,9 @@ def build_transport(engine: Engine, name: str, parent: str, script_sha: str) -> changes = engine.request("GET", f"/containers/{temporary}/changes") allowed = {str(path) for path in Path(SOURCE).parents} | {SOURCE} if not changes or any(item["Kind"] != 0 or item["Path"] not in allowed for item in changes): - raise ValueError("transport layer changed files outside the reviewed source") + raise ValueError( + f"transport layer changed files outside the reviewed source: {changes!r}" + ) original_image = engine.request("GET", f"/images/sha256:{parent}/json") config = copy.deepcopy(original_image["Config"]) config.setdefault("Labels", {}).update( diff --git a/tests/test_observatory_claim_transport_migration.py b/tests/test_observatory_claim_transport_migration.py index 3a6fbe4..e450645 100644 --- a/tests/test_observatory_claim_transport_migration.py +++ b/tests/test_observatory_claim_transport_migration.py @@ -2,7 +2,9 @@ from __future__ import annotations import copy import importlib.util +import io import json +import tarfile from pathlib import Path import pytest @@ -127,3 +129,53 @@ def test_changed_plan_cannot_write_evidence_or_touch_containers( with pytest.raises(ValueError, match="plan changed"): migration.apply(object(), "0" * 64, evidence) assert not evidence.exists() + + +def test_image_layer_refreshes_mtime_and_retains_exact_parent(monkeypatch) -> None: + source = b'PROTOCOL = "missioncore.observatory-worker-claim-request/v2"\n' + monkeypatch.setattr(migration, "BEFORE_SHA", migration.sha(source)) + archive = io.BytesIO() + with tarfile.open(fileobj=archive, mode="w") as output: + member = tarfile.TarInfo("worker_http_transport.py") + member.size = len(source) + member.mtime = 123 + output.addfile(member, io.BytesIO(source)) + + class FakeEngine: + removed = False + + def request(self, method, path, body=None, *, raw=False): + if path.startswith("/containers/create"): + assert body["Entrypoint"] == ["/bin/true"] + assert body["HostConfig"]["NetworkMode"] == "none" + return {"Id": "temporary"} + if path.endswith("/start"): + return None + if path.endswith("/wait"): + return {"StatusCode": 0} + if "/archive?" in path: + if method == "GET": + return archive.getvalue() + with tarfile.open(fileobj=io.BytesIO(body)) as uploaded: + patched = uploaded.getmembers()[0] + assert patched.mtime > 123 + assert uploaded.extractfile(patched).read() == source.replace( + migration.OLD, migration.NEW + ) + return None + if path.endswith("/changes"): + return [{"Kind": 0, "Path": migration.SOURCE}] + if path == "/images/sha256:parent/json": + return {"Config": {"Labels": {}}, "RootFS": {"Layers": ["base"]}} + if path.startswith("/commit?"): + assert body["Labels"][migration.PARENT_LABEL] == "parent" + return {"Id": "sha256:new"} + if path == "/images/sha256:new/json": + return {"RootFS": {"Layers": ["base", "transport-only"]}} + assert method == "DELETE" and path == "/containers/temporary" + self.removed = True + + engine = FakeEngine() + result = migration.build_transport(engine, "ndc-test-agent", "parent", "script") + assert result["image"] == "sha256:new" + assert engine.removed