diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index 2cbbde6f..c1676161 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -183,9 +183,14 @@ jobs: docker build -t "$orchestrator" -f Dockerfile.orchestrator . orchestrator_id=$(docker image inspect --format '{{.Id}}' "$orchestrator") case "$orchestrator_id" in sha256:*) ;; *) exit 1 ;; esac + orchestrator_base="bot-bottle-orchestrator-inputs:sha256-${orchestrator_id#sha256:}" + docker image tag "$orchestrator_id" "$orchestrator_base" + test "$( + docker image inspect --format '{{.Id}}' "$orchestrator_base" + )" = "$orchestrator_id" docker build -t "$gateway" -f Dockerfile.gateway . docker build \ - --build-arg "ORCHESTRATOR_BASE_IMAGE=$orchestrator_id" \ + --build-arg "ORCHESTRATOR_BASE_IMAGE=$orchestrator_base" \ -t "$orchestrator_fc" -f Dockerfile.orchestrator.fc . docker build -t "$claude" -f bot_bottle/contrib/claude/Dockerfile . docker build -t "$codex" -f bot_bottle/contrib/codex/Dockerfile . diff --git a/Dockerfile.gateway b/Dockerfile.gateway index d0b7eead..1c7a2765 100644 --- a/Dockerfile.gateway +++ b/Dockerfile.gateway @@ -47,10 +47,10 @@ FROM python:3.12.13-slim-trixie@sha256:57cd7c3a7a273101a6485ba99423ee56815788280 # snapshot, the same Dockerfile resolves different package versions over time. ARG DEBIAN_SNAPSHOT=20260724T000000Z RUN sed -i \ - -e "s|http://deb.debian.org/debian-security|https://snapshot.debian.org/archive/debian-security/${DEBIAN_SNAPSHOT}|g" \ - -e "s|https://deb.debian.org/debian-security|https://snapshot.debian.org/archive/debian-security/${DEBIAN_SNAPSHOT}|g" \ - -e "s|http://deb.debian.org/debian|https://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}|g" \ - -e "s|https://deb.debian.org/debian|https://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}|g" \ + -e "s|http://deb.debian.org/debian-security|http://snapshot.debian.org/archive/debian-security/${DEBIAN_SNAPSHOT}|g" \ + -e "s|https://deb.debian.org/debian-security|http://snapshot.debian.org/archive/debian-security/${DEBIAN_SNAPSHOT}|g" \ + -e "s|http://deb.debian.org/debian|http://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}|g" \ + -e "s|https://deb.debian.org/debian|http://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}|g" \ /etc/apt/sources.list.d/debian.sources # Runtime system deps: diff --git a/Dockerfile.orchestrator.fc b/Dockerfile.orchestrator.fc index fb38badc..4f9e3a7a 100644 --- a/Dockerfile.orchestrator.fc +++ b/Dockerfile.orchestrator.fc @@ -20,10 +20,10 @@ FROM ${ORCHESTRATOR_BASE_IMAGE} ARG DEBIAN_SNAPSHOT=20260724T000000Z RUN sed -i \ - -e "s|http://deb.debian.org/debian-security|https://snapshot.debian.org/archive/debian-security/${DEBIAN_SNAPSHOT}|g" \ - -e "s|https://deb.debian.org/debian-security|https://snapshot.debian.org/archive/debian-security/${DEBIAN_SNAPSHOT}|g" \ - -e "s|http://deb.debian.org/debian|https://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}|g" \ - -e "s|https://deb.debian.org/debian|https://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}|g" \ + -e "s|http://deb.debian.org/debian-security|http://snapshot.debian.org/archive/debian-security/${DEBIAN_SNAPSHOT}|g" \ + -e "s|https://deb.debian.org/debian-security|http://snapshot.debian.org/archive/debian-security/${DEBIAN_SNAPSHOT}|g" \ + -e "s|http://deb.debian.org/debian|http://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}|g" \ + -e "s|https://deb.debian.org/debian|http://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}|g" \ /etc/apt/sources.list.d/debian.sources RUN apt-get -o Acquire::Check-Valid-Until=false update \ diff --git a/bot_bottle/backend/docker/util.py b/bot_bottle/backend/docker/util.py index 2a9c24fd..05c13d28 100644 --- a/bot_bottle/backend/docker/util.py +++ b/bot_bottle/backend/docker/util.py @@ -165,6 +165,36 @@ def image_id(ref: str) -> str: return image +def pinned_local_image_ref(ref: str) -> str: + """Give a local image a content-derived tag and verify the tag resolves + back to the same image ID. + + BuildKit treats a bare ``sha256:...`` ID in ``FROM`` as a registry + repository name. A tag whose complete suffix is the local image ID remains + resolvable by BuildKit, while the post-tag inspection keeps the handoff + fail-closed. + """ + image = image_id(ref) + digest = image.removeprefix("sha256:") + if len(digest) != 64 or any(char not in "0123456789abcdef" for char in digest): + die(f"could not derive a local base tag from invalid image ID {image!r}") + + repository = ref.split("@", 1)[0] + last_slash = repository.rfind("/") + last_colon = repository.rfind(":") + if last_colon > last_slash: + repository = repository[:last_colon] + pinned_ref = f"{repository}:sha256-{digest}" + + result = run_docker(["docker", "image", "tag", image, pinned_ref]) + if result.returncode != 0: + detail = (result.stderr or result.stdout or "").strip() + die(f"could not tag exact local image {image}: {detail or ''}") + if image_id(pinned_ref) != image: + die(f"content-derived local tag {pinned_ref!r} did not resolve to {image}") + return pinned_ref + + def verify_agent_image(image: str, argv: tuple[str, ...]) -> None: """Run `argv` inside a throwaway container of a freshly built agent image and die loudly if it fails, instead of shipping an image diff --git a/bot_bottle/backend/firecracker/infra_vm.py b/bot_bottle/backend/firecracker/infra_vm.py index 9f2a1a48..0595df1a 100644 --- a/bot_bottle/backend/firecracker/infra_vm.py +++ b/bot_bottle/backend/firecracker/infra_vm.py @@ -133,14 +133,14 @@ def build_infra_images_with_docker() -> None: root = str(resources.build_root()) docker_mod.build_image( _ORCHESTRATOR_IMAGE, root, dockerfile="Dockerfile.orchestrator") - orchestrator_id = docker_mod.image_id(_ORCHESTRATOR_IMAGE) + orchestrator_base = docker_mod.pinned_local_image_ref(_ORCHESTRATOR_IMAGE) docker_mod.build_image( _GATEWAY_IMAGE, root, dockerfile="Dockerfile.gateway") docker_mod.build_image( _ORCHESTRATOR_FC_IMAGE, root, dockerfile="Dockerfile.orchestrator.fc", - build_args={"ORCHESTRATOR_BASE_IMAGE": orchestrator_id}, + build_args={"ORCHESTRATOR_BASE_IMAGE": orchestrator_base}, ) diff --git a/bot_bottle/contrib/claude/Dockerfile b/bot_bottle/contrib/claude/Dockerfile index 36da01a4..f76a8e8a 100644 --- a/bot_bottle/contrib/claude/Dockerfile +++ b/bot_bottle/contrib/claude/Dockerfile @@ -13,10 +13,10 @@ FROM node:22.23.1-trixie-slim@sha256:e6d9a389d34ff9678438af985c9913fbd1eb6ed36e8 ARG DEBIAN_SNAPSHOT=20260724T000000Z RUN sed -i \ - -e "s|http://deb.debian.org/debian-security|https://snapshot.debian.org/archive/debian-security/${DEBIAN_SNAPSHOT}|g" \ - -e "s|https://deb.debian.org/debian-security|https://snapshot.debian.org/archive/debian-security/${DEBIAN_SNAPSHOT}|g" \ - -e "s|http://deb.debian.org/debian|https://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}|g" \ - -e "s|https://deb.debian.org/debian|https://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}|g" \ + -e "s|http://deb.debian.org/debian-security|http://snapshot.debian.org/archive/debian-security/${DEBIAN_SNAPSHOT}|g" \ + -e "s|https://deb.debian.org/debian-security|http://snapshot.debian.org/archive/debian-security/${DEBIAN_SNAPSHOT}|g" \ + -e "s|http://deb.debian.org/debian|http://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}|g" \ + -e "s|https://deb.debian.org/debian|http://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}|g" \ /etc/apt/sources.list.d/debian.sources # Install runtime system deps. claude-code shells out to git for several diff --git a/bot_bottle/contrib/codex/Dockerfile b/bot_bottle/contrib/codex/Dockerfile index 373a687e..add00e16 100644 --- a/bot_bottle/contrib/codex/Dockerfile +++ b/bot_bottle/contrib/codex/Dockerfile @@ -12,10 +12,10 @@ ARG CODEX_VERSION=0.145.0 ARG DEBIAN_SNAPSHOT=20260724T000000Z RUN sed -i \ - -e "s|http://deb.debian.org/debian-security|https://snapshot.debian.org/archive/debian-security/${DEBIAN_SNAPSHOT}|g" \ - -e "s|https://deb.debian.org/debian-security|https://snapshot.debian.org/archive/debian-security/${DEBIAN_SNAPSHOT}|g" \ - -e "s|http://deb.debian.org/debian|https://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}|g" \ - -e "s|https://deb.debian.org/debian|https://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}|g" \ + -e "s|http://deb.debian.org/debian-security|http://snapshot.debian.org/archive/debian-security/${DEBIAN_SNAPSHOT}|g" \ + -e "s|https://deb.debian.org/debian-security|http://snapshot.debian.org/archive/debian-security/${DEBIAN_SNAPSHOT}|g" \ + -e "s|http://deb.debian.org/debian|http://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}|g" \ + -e "s|https://deb.debian.org/debian|http://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}|g" \ /etc/apt/sources.list.d/debian.sources RUN apt-get -o Acquire::Check-Valid-Until=false update \ diff --git a/bot_bottle/contrib/pi/Dockerfile b/bot_bottle/contrib/pi/Dockerfile index 5da73c8a..54c70031 100644 --- a/bot_bottle/contrib/pi/Dockerfile +++ b/bot_bottle/contrib/pi/Dockerfile @@ -6,10 +6,10 @@ FROM node:22.23.1-trixie-slim@sha256:e6d9a389d34ff9678438af985c9913fbd1eb6ed36e8 ARG DEBIAN_SNAPSHOT=20260724T000000Z RUN sed -i \ - -e "s|http://deb.debian.org/debian-security|https://snapshot.debian.org/archive/debian-security/${DEBIAN_SNAPSHOT}|g" \ - -e "s|https://deb.debian.org/debian-security|https://snapshot.debian.org/archive/debian-security/${DEBIAN_SNAPSHOT}|g" \ - -e "s|http://deb.debian.org/debian|https://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}|g" \ - -e "s|https://deb.debian.org/debian|https://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}|g" \ + -e "s|http://deb.debian.org/debian-security|http://snapshot.debian.org/archive/debian-security/${DEBIAN_SNAPSHOT}|g" \ + -e "s|https://deb.debian.org/debian-security|http://snapshot.debian.org/archive/debian-security/${DEBIAN_SNAPSHOT}|g" \ + -e "s|http://deb.debian.org/debian|http://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}|g" \ + -e "s|https://deb.debian.org/debian|http://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}|g" \ /etc/apt/sources.list.d/debian.sources RUN apt-get -o Acquire::Check-Valid-Until=false update \ diff --git a/docs/image-build-inputs.md b/docs/image-build-inputs.md index 59efb2fe..61e8ed88 100644 --- a/docs/image-build-inputs.md +++ b/docs/image-build-inputs.md @@ -7,7 +7,10 @@ immutability: - Python and Node base images use version-qualified tags plus multi-platform OCI index digests. - Debian packages resolve from the same dated `snapshot.debian.org` archive in - every image that runs `apt-get install`. + every image that runs `apt-get install`. The snapshot endpoint uses HTTP so a + fresh image does not need a host-specific TLS interception CA; APT still + authenticates the repository metadata and package hashes with Debian's + signed Release files. - Gateway Python dependencies install from `requirements.gateway.lock` with `pip --require-hashes`; provider npm packages install with `npm ci` from committed lockfiles. @@ -17,9 +20,11 @@ immutability: The standalone layout is retained because Codex remote control requires it. `Dockerfile.orchestrator.fc` is the one intentionally dynamic `FROM`. It has no -default value: the Firecracker build coordinator builds the orchestrator first, -resolves its local `sha256:` image ID, and supplies that exact ID as -`ORCHESTRATOR_BASE_IMAGE`. +default value: the Firecracker build coordinator resolves the freshly built +orchestrator's local `sha256:` image ID, creates a local tag containing the +complete ID, verifies that tag resolves to the same ID, and supplies the +content-derived reference as `ORCHESTRATOR_BASE_IMAGE`. This accommodates +BuildKit, which treats a bare image ID in `FROM` as a registry repository. ## Refreshing inputs diff --git a/tests/unit/test_docker_util_image.py b/tests/unit/test_docker_util_image.py index 4040d6a1..5e3e7d1d 100644 --- a/tests/unit/test_docker_util_image.py +++ b/tests/unit/test_docker_util_image.py @@ -10,7 +10,7 @@ from __future__ import annotations import subprocess import unittest from datetime import timezone -from unittest.mock import patch +from unittest.mock import call, patch from bot_bottle.backend.docker import util as docker_mod @@ -170,5 +170,59 @@ class TestImageId(unittest.TestCase): die.assert_called_once() +class TestPinnedLocalImageRef(unittest.TestCase): + def test_tags_and_verifies_content_derived_reference(self): + image = "sha256:" + "c" * 64 + expected = "registry.example:5000/team/base:sha256-" + "c" * 64 + with patch.object( + docker_mod, + "image_id", + side_effect=[image, image], + ) as image_id, patch.object( + docker_mod, + "run_docker", + return_value=_ok(), + ) as run: + self.assertEqual( + expected, + docker_mod.pinned_local_image_ref( + "registry.example:5000/team/base:mutable" + ), + ) + self.assertEqual( + ["docker", "image", "tag", image, expected], + run.call_args.args[0], + ) + self.assertEqual( + [ + call("registry.example:5000/team/base:mutable"), + call(expected), + ], + image_id.call_args_list, + ) + + def test_rejects_invalid_id_or_failed_tag(self): + cases = [ + ("sha256:short", _ok()), + ("sha256:" + "d" * 64, _fail("tag failed")), + ] + for image, result in cases: + with self.subTest(image=image), patch.object( + docker_mod, + "image_id", + return_value=image, + ), patch.object( + docker_mod, + "run_docker", + return_value=result, + ), patch.object( + docker_mod, + "die", + side_effect=SystemExit("die"), + ): + with self.assertRaises(SystemExit): + docker_mod.pinned_local_image_ref("base:latest") + + if __name__ == "__main__": unittest.main() diff --git a/tests/unit/test_firecracker_infra_vm.py b/tests/unit/test_firecracker_infra_vm.py index f5b346aa..4f8f44c5 100644 --- a/tests/unit/test_firecracker_infra_vm.py +++ b/tests/unit/test_firecracker_infra_vm.py @@ -110,9 +110,9 @@ class TestEnsureBuilt(unittest.TestCase): patch.object(infra_vm.docker_mod, "build_image") as build, \ patch.object( infra_vm.docker_mod, - "image_id", - return_value="sha256:" + "a" * 64, - ) as image_id: + "pinned_local_image_ref", + return_value="bot-bottle-orchestrator:sha256-" + "a" * 64, + ) as pinned_ref: infra_vm.ensure_built() tags = [c.args[0] for c in build.call_args_list] # orchestrator-fc is FROM orchestrator, so the base is built first. @@ -121,9 +121,12 @@ class TestEnsureBuilt(unittest.TestCase): self.assertEqual(infra_vm._ORCHESTRATOR_FC_IMAGE, tags[-1]) self.assertLess(tags.index(infra_vm._ORCHESTRATOR_IMAGE), tags.index(infra_vm._ORCHESTRATOR_FC_IMAGE)) - image_id.assert_called_once_with(infra_vm._ORCHESTRATOR_IMAGE) + pinned_ref.assert_called_once_with(infra_vm._ORCHESTRATOR_IMAGE) self.assertEqual( - {"ORCHESTRATOR_BASE_IMAGE": "sha256:" + "a" * 64}, + { + "ORCHESTRATOR_BASE_IMAGE": + "bot-bottle-orchestrator:sha256-" + "a" * 64, + }, build.call_args_list[-1].kwargs["build_args"], )