diff --git a/build_system/builder/release/tools/check_public_binary_release.py b/build_system/builder/release/tools/check_public_binary_release.py index 84c4a865f..c6310feb1 100644 --- a/build_system/builder/release/tools/check_public_binary_release.py +++ b/build_system/builder/release/tools/check_public_binary_release.py @@ -731,7 +731,11 @@ def run_docker_binary_transition_smoke( chmod 0440 /etc/sudoers.d/capsemtest check_binary_versions() {{ expected="$1" + # Each installed package is held to the binaries it owns: the older one + # predates capsem-router (it shipped capsem-port-router). + owned=$(dpkg -L capsem) for bin in {helpers}; do + printf '%s\n' "$owned" | grep -Fqx "/usr/bin/$bin" || continue su capsemtest -c "test -x \"\\$HOME/.capsem/bin/$bin\"" su capsemtest -c "\"\\$HOME/.capsem/bin/$bin\" --version" | grep -F "$expected" done diff --git a/build_system/builder/release/tools/release_installed_probe.py b/build_system/builder/release/tools/release_installed_probe.py index f12e1cd02..d50b5b2d3 100644 --- a/build_system/builder/release/tools/release_installed_probe.py +++ b/build_system/builder/release/tools/release_installed_probe.py @@ -27,6 +27,21 @@ ) +def packaged_host_binaries(deb: Path) -> tuple[str, ...]: + """The host binaries an exact package ships, in HOST_BINARIES order. + + A transition installs an older package first, and binary sets change + between releases: 0.6.3 shipped capsem-port-router, not capsem-router. + Checking the current names against it failed on a binary that package + never had, so each installed package is held to its own payload. + """ + shipped = { + Path(name).name + for name in deb_payload_files(deb, select=lambda name: Path(name).parent.as_posix().endswith("usr/bin")) + } + return tuple(binary for binary in HOST_BINARIES if binary in shipped) + + def packaged_manifest_metadata(deb: Path) -> dict[str, str]: """Return the future polling identity declared by an exact package. @@ -114,7 +129,10 @@ def exact_installed_probe_shell(evidence_dir: Path) -> str: }} check_binary_versions() {{ expected="$1" - for binary in {" ".join(HOST_BINARIES)}; do + artifact="$2" + binaries=$({shlex.quote(sys.executable)} -c 'import sys; from pathlib import Path; from capsem_builder.release.tools.release_installed_probe import packaged_host_binaries; print(" ".join(packaged_host_binaries(Path(sys.argv[1]))))' "$artifact") + test -n "$binaries" + for binary in $binaries; do test -x "$CAPSEM_HOME_DIR/bin/$binary" if [ "$binary" = capsem ]; then "$CAPSEM_HOME_DIR/bin/$binary" version @@ -134,7 +152,7 @@ def exact_installed_probe_shell(evidence_dir: Path) -> str: architecture="$7" metadata_manifest_url="${{8:-$manifest_url}}" wait_for_service - check_binary_versions "$package_version" + check_binary_versions "$package_version" "$artifact" dpkg-query -W -f='${{Version}}' capsem | grep -Fx "$package_version" {shlex.quote(sys.executable)} build_system/scripts/release/verify-installed-release.py \ --capsem "$CAPSEM_BIN" \ diff --git a/build_system/tests/release/test_deb_package_portability.py b/build_system/tests/release/test_deb_package_portability.py index 2fbd37f26..d6fd42a71 100644 --- a/build_system/tests/release/test_deb_package_portability.py +++ b/build_system/tests/release/test_deb_package_portability.py @@ -78,3 +78,83 @@ def test_manifest_metadata_requires_one_package_owned_member(package: Path, coun _write_synthetic_deb(tree, package) with pytest.raises(SystemExit, match=f"exactly one manifest metadata, found {count}"): release_installed_probe.packaged_manifest_metadata(package) + + +def test_installed_probe_checks_the_binaries_the_package_ships(package: Path) -> None: + """A transition installs an older package whose binary set differs. + + 0.6.3 shipped capsem-port-router (and no capsem-router); the probe checked + every current binary name against it and failed the release glow-up on a + binary that package never had. It checks what the package ships. + """ + tree = package.parent / "tree" + for name in ("capsem-service", "capsem-port-router", "capsem-app"): + (tree / "usr/bin" / name).write_bytes(b"old package binary") + package.unlink() + _write_synthetic_deb(tree, package) + + assert release_installed_probe.packaged_host_binaries(package) == ("capsem", "capsem-service") + + +def test_installed_probe_shell_checks_each_artifact_payload(tmp_path: Path) -> None: + shell = release_installed_probe.exact_installed_probe_shell(tmp_path) + assert 'check_binary_versions "$package_version" "$artifact"' in shell + assert "packaged_host_binaries" in shell + + +def _public_transition_version_check(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> str: + """The rendered public transition script's binary check, as bash.""" + import re + + from capsem_builder.release.tools import check_public_binary_release as public + + scripts: list[str] = [] + monkeypatch.setattr(public.shutil, "which", lambda _name: "/usr/bin/docker") + monkeypatch.setattr( + public, "linux_amd64_current_package", lambda manifest: {"version": manifest["version"]} + ) + monkeypatch.setattr(public.subprocess, "run", lambda argv, **_: scripts.append(argv[-1])) + public.run_docker_binary_transition_smoke( + older_manifest={"version": "0.6.3"}, + newer_manifest={"version": "0.6.4"}, + install_script_url="http://127.0.0.1:1/install.sh", + docker_image="unused", + work_dir=tmp_path / "work", + ) + monkeypatch.undo() # the stubs patched the shared subprocess module + match = re.search(r"^check_binary_versions\(\) \{\n.*?^\}\n", scripts[0], re.S | re.M) + assert match, scripts[0] + return match.group(0) + + +@pytest.mark.parametrize("owned_router", [False, True]) +def test_public_transition_checks_the_binaries_the_installed_package_owns( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, owned_router: bool +) -> None: + """The older public package predates capsem-router; checking the current + binary names against it failed a release on a binary it never shipped. + A binary the installed package does own must still be present.""" + import subprocess + + function = _public_transition_version_check(tmp_path, monkeypatch) + home = tmp_path / "home" + (home / ".capsem/bin").mkdir(parents=True) + for name in ("capsem", "capsem-service"): + tool = home / ".capsem/bin" / name + tool.write_text("#!/bin/sh\necho " + name + " 0.6.3\n") + tool.chmod(0o755) + owned = ["/usr/bin/capsem", "/usr/bin/capsem-service"] + (["/usr/bin/capsem-router"] if owned_router else []) + stubs = tmp_path / "stubs" + stubs.mkdir() + (stubs / "dpkg").write_text("#!/bin/sh\nprintf '%s\\n' " + " ".join(owned) + "\n") + (stubs / "su").write_text('#!/bin/sh\nshift; exec sh -c "$2"\n') + for stub in stubs.iterdir(): + stub.chmod(0o755) + result = subprocess.run( + ["bash", "-c", "set -euo pipefail\n" + function + "\ncheck_binary_versions 0.6.3\n"], + env={"PATH": f"{stubs}:/usr/bin:/bin", "HOME": str(home)}, + capture_output=True, + text=True, + check=False, + ) + assert (result.returncode == 0) is (not owned_router), result.stderr diff --git a/build_system/tests/release/test_release_glowup.py b/build_system/tests/release/test_release_glowup.py index 98c3cd139..891bea140 100644 --- a/build_system/tests/release/test_release_glowup.py +++ b/build_system/tests/release/test_release_glowup.py @@ -1659,7 +1659,7 @@ def capture(command: list[str], **_kwargs) -> None: assert "build_system/scripts/release/verify-installed-release.py" in script assert '"$CAPSEM_BIN" doctor' in script assert "build_system/scripts/build/run-installed-winterfell.py" in script - assert "capsem-mock-server" in script + assert "packaged_host_binaries" in script # each package holds itself to its own payload assert "update --yes" not in script assert "update --yes" not in tamper_script assert "update --yes" not in incompatible_script