From 21479340f32dc4e3421ff3f8502251c55934fa1e Mon Sep 17 00:00:00 2001 From: Ian Eldred Pudney Date: Thu, 1 Oct 2026 13:44:12 -0700 Subject: [PATCH] fix(gate): repair three latent failures in the Linux-native deb proof The exact Debian package proof (DebProof) only runs on a native Linux host with /dev/kvm and /dev/vhost-vsock. CI runners lack vhost-vsock (the proof selector skips it) and macOS release machines cross-compile host!=target (skipped too), so this rail had never executed end-to-end on a real machine until now. Running `capsem-gate cross-compile x86_64` on a regular machine surfaced three independent bugs, each of which failed the gate: 1. Staging ownership: the sealed install-test image excludes /cache via .dockerignore, so Docker materializes the mountpoint chain /src/cache/target for the read-only packages mount as root. Staging then runs as the container user `capsem` (uid 1000) and dies with `mkdir: cannot create directory 'cache/target/tests': Permission denied` on any host whose uid is not 1000. InstallContainer solved exactly this with _claim_paths() after await_systemd; DebProof never did. Fix: give DebProof the same _claim_paths() (root mkdir -p + chown -R of layout.owned_paths and owned_parent_paths), called right after systemd is ready. 2. Authored-graph path: `capsem-admin assets channel build --channel X` writes /assets/X/manifest.json, but ReleaseGraph's build_channel() returned the hardcoded config constant graph_manifest = "assets/local/manifest.json". The paths only coincide for the install gate's channel "local"; DebProof authors the package channel (default "stable", and its own validation refuses "local"), so record-binary immediately failed with `read .../assets/local/manifest.json: No such file or directory`. Fix: build_channel() now derives the authored path from the channel it just built (assets//), and hand_off() takes the channel and validates against the same channel-aware location. Behavior for the install gate (channel=local) is unchanged. 3. Split provenance in the installed-release verification: for a preverified payload the postinst deliberately keeps the package's baked manifest-metadata (update_status_refresh_skipped reason=preverified_install_payload) while the bytes are hydrated from the proof's authored handoff channel. verify-installed-release models exactly this split via --metadata-manifest-url (the release probe already passes it), but DebProof._verify_release only passed --manifest-url, so the proof failed comparing the metadata URL against the handoff URL. Fix: pass --metadata-manifest-url with the baked URL alongside --manifest-url with the handoff. Tests: the four gate tests that pinned the buggy constants are updated to pin the fixed behavior (channel-aware authored path; hand_off's channel argument). 53/53 pass in tests/gate/test_gate_debproof.py + test_gate_install_ordering.py. With these three fixes the full cross-compile gate passes on a native Linux+KVM host, including dpkg install inside the sealed systemd container, release verification, and the guest shell boot proof. --- build_system/builder/gate/debproof.py | 24 +++++++++++++++++++ build_system/builder/gate/releasegraph.py | 23 ++++++++++++++---- build_system/tests/gate/test_gate_debproof.py | 4 ++-- .../tests/gate/test_gate_install_ordering.py | 4 ++-- 4 files changed, 47 insertions(+), 8 deletions(-) diff --git a/build_system/builder/gate/debproof.py b/build_system/builder/gate/debproof.py index c9ec8616e..280220e6b 100644 --- a/build_system/builder/gate/debproof.py +++ b/build_system/builder/gate/debproof.py @@ -164,6 +164,25 @@ def _start(self, runtime: VmDeviceRuntime) -> None: interval=self._install.systemd_ready_interval_seconds, sleep=self._sleep, ) + self._claim_paths() + + def _claim_paths(self) -> None: + """Create and own the writable staging layout before staging writes to it. + + The sealed image dockerignores `cache/`, so the only `/src/cache` in + this container is the mountpoint chain Docker materialized as root for + the read-only package mount. On a host whose uid is not the guest's, + staging as the guest then dies at `mkdir cache/target/tests` -- the + exact shape `InstallContainer._claim_paths` already repairs for the + install gate, applied here to the proof container it never covered. + """ + guest = self._install.guest_user.name + owned = self._install.layout.owned_paths(self._install.mount) + self._docker.exec(self._proof.container, ["mkdir", "-p", *owned]) + self._docker.exec(self._proof.container, ["chown", "-R", f"{guest}:{guest}", *owned]) + parents = self._install.layout.owned_parent_paths(self._install.mount) + self._docker.exec(self._proof.container, ["chown", f"{guest}:{guest}", *parents]) + def _prepare_handoff(self, package: str, version: str) -> None: """Author the exact local graph before the package's postinst runs.""" layout = self._install.layout @@ -262,6 +281,11 @@ def _verify_release(self, expected: str) -> None: f"{guest.home}/{self._install.capsem_home}", "--manifest-url", manifest, + # The handoff hydrated the bytes; the baked URL is what the + # postinst preserves in manifest-metadata for a preverified + # payload. The verifier models exactly this split. + "--metadata-manifest-url", + self.manifest_url, "--channel", self.channel, "--package-version", diff --git a/build_system/builder/gate/releasegraph.py b/build_system/builder/gate/releasegraph.py index f7f0f58ad..40cfb7862 100644 --- a/build_system/builder/gate/releasegraph.py +++ b/build_system/builder/gate/releasegraph.py @@ -29,6 +29,8 @@ from __future__ import annotations +from pathlib import PurePosixPath + from . import config as gate_config from .docker import Docker from .errors import GateError @@ -101,7 +103,19 @@ def record(manifest: str) -> None: manifest = author_binary_graph(assets_manifest, build=build, record=record) self.build_site(dist=out_dir) self.check_channel(admin, channel=channel, dist=out_dir, manifest=manifest) - self.hand_off(manifest) + self.hand_off(manifest, channel=channel) + + def _authored_graph(self, *, channel: str, out_dir: str) -> str: + """Where `assets channel build --channel ` writes its graph. + + `capsem-admin` lays the distribution out as + `/assets//`. The configured + `graph_manifest` names that location for the install gate's `local` + channel; any other channel -- the package proof's `stable`, say -- + replaces the channel segment, not the shape. + """ + name = PurePosixPath(self._config.graph_manifest).name + return f"{out_dir}/assets/{channel}/{name}" def extract_admin(self, package: str) -> str: """Unpack the package without installing it, and return its admin binary. @@ -206,7 +220,7 @@ def build_channel( user=self._config.guest_user.name, cwd=self._mount, ) - return f"{out_dir}/{self._config.graph_manifest}" + return self._authored_graph(channel=channel, out_dir=out_dir) def build_site(self, *, dist: str) -> None: """Render the release site over the generated distribution.""" @@ -241,7 +255,7 @@ def check_channel(self, admin: str, *, channel: str, dist: str, manifest: str) - # -- the handoff ------------------------------------------------------- - def hand_off(self, manifest: str) -> None: + def hand_off(self, manifest: str, *, channel: str) -> None: """Point the package's postinst at the graph just authored. Refuses two mistakes the installer cannot report. A target that does @@ -251,8 +265,9 @@ def hand_off(self, manifest: str) -> None: an install that looks fine and carries the wrong manifest. """ absolute = manifest if manifest.startswith("/") else f"{self._mount}/{manifest}" + authored = f"/assets/{channel}/{PurePosixPath(self._config.graph_manifest).name}" if absolute.endswith(f"/{self._config.legacy_projection}") and not absolute.endswith( - f"/{self._config.graph_manifest}" + authored ): raise GateError( f"the install handoff must select the authoritative release graph, " diff --git a/build_system/tests/gate/test_gate_debproof.py b/build_system/tests/gate/test_gate_debproof.py index 5aa4c9c3a..3c00b4624 100644 --- a/build_system/tests/gate/test_gate_debproof.py +++ b/build_system/tests/gate/test_gate_debproof.py @@ -196,7 +196,7 @@ def test_exact_package_graph_is_checked_and_handed_off_before_dpkg( handoff = transcript.index("install-manifest-request.sh write") install = transcript.index("dpkg -i") assert extract < first_build < record < second_build < check < handoff < install - authoritative = f"{CONFIG.install.layout.channel}/{CONFIG.install.graph_manifest}" + authoritative = f"{CONFIG.install.layout.channel}/assets/nightly/manifest.json" record_command = runner.matching(r"assets channel record-binary")[0] assert f"--manifest-path {authoritative}" in record_command assert f"--source-commit {SOURCE_COMMIT}" in transcript @@ -215,7 +215,7 @@ def test_read_only_content_is_staged_before_record_binary_mutates_the_generated_ assert f":{CONFIG.install.proof_assets_mount}:ro" in started assert f":{CONFIG.install.proof_config_mount}:ro" in started record = runner.matching(r"assets channel record-binary")[0] - authoritative = f"{CONFIG.install.layout.channel}/{CONFIG.install.graph_manifest}" + authoritative = f"{CONFIG.install.layout.channel}/assets/nightly/manifest.json" assert f"--manifest-path {authoritative}" in record assert ( f"--manifest-path {CONFIG.install.proof_assets_mount}/{CONFIG.install.manifest_name}" diff --git a/build_system/tests/gate/test_gate_install_ordering.py b/build_system/tests/gate/test_gate_install_ordering.py index 2be665271..01c34eec2 100644 --- a/build_system/tests/gate/test_gate_install_ordering.py +++ b/build_system/tests/gate/test_gate_install_ordering.py @@ -415,7 +415,7 @@ def test_the_legacy_runtime_projection_is_refused(tmp_path: Path) -> None: graph = ReleaseGraph(Docker(runner), CONFIG, source_commit=SOURCE_COMMIT) with pytest.raises(GateError, match="not the legacy runtime projection"): - graph.hand_off(f"{LAYOUT.assets}/manifest.json") + graph.hand_off(f"{LAYOUT.assets}/manifest.json", channel=INSTALL.channel) assert not runner.ran(r"install-manifest-request"), ( "the refusal must happen before anything is written" @@ -430,7 +430,7 @@ def test_a_handoff_target_that_does_not_exist_is_refused(tmp_path: Path) -> None graph = ReleaseGraph(Docker(runner), CONFIG, source_commit=SOURCE_COMMIT) with pytest.raises(GateError, match="would find no request"): - graph.hand_off(AUTHORITATIVE) + graph.hand_off(AUTHORITATIVE, channel=INSTALL.channel) def test_clearing_a_handoff_that_was_never_written_does_nothing(tmp_path: Path) -> None: