build: make pinned image inputs portable
prd-number-check / require-numbered-prds (pull_request) Successful in 8s
refresh-image-locks / refresh (push) Successful in 28s
lint / lint (push) Successful in 1m3s
test / unit (pull_request) Successful in 53s
test / integration-docker (pull_request) Successful in 2m50s
test / coverage (pull_request) Failing after 22s
test / image-input-builds (pull_request) Successful in 3m35s
tracker-policy-pr / check-pr (pull_request) Failing after 11m8s

This commit is contained in:
2026-07-26 09:42:54 +00:00
parent 023b23de6a
commit 377f8d125b
11 changed files with 130 additions and 33 deletions
+6 -1
View File
@@ -183,9 +183,14 @@ jobs:
docker build -t "$orchestrator" -f Dockerfile.orchestrator . docker build -t "$orchestrator" -f Dockerfile.orchestrator .
orchestrator_id=$(docker image inspect --format '{{.Id}}' "$orchestrator") orchestrator_id=$(docker image inspect --format '{{.Id}}' "$orchestrator")
case "$orchestrator_id" in sha256:*) ;; *) exit 1 ;; esac 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 -t "$gateway" -f Dockerfile.gateway .
docker build \ docker build \
--build-arg "ORCHESTRATOR_BASE_IMAGE=$orchestrator_id" \ --build-arg "ORCHESTRATOR_BASE_IMAGE=$orchestrator_base" \
-t "$orchestrator_fc" -f Dockerfile.orchestrator.fc . -t "$orchestrator_fc" -f Dockerfile.orchestrator.fc .
docker build -t "$claude" -f bot_bottle/contrib/claude/Dockerfile . docker build -t "$claude" -f bot_bottle/contrib/claude/Dockerfile .
docker build -t "$codex" -f bot_bottle/contrib/codex/Dockerfile . docker build -t "$codex" -f bot_bottle/contrib/codex/Dockerfile .
+4 -4
View File
@@ -47,10 +47,10 @@ FROM python:3.12.13-slim-trixie@sha256:57cd7c3a7a273101a6485ba99423ee56815788280
# snapshot, the same Dockerfile resolves different package versions over time. # snapshot, the same Dockerfile resolves different package versions over time.
ARG DEBIAN_SNAPSHOT=20260724T000000Z ARG DEBIAN_SNAPSHOT=20260724T000000Z
RUN sed -i \ RUN sed -i \
-e "s|http://deb.debian.org/debian-security|https://snapshot.debian.org/archive/debian-security/${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|https://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|https://snapshot.debian.org/archive/debian/${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|https://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 /etc/apt/sources.list.d/debian.sources
# Runtime system deps: # Runtime system deps:
+4 -4
View File
@@ -20,10 +20,10 @@ FROM ${ORCHESTRATOR_BASE_IMAGE}
ARG DEBIAN_SNAPSHOT=20260724T000000Z ARG DEBIAN_SNAPSHOT=20260724T000000Z
RUN sed -i \ RUN sed -i \
-e "s|http://deb.debian.org/debian-security|https://snapshot.debian.org/archive/debian-security/${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|https://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|https://snapshot.debian.org/archive/debian/${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|https://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 /etc/apt/sources.list.d/debian.sources
RUN apt-get -o Acquire::Check-Valid-Until=false update \ RUN apt-get -o Acquire::Check-Valid-Until=false update \
+30
View File
@@ -165,6 +165,36 @@ def image_id(ref: str) -> str:
return image 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 '<no detail>'}")
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: def verify_agent_image(image: str, argv: tuple[str, ...]) -> None:
"""Run `argv` inside a throwaway container of a freshly built agent """Run `argv` inside a throwaway container of a freshly built agent
image and die loudly if it fails, instead of shipping an image image and die loudly if it fails, instead of shipping an image
+2 -2
View File
@@ -133,14 +133,14 @@ def build_infra_images_with_docker() -> None:
root = str(resources.build_root()) root = str(resources.build_root())
docker_mod.build_image( docker_mod.build_image(
_ORCHESTRATOR_IMAGE, root, dockerfile="Dockerfile.orchestrator") _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( docker_mod.build_image(
_GATEWAY_IMAGE, root, dockerfile="Dockerfile.gateway") _GATEWAY_IMAGE, root, dockerfile="Dockerfile.gateway")
docker_mod.build_image( docker_mod.build_image(
_ORCHESTRATOR_FC_IMAGE, _ORCHESTRATOR_FC_IMAGE,
root, root,
dockerfile="Dockerfile.orchestrator.fc", dockerfile="Dockerfile.orchestrator.fc",
build_args={"ORCHESTRATOR_BASE_IMAGE": orchestrator_id}, build_args={"ORCHESTRATOR_BASE_IMAGE": orchestrator_base},
) )
+4 -4
View File
@@ -13,10 +13,10 @@ FROM node:22.23.1-trixie-slim@sha256:e6d9a389d34ff9678438af985c9913fbd1eb6ed36e8
ARG DEBIAN_SNAPSHOT=20260724T000000Z ARG DEBIAN_SNAPSHOT=20260724T000000Z
RUN sed -i \ RUN sed -i \
-e "s|http://deb.debian.org/debian-security|https://snapshot.debian.org/archive/debian-security/${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|https://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|https://snapshot.debian.org/archive/debian/${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|https://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 /etc/apt/sources.list.d/debian.sources
# Install runtime system deps. claude-code shells out to git for several # Install runtime system deps. claude-code shells out to git for several
+4 -4
View File
@@ -12,10 +12,10 @@ ARG CODEX_VERSION=0.145.0
ARG DEBIAN_SNAPSHOT=20260724T000000Z ARG DEBIAN_SNAPSHOT=20260724T000000Z
RUN sed -i \ RUN sed -i \
-e "s|http://deb.debian.org/debian-security|https://snapshot.debian.org/archive/debian-security/${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|https://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|https://snapshot.debian.org/archive/debian/${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|https://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 /etc/apt/sources.list.d/debian.sources
RUN apt-get -o Acquire::Check-Valid-Until=false update \ RUN apt-get -o Acquire::Check-Valid-Until=false update \
+4 -4
View File
@@ -6,10 +6,10 @@ FROM node:22.23.1-trixie-slim@sha256:e6d9a389d34ff9678438af985c9913fbd1eb6ed36e8
ARG DEBIAN_SNAPSHOT=20260724T000000Z ARG DEBIAN_SNAPSHOT=20260724T000000Z
RUN sed -i \ RUN sed -i \
-e "s|http://deb.debian.org/debian-security|https://snapshot.debian.org/archive/debian-security/${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|https://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|https://snapshot.debian.org/archive/debian/${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|https://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 /etc/apt/sources.list.d/debian.sources
RUN apt-get -o Acquire::Check-Valid-Until=false update \ RUN apt-get -o Acquire::Check-Valid-Until=false update \
+9 -4
View File
@@ -7,7 +7,10 @@ immutability:
- Python and Node base images use version-qualified tags plus multi-platform - Python and Node base images use version-qualified tags plus multi-platform
OCI index digests. OCI index digests.
- Debian packages resolve from the same dated `snapshot.debian.org` archive in - 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 - Gateway Python dependencies install from `requirements.gateway.lock` with
`pip --require-hashes`; provider npm packages install with `npm ci` from `pip --require-hashes`; provider npm packages install with `npm ci` from
committed lockfiles. committed lockfiles.
@@ -17,9 +20,11 @@ immutability:
The standalone layout is retained because Codex remote control requires it. The standalone layout is retained because Codex remote control requires it.
`Dockerfile.orchestrator.fc` is the one intentionally dynamic `FROM`. It has no `Dockerfile.orchestrator.fc` is the one intentionally dynamic `FROM`. It has no
default value: the Firecracker build coordinator builds the orchestrator first, default value: the Firecracker build coordinator resolves the freshly built
resolves its local `sha256:` image ID, and supplies that exact ID as orchestrator's local `sha256:` image ID, creates a local tag containing the
`ORCHESTRATOR_BASE_IMAGE`. 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 ## Refreshing inputs
+55 -1
View File
@@ -10,7 +10,7 @@ from __future__ import annotations
import subprocess import subprocess
import unittest import unittest
from datetime import timezone 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 from bot_bottle.backend.docker import util as docker_mod
@@ -170,5 +170,59 @@ class TestImageId(unittest.TestCase):
die.assert_called_once() 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__": if __name__ == "__main__":
unittest.main() unittest.main()
+8 -5
View File
@@ -110,9 +110,9 @@ class TestEnsureBuilt(unittest.TestCase):
patch.object(infra_vm.docker_mod, "build_image") as build, \ patch.object(infra_vm.docker_mod, "build_image") as build, \
patch.object( patch.object(
infra_vm.docker_mod, infra_vm.docker_mod,
"image_id", "pinned_local_image_ref",
return_value="sha256:" + "a" * 64, return_value="bot-bottle-orchestrator:sha256-" + "a" * 64,
) as image_id: ) as pinned_ref:
infra_vm.ensure_built() infra_vm.ensure_built()
tags = [c.args[0] for c in build.call_args_list] tags = [c.args[0] for c in build.call_args_list]
# orchestrator-fc is FROM orchestrator, so the base is built first. # 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.assertEqual(infra_vm._ORCHESTRATOR_FC_IMAGE, tags[-1])
self.assertLess(tags.index(infra_vm._ORCHESTRATOR_IMAGE), self.assertLess(tags.index(infra_vm._ORCHESTRATOR_IMAGE),
tags.index(infra_vm._ORCHESTRATOR_FC_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( 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"], build.call_args_list[-1].kwargs["build_args"],
) )