fix(build): close remaining mutable image inputs

This commit is contained in:
2026-07-26 20:41:29 +00:00
committed by didericis
parent 33b7bcd082
commit 73c566f3ff
16 changed files with 184 additions and 44 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ on:
- "bot_bottle/contrib/*/Dockerfile"
- "bot_bottle/contrib/*/package.json"
- "bot_bottle/contrib/*/package-lock.json"
- "bot_bottle/contrib/codex/install.sh.sha256"
- "bot_bottle/contrib/codex/codex-package_SHA256SUMS"
- "requirements.gateway.*"
- "image-build-args.json"
- ".pylintrc"
+4 -8
View File
@@ -76,19 +76,15 @@ jobs:
bot_bottle/contrib/claude/package-lock.json \
bot_bottle/contrib/pi/package-lock.json
- name: Refresh pinned Codex installer checksum
- name: Refresh pinned Codex archive checksums
run: |
CODEX_VERSION=$(
sed -n 's/^ARG CODEX_VERSION=//p' bot_bottle/contrib/codex/Dockerfile
)
test -n "$CODEX_VERSION"
curl -fsSL \
"https://raw.githubusercontent.com/openai/codex/rust-v${CODEX_VERSION}/scripts/install/install.sh" \
-o bot_bottle/contrib/codex/install.sh
(
cd bot_bottle/contrib/codex
sha256sum install.sh > install.sh.sha256
)
"https://github.com/openai/codex/releases/download/rust-v${CODEX_VERSION}/codex-package_SHA256SUMS" \
-o bot_bottle/contrib/codex/codex-package_SHA256SUMS
- name: Upload refreshed inputs
uses: actions/upload-artifact@v3
@@ -98,4 +94,4 @@ jobs:
requirements.gateway.lock
bot_bottle/contrib/claude/package-lock.json
bot_bottle/contrib/pi/package-lock.json
bot_bottle/contrib/codex/install.sh.sha256
bot_bottle/contrib/codex/codex-package_SHA256SUMS
+5 -1
View File
@@ -116,7 +116,11 @@ def _layer_nested_containers(
)
info(f"using cached nested-container image {derived!r}")
return derived
return nested_containers_mod.build_image(agent_image, container_mod.build_image)
return nested_containers_mod.build_image(
agent_image,
container_mod.build_image,
container_mod.pinned_local_image_ref,
)
@contextmanager
@@ -57,6 +57,7 @@ _GUEST_DEVICES = ("/dev/fuse", "/dev/net/tun")
def build_image(
base_image: str,
build: Callable[..., None],
pin_local_base: Callable[[str], str],
) -> str:
"""Layer the nested-container tooling onto an already-built agent image.
@@ -67,13 +68,15 @@ def build_image(
# abstraction once that infrastructure exists.
"""
image = f"{base_image}{IMAGE_SUFFIX}"
pinned_base = pin_local_base(base_image)
init_script = Path(__file__).with_name("nested-containers-init.sh")
with tempfile.TemporaryDirectory(prefix="bot-bottle-nested-containers.") as tmp:
context = Path(tmp)
shutil.copy2(init_script, context / "nested-containers-init.sh")
(context / "Dockerfile").write_text(
"FROM docker:28-cli AS docker_cli\n"
f"FROM {base_image}\n"
"ARG DOCKER_CLI_BASE_IMAGE\n"
"FROM ${DOCKER_CLI_BASE_IMAGE} AS docker_cli\n"
f"FROM {pinned_base}\n"
"USER root\n"
"COPY --from=docker_cli /usr/local/bin/docker /usr/local/bin/docker\n"
"COPY --from=docker_cli /usr/local/libexec/docker/cli-plugins/"
@@ -682,6 +682,40 @@ def image_id(ref: str) -> str:
raise AssertionError("unreachable")
def pinned_local_image_ref(ref: str) -> str:
"""Tag a local image with its complete content ID for a stable ``FROM``.
Agent images are immediately used as bases for the optional
nested-containers layer. A content-derived tag prevents another concurrent
build from moving the provider's ordinary ``:latest`` tag between those
two builds.
"""
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 = subprocess.run(
[_CONTAINER, "image", "tag", image, pinned_ref],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
die(
f"could not tag exact local image {image}: "
f"{(result.stderr or result.stdout or '').strip() 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 image_created_at(ref: str) -> datetime | None:
"""Return the image creation timestamp as an aware UTC datetime, or None
when the field is absent or unparseable (e.g. FROM-scratch images, images
+25 -15
View File
@@ -6,9 +6,8 @@
ARG NODE_BASE_IMAGE
FROM ${NODE_BASE_IMAGE}
# The standalone installer is used below because remote-control requires its
# managed package layout. Keep this exact release in sync with the verified
# installer source and checksum.
# Remote-control requires the standalone package layout. Keep this exact release
# in sync with the committed upstream archive checksums.
ARG CODEX_VERSION=0.145.0
ARG DEBIAN_SNAPSHOT=20260724T000000Z
@@ -44,18 +43,29 @@ WORKDIR /home/node
ENV PATH="/home/node/.local/bin:${PATH}"
# Remote-control support requires the standalone Codex install layout under
# ~/.codex/packages/standalone/current. Fetch the installer from the same
# immutable release tag as the requested CLI, verify the committed checksum
# before executing it, and let the official installer verify the selected
# release archive against upstream release metadata.
COPY --chown=node:node bot_bottle/contrib/codex/install.sh.sha256 /tmp/install.sh.sha256
RUN curl -fsSL \
"https://raw.githubusercontent.com/openai/codex/rust-v${CODEX_VERSION}/scripts/install/install.sh" \
-o /tmp/install.sh \
&& cd /tmp \
&& sha256sum -c install.sh.sha256 \
&& CODEX_NON_INTERACTIVE=1 sh /tmp/install.sh --release "${CODEX_VERSION}" \
# Install the exact standalone release archive selected by the target
# architecture. The checksum list is copied from the immutable upstream release
# and committed so a rebuild cannot silently accept changed release bytes.
COPY --chown=node:node bot_bottle/contrib/codex/codex-package_SHA256SUMS /tmp/codex-package_SHA256SUMS
RUN case "$(dpkg --print-architecture)" in \
amd64) codex_target=x86_64-unknown-linux-musl ;; \
arm64) codex_target=aarch64-unknown-linux-musl ;; \
*) echo "unsupported Codex architecture: $(dpkg --print-architecture)" >&2; exit 1 ;; \
esac \
&& codex_asset="codex-package-${codex_target}.tar.gz" \
&& codex_sha256="$(awk -v asset="${codex_asset}" '$2 == asset { print $1 }' /tmp/codex-package_SHA256SUMS)" \
&& test -n "${codex_sha256}" \
&& curl -fsSL \
"https://github.com/openai/codex/releases/download/rust-v${CODEX_VERSION}/${codex_asset}" \
-o "/tmp/${codex_asset}" \
&& echo "${codex_sha256} /tmp/${codex_asset}" | sha256sum -c - \
&& codex_release="/home/node/.codex/packages/standalone/releases/${CODEX_VERSION}-${codex_target}" \
&& mkdir -p "${codex_release}" /home/node/.local/bin \
&& tar -xzf "/tmp/${codex_asset}" -C "${codex_release}" \
&& ln -s bin/codex "${codex_release}/codex" \
&& ln -s "${codex_release}" /home/node/.codex/packages/standalone/current \
&& ln -s /home/node/.codex/packages/standalone/current/bin/codex /home/node/.local/bin/codex \
&& rm "/tmp/${codex_asset}" \
&& test "$(codex --version)" = "codex-cli ${CODEX_VERSION}"
CMD ["codex"]
@@ -0,0 +1,2 @@
54f79a05aba6f9abf8ef988abcae8bf2fcefba20beb549b4ff2b3acdb2cb6f54 codex-package-aarch64-unknown-linux-musl.tar.gz
71a28d362c96ac9829bf8203a2c71be451aeb726adb843167fdaf0eae8fe7dd9 codex-package-x86_64-unknown-linux-musl.tar.gz
@@ -1 +0,0 @@
1154e9daf713aacd1534efca8042bfd6665ad24bc1d1dfd86b8f439fe60a7a5d install.sh
+16 -2
View File
@@ -52,7 +52,11 @@ BUNDLED_RESOURCES: tuple[str, ...] = (
# tell for which layout we're in.
_CHECKOUT_MARKER = "Dockerfile.gateway"
_IMAGE_BUILD_ARGS_FILE = "image-build-args.json"
_CENTRAL_BUILD_ARG_NAMES = frozenset({"NODE_BASE_IMAGE", "PYTHON_BASE_IMAGE"})
_CENTRAL_BUILD_ARG_NAMES = frozenset({
"DOCKER_CLI_BASE_IMAGE",
"NODE_BASE_IMAGE",
"PYTHON_BASE_IMAGE",
})
class ResourceError(RuntimeError):
@@ -112,7 +116,17 @@ def image_build_args(
wanted = declared & _CENTRAL_BUILD_ARG_NAMES
if not wanted:
return {}
root = Path(context) if context is not None else build_root()
# Generated Dockerfiles (notably the macOS nested-container layer) live in
# temporary build contexts. Resolve their declaration there, but take the
# centralized values from that context only when it carries its own input
# file; otherwise use bot-bottle's staged build root.
context_root = Path(context) if context is not None else None
root = (
context_root
if context_root is not None
and (context_root / _IMAGE_BUILD_ARGS_FILE).is_file()
else build_root()
)
inputs_path = root / _IMAGE_BUILD_ARGS_FILE
try:
inputs = json.loads(inputs_path.read_text(encoding="utf-8"))
+5 -4
View File
@@ -4,7 +4,8 @@ Bot-bottle's supported images are intended to rebuild from the same declared
inputs on Linux amd64 and arm64. The repository enforces four layers of
immutability:
- Python and Node base images are required, defaultless Docker build arguments.
- Python, Node, and nested-container Docker CLI base images are required,
defaultless Docker build arguments.
Their version-qualified tags and multi-platform OCI index digests live
together in `image-build-args.json`; every supported builder reads that
file and passes only the arguments its Dockerfile declares.
@@ -16,9 +17,9 @@ immutability:
- Gateway Python dependencies install from `requirements.gateway.lock` with
`pip --require-hashes`; provider npm packages install with `npm ci` from
committed lockfiles.
- The Codex standalone installer is fetched from the selected Codex release
tag, checked against `install.sh.sha256`, invoked with an exact `--release`,
and then verifies the selected release archive against upstream metadata.
- The Codex standalone archive is fetched from the exact `CODEX_VERSION`
release and checked against the committed upstream
`codex-package_SHA256SUMS` before extraction.
The standalone layout is retained because Codex remote control requires it.
`Dockerfile.orchestrator.fc` is the one intentionally dynamic `FROM`. It has no
+1
View File
@@ -1,4 +1,5 @@
{
"DOCKER_CLI_BASE_IMAGE": "docker:28-cli@sha256:625d9431a9f54c5a2bc90f24f0e1c3d55b1349fd857dd85035f98c2c9acbdd4d",
"NODE_BASE_IMAGE": "node:22.23.1-trixie-slim@sha256:e6d9a389d34ff9678438af985c9913fbd1eb6ed36e80fea56644f4b4f6dd70ba",
"PYTHON_BASE_IMAGE": "python:3.12.13-slim-trixie@sha256:57cd7c3a7a273101a6485ba99423ee568157882804b1124b4dd04266317710de"
}
+1 -1
View File
@@ -41,7 +41,7 @@ bot_bottle = [
"contrib/claude/package.json",
"contrib/claude/package-lock.json",
"contrib/codex/Dockerfile",
"contrib/codex/install.sh.sha256",
"contrib/codex/codex-package_SHA256SUMS",
"contrib/pi/Dockerfile",
"contrib/pi/package.json",
"contrib/pi/package-lock.json",
+27 -3
View File
@@ -22,7 +22,11 @@ NPM_MANIFESTS = (
Path("bot_bottle/contrib/pi/package.json"),
)
IMAGE_BUILD_ARGS = Path("image-build-args.json")
REQUIRED_BASE_ARGS = frozenset({"NODE_BASE_IMAGE", "PYTHON_BASE_IMAGE"})
REQUIRED_BASE_ARGS = frozenset({
"DOCKER_CLI_BASE_IMAGE",
"NODE_BASE_IMAGE",
"PYTHON_BASE_IMAGE",
})
_DIGEST = re.compile(r"^[0-9a-f]{64}$")
_EXACT_NPM_VERSION = re.compile(
@@ -127,9 +131,9 @@ def check_dockerfile(
problems.append(f"{path}: gateway Python lock is not consumed")
if path == Path("bot_bottle/contrib/codex/Dockerfile"):
required = (
"install.sh.sha256",
"codex-package_SHA256SUMS",
"sha256sum -c",
'--release "${CODEX_VERSION}"',
"codex-package-${codex_target}.tar.gz",
)
for marker in required:
if marker not in instructions:
@@ -256,6 +260,26 @@ def check_repo(root: Path = REPO_ROOT) -> list[str]:
problems.extend(check_dockerfile(path, text, image_build_args))
for manifest in NPM_MANIFESTS:
problems.extend(check_npm_manifest(root, manifest))
nested_path = Path(
"bot_bottle/backend/macos_container/nested_containers.py",
)
try:
nested_source = (root / nested_path).read_text(encoding="utf-8")
except OSError as exc:
problems.append(f"{nested_path}: cannot read generated image source: {exc}")
else:
if "FROM docker:" in nested_source:
problems.append(
"nested-containers image uses a mutable Docker CLI base",
)
if '"ARG DOCKER_CLI_BASE_IMAGE\\n"' not in nested_source:
problems.append(
"nested-containers image does not consume DOCKER_CLI_BASE_IMAGE",
)
if "pin_local_base(base_image)" not in nested_source:
problems.append(
"nested-containers image does not pin its local agent base",
)
problems.extend(check_python_lock(root))
return problems
+23
View File
@@ -146,6 +146,29 @@ resolver #2
],
)
def test_pinned_local_image_ref_tags_and_verifies_exact_id(self):
image = "sha256:" + "a" * 64
completed = util.subprocess.CompletedProcess(
args=[], returncode=0, stdout="", stderr="",
)
with patch.object(util, "image_id", return_value=image) as inspect, \
patch.object(util.subprocess, "run", return_value=completed) as run:
pinned = util.pinned_local_image_ref("registry:5000/agent:latest")
self.assertEqual(
f"registry:5000/agent:sha256-{'a' * 64}",
pinned,
)
run.assert_called_once_with(
["container", "image", "tag", image, pinned],
capture_output=True,
text=True,
check=False,
)
self.assertEqual(
[("registry:5000/agent:latest",), (pinned,)],
[call.args for call in inspect.call_args_list],
)
def test_commit_container_execs_tar_and_builds_image(self):
# stderr is bytes because subprocess.run uses stderr=PIPE without text=True
completed = util.subprocess.CompletedProcess(
+20 -6
View File
@@ -107,12 +107,16 @@ class TestNestedContainersImage(unittest.TestCase):
def build(image: str, context: str, *, dockerfile: str) -> None:
calls.append((image, context, dockerfile))
text = Path(dockerfile).read_text(encoding="utf-8")
self.assertIn("FROM agent:base", text)
self.assertIn("ARG DOCKER_CLI_BASE_IMAGE", text)
self.assertIn("FROM ${DOCKER_CLI_BASE_IMAGE} AS docker_cli", text)
self.assertIn("FROM agent:sha256-deadbeef", text)
self.assertIn("aardvark-dns fuse-overlayfs netavark nftables passt podman", text)
self.assertIn("USER node", text)
self.assertTrue((Path(context) / "nested-containers-init.sh").is_file())
image = nested_containers.build_image("agent:base", build)
image = nested_containers.build_image(
"agent:base", build, lambda _ref: "agent:sha256-deadbeef",
)
self.assertEqual("agent:base-nested-containers", image)
self.assertEqual("agent:base-nested-containers", calls[0][0])
@@ -127,7 +131,9 @@ class TestNestedContainersImage(unittest.TestCase):
def build(_image: str, _context: str, *, dockerfile: str) -> None:
seen.append(Path(dockerfile).read_text(encoding="utf-8"))
nested_containers.build_image("agent:base", build)
nested_containers.build_image(
"agent:base", build, lambda _ref: "agent:sha256-deadbeef",
)
for package in ("podman", "passt", "nftables", "aardvark-dns"):
self.assertIn(package, seen[0])
@@ -143,7 +149,9 @@ class TestNestedContainersImage(unittest.TestCase):
def build(_image: str, _context: str, *, dockerfile: str) -> None:
seen.append(Path(dockerfile).read_text(encoding="utf-8"))
nested_containers.build_image("agent:base", build)
nested_containers.build_image(
"agent:base", build, lambda _ref: "agent:sha256-deadbeef",
)
text = seen[0]
self.assertIn("sed -i '/^node:/d' /etc/subuid /etc/subgid", text)
self.assertNotIn("subuid", text.replace(
@@ -286,7 +294,9 @@ class TestBuildOrLoadImages(unittest.TestCase):
"agent:base", str(launch_mod.resources.build_root()),
dockerfile="/repo/Dockerfile",
)
derived.assert_called_once_with("agent:base", build)
derived.assert_called_once_with(
"agent:base", build, launch_mod.container_mod.pinned_local_image_ref,
)
self.assertEqual("agent:base-nested-containers", images.agent)
def test_derived_image_layers_onto_a_committed_image(self) -> None:
@@ -307,7 +317,11 @@ class TestBuildOrLoadImages(unittest.TestCase):
images = launch_mod.build_or_load_images(plan)
build.assert_not_called()
derived.assert_called_once_with("agent:committed", build)
derived.assert_called_once_with(
"agent:committed",
build,
launch_mod.container_mod.pinned_local_image_ref,
)
self.assertEqual("agent:committed-nested-containers", images.agent)
def test_cached_policy_refuses_to_build_the_derived_image(self) -> None:
+15
View File
@@ -50,6 +50,21 @@ class TestCheckoutMode(unittest.TestCase):
r"^python:\d+\.\d+\.\d+-.+@sha256:[0-9a-f]{64}$",
)
def test_generated_dockerfile_uses_central_input_from_build_root(self):
with tempfile.TemporaryDirectory() as tmp:
dockerfile = Path(tmp) / "Dockerfile"
dockerfile.write_text(
"ARG DOCKER_CLI_BASE_IMAGE\n"
"FROM ${DOCKER_CLI_BASE_IMAGE}\n",
encoding="utf-8",
)
args = resources.image_build_args(dockerfile, context=tmp)
self.assertEqual({"DOCKER_CLI_BASE_IMAGE"}, set(args))
self.assertRegex(
args["DOCKER_CLI_BASE_IMAGE"],
r"^docker:\d+-cli@sha256:[0-9a-f]{64}$",
)
def test_bundled_resources_all_exist_at_root(self):
# Drift guard: every path setup.py bundles must exist in the checkout.
root = resources.build_root()