build: pin and verify image inputs
This commit is contained in:
+9
-5
@@ -36,11 +36,12 @@
|
||||
# 9420 git-gate smart HTTP (VM-backend agent-facing transport)
|
||||
# 9100 supervise (MCP HTTP)
|
||||
|
||||
# Based on `python:3.12-slim` (Debian trixie) rather than the
|
||||
# Based on an exact `python:3.12.13-slim-trixie` multi-architecture manifest
|
||||
# rather than the
|
||||
# `mitmproxy/mitmproxy` image (Debian bookworm), matching the trixie base the
|
||||
# orchestrator image needs for buildah (Dockerfile.orchestrator.fc). mitmproxy
|
||||
# is pip-installed to the same effect as the upstream image.
|
||||
FROM python:3.12-slim
|
||||
FROM python:3.12.13-slim-trixie@sha256:57cd7c3a7a273101a6485ba99423ee568157882804b1124b4dd04266317710de
|
||||
|
||||
# Runtime system deps:
|
||||
# git supplies the `git daemon` subcommand (no separate package)
|
||||
@@ -54,10 +55,13 @@ RUN apt-get update \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# mitmdump (the egress data plane). The upstream mitmproxy image baked
|
||||
# this in; on the plain python base we pip-install the same pinned
|
||||
# version. Its CA dir is set explicitly via `--set confdir=` in
|
||||
# this in; on the plain python base we install a fully resolved lock whose
|
||||
# distributions are all hash-verified. Its CA dir is set explicitly via
|
||||
# `--set confdir=` in
|
||||
# egress-entrypoint.sh, so it doesn't depend on a `mitmproxy` home user.
|
||||
RUN pip install --no-cache-dir mitmproxy==11.1.3
|
||||
COPY requirements.gateway.lock /tmp/requirements.gateway.lock
|
||||
RUN pip install --no-cache-dir --require-hashes \
|
||||
-r /tmp/requirements.gateway.lock
|
||||
|
||||
# gitleaks (the pre-receive hook's secret scanner). Installed from its
|
||||
# official release, pinned by version + SHA256 and verified — rather than
|
||||
|
||||
@@ -16,9 +16,11 @@
|
||||
# secret-dense control plane on a minimal dependency surface is the point
|
||||
# (PRD 0070's "secret concentration").
|
||||
#
|
||||
# Shares the trixie `python:3.12-slim` base with the gateway image.
|
||||
# Shares an exact multi-architecture Python/trixie manifest with the gateway
|
||||
# image. The version-qualified tag keeps the human-readable upstream version;
|
||||
# the digest makes the bytes immutable.
|
||||
|
||||
FROM python:3.12-slim
|
||||
FROM python:3.12.13-slim-trixie@sha256:57cd7c3a7a273101a6485ba99423ee568157882804b1124b4dd04266317710de
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -12,8 +12,11 @@
|
||||
# bare microVM (no fuse-overlayfs / overlay module / subuid maps). The trixie
|
||||
# base (from Dockerfile.orchestrator's python:3.12-slim) carries buildah 1.39,
|
||||
# which parses the Dockerfile heredocs agent images use (bookworm's 1.28 can't).
|
||||
# Matches image_builder.
|
||||
FROM bot-bottle-orchestrator:latest
|
||||
# Matches image_builder. There is deliberately no default: the build coordinator
|
||||
# passes the exact local image ID returned by `docker image inspect`, so this
|
||||
# stage cannot silently resolve a stale `:latest` tag.
|
||||
ARG ORCHESTRATOR_BASE_IMAGE
|
||||
FROM ${ORCHESTRATOR_BASE_IMAGE}
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
|
||||
@@ -4,5 +4,7 @@
|
||||
include Dockerfile.gateway
|
||||
include Dockerfile.orchestrator
|
||||
include Dockerfile.orchestrator.fc
|
||||
include requirements.gateway.in
|
||||
include requirements.gateway.lock
|
||||
include nix/firecracker-netpool.nix
|
||||
include scripts/firecracker-netpool.sh
|
||||
|
||||
@@ -119,7 +119,13 @@ def docker_cp(src: str, dest: str) -> None:
|
||||
f"{(result.stderr or '').strip() or '<no stderr>'}")
|
||||
|
||||
|
||||
def build_image(ref: str, context: str, *, dockerfile: str = "") -> None:
|
||||
def build_image(
|
||||
ref: str,
|
||||
context: str,
|
||||
*,
|
||||
dockerfile: str = "",
|
||||
build_args: dict[str, str] | None = None,
|
||||
) -> None:
|
||||
"""Invokes `docker build` every call. Layer cache makes no-change
|
||||
rebuilds cheap; running every time means Dockerfile edits land
|
||||
without manual `docker rmi`.
|
||||
@@ -139,10 +145,26 @@ def build_image(ref: str, context: str, *, dockerfile: str = "") -> None:
|
||||
args.append("--no-cache")
|
||||
if dockerfile:
|
||||
args.extend(["-f", dockerfile])
|
||||
for name, value in (build_args or {}).items():
|
||||
args.extend(["--build-arg", f"{name}={value}"])
|
||||
args.append(context)
|
||||
subprocess.run(args, check=True)
|
||||
|
||||
|
||||
def image_id(ref: str) -> str:
|
||||
"""Return the exact content-addressed ID for a local image.
|
||||
|
||||
This is used when one locally built image is another Dockerfile's base:
|
||||
passing the ID prevents a mutable tag from being resolved between builds.
|
||||
"""
|
||||
result = run_docker(["docker", "image", "inspect", "--format", "{{.Id}}", ref])
|
||||
image = result.stdout.strip()
|
||||
if result.returncode != 0 or not image.startswith("sha256:"):
|
||||
detail = (result.stderr or result.stdout or "").strip()
|
||||
die(f"could not resolve exact image ID for {ref!r}: {detail or '<no detail>'}")
|
||||
return image
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -49,9 +49,9 @@ _ARTIFACT_FORMAT = "1"
|
||||
# from its own generic package; the Dockerfiles baked into each differ (only the
|
||||
# orchestrator rootfs carries buildah), so the versions are hashed separately.
|
||||
ROLES = ("orchestrator", "gateway")
|
||||
_DOCKERFILES = {
|
||||
_BUILD_INPUTS = {
|
||||
"orchestrator": ("Dockerfile.orchestrator", "Dockerfile.orchestrator.fc"),
|
||||
"gateway": ("Dockerfile.gateway",),
|
||||
"gateway": ("Dockerfile.gateway", "requirements.gateway.lock"),
|
||||
}
|
||||
|
||||
_DEFAULT_BASE = "https://gitea.dideric.is"
|
||||
@@ -101,7 +101,7 @@ def infra_artifact_version(
|
||||
h.update(str(path.relative_to(repo_root)).encode())
|
||||
h.update(b"\0")
|
||||
h.update(path.read_bytes())
|
||||
for name in _DOCKERFILES[role]:
|
||||
for name in _BUILD_INPUTS[role]:
|
||||
h.update(name.encode())
|
||||
h.update(b"\0")
|
||||
h.update((repo_root / name).read_bytes())
|
||||
|
||||
@@ -133,10 +133,15 @@ 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)
|
||||
docker_mod.build_image(
|
||||
_GATEWAY_IMAGE, root, dockerfile="Dockerfile.gateway")
|
||||
docker_mod.build_image(
|
||||
_ORCHESTRATOR_FC_IMAGE, root, dockerfile="Dockerfile.orchestrator.fc")
|
||||
_ORCHESTRATOR_FC_IMAGE,
|
||||
root,
|
||||
dockerfile="Dockerfile.orchestrator.fc",
|
||||
build_args={"ORCHESTRATOR_BASE_IMAGE": orchestrator_id},
|
||||
)
|
||||
|
||||
|
||||
def build_rootfs_dir(role: str) -> Path:
|
||||
|
||||
@@ -8,9 +8,8 @@
|
||||
# Layer ordering is deliberate: the npm install lives in its own layer so
|
||||
# changes to the rest of the repo (or to the CMD) don't bust it.
|
||||
|
||||
# Current Node LTS; slim variant keeps the image small while still
|
||||
# providing apt-get for any future additions.
|
||||
FROM node:22-trixie-slim
|
||||
# Version-qualified Node LTS, pinned to its multi-architecture manifest.
|
||||
FROM node:22.23.1-trixie-slim@sha256:e6d9a389d34ff9678438af985c9913fbd1eb6ed36e80fea56644f4b4f6dd70ba
|
||||
|
||||
# Install runtime system deps. claude-code shells out to git for several
|
||||
# features (status checks, commits, PR creation) — without git in the
|
||||
@@ -39,11 +38,13 @@ RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends python3 python3-pip python3-venv \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install claude-code globally. Pinned to the version verified in the v1
|
||||
# build (`claude --version` returns 2.1.126). Bump deliberately when
|
||||
# rolling forward; an unpinned install would mean rebuilds silently pick
|
||||
# up new behavior.
|
||||
RUN npm install -g --no-fund --no-audit @anthropic-ai/claude-code@2.1.172 \
|
||||
# Install from the committed npm lock. `npm ci` verifies every registry
|
||||
# artifact against its lockfile integrity and refuses dependency drift.
|
||||
COPY bot_bottle/contrib/claude/package.json \
|
||||
bot_bottle/contrib/claude/package-lock.json /opt/claude/
|
||||
RUN cd /opt/claude \
|
||||
&& npm ci --omit=dev --no-fund --no-audit \
|
||||
&& ln -s /opt/claude/node_modules/.bin/claude /usr/local/bin/claude \
|
||||
&& npm cache clean --force
|
||||
|
||||
# Git reads both ~/.gitconfig and ~/.config/git/config. Keep its XDG config
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# Mirrors the default Claude image shape: Node LTS, git/network tooling,
|
||||
# non-root node user, and the provider CLI installed for that user.
|
||||
|
||||
FROM node:22-trixie-slim
|
||||
FROM node:22.23.1-trixie-slim@sha256:e6d9a389d34ff9678438af985c9913fbd1eb6ed36e80fea56644f4b4f6dd70ba
|
||||
|
||||
# The standalone installer is used below because remote-control requires its
|
||||
# managed package layout. Keep this exact release in sync with the verified
|
||||
@@ -35,10 +35,18 @@ WORKDIR /home/node
|
||||
|
||||
ENV PATH="/home/node/.local/bin:${PATH}"
|
||||
|
||||
# Remote-control support requires the standalone Codex install layout
|
||||
# under ~/.codex/packages/standalone/current. The npm package can run
|
||||
# the TUI, but remote-control commands expect this installer-owned path.
|
||||
RUN mkdir -p /home/node/.codex \
|
||||
&& curl -fsSL https://chatgpt.com/codex/install.sh | sh
|
||||
# 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}" \
|
||||
&& test "$(codex --version)" = "codex-cli ${CODEX_VERSION}"
|
||||
|
||||
CMD ["codex"]
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
#
|
||||
# Node LTS, git/network tooling, and the Pi coding-agent CLI installed globally.
|
||||
|
||||
FROM node:22-trixie-slim
|
||||
FROM node:22.23.1-trixie-slim@sha256:e6d9a389d34ff9678438af985c9913fbd1eb6ed36e80fea56644f4b4f6dd70ba
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
@@ -19,9 +19,6 @@ RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends python3 python3-pip python3-venv \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN npm install -g --ignore-scripts --no-fund --no-audit @earendil-works/pi-coding-agent \
|
||||
&& npm cache clean --force
|
||||
|
||||
RUN install -d -o node -g node -m 755 /home/node/.config /home/node/.config/git \
|
||||
&& mkdir -p /home/node/.pi/agent \
|
||||
/home/node/.pi/context-mode/sessions \
|
||||
@@ -34,10 +31,15 @@ RUN install -d -o node -g node -m 755 /home/node/.config /home/node/.config/git
|
||||
USER node
|
||||
WORKDIR /home/node
|
||||
|
||||
RUN pi install npm:@harms-haus/pi-cwd \
|
||||
&& pi install npm:pi-web-access \
|
||||
&& pi install npm:context-mode \
|
||||
&& pi install npm:pi-subagents \
|
||||
&& pi install npm:pi-mcp-adapter
|
||||
# Pi discovers npm packages from its agent directory. Installing the CLI and
|
||||
# extensions together from the committed lock makes all direct and transitive
|
||||
# package versions deterministic, with npm integrity verification.
|
||||
COPY --chown=node:node bot_bottle/contrib/pi/package.json \
|
||||
bot_bottle/contrib/pi/package-lock.json /home/node/.pi/agent/
|
||||
RUN cd /home/node/.pi/agent \
|
||||
&& npm ci --omit=dev --no-fund --no-audit \
|
||||
&& npm cache clean --force
|
||||
|
||||
ENV PATH="/home/node/.pi/agent/node_modules/.bin:${PATH}"
|
||||
|
||||
CMD ["pi"]
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-coding-agent": "0.81.1",
|
||||
"@earendil-works/pi-agent-core": "0.81.1",
|
||||
"@earendil-works/pi-ai": "0.81.1",
|
||||
"@earendil-works/pi-tui": "0.81.1",
|
||||
"@harms-haus/pi-cwd": "1.0.0",
|
||||
"context-mode": "1.0.169",
|
||||
"pi-mcp-adapter": "2.11.0",
|
||||
|
||||
@@ -40,6 +40,7 @@ BUNDLED_RESOURCES: tuple[str, ...] = (
|
||||
"Dockerfile.gateway",
|
||||
"Dockerfile.orchestrator",
|
||||
"Dockerfile.orchestrator.fc",
|
||||
"requirements.gateway.lock",
|
||||
"nix/firecracker-netpool.nix",
|
||||
"scripts/firecracker-netpool.sh",
|
||||
)
|
||||
|
||||
@@ -38,8 +38,13 @@ include = ["bot_bottle*"]
|
||||
bot_bottle = [
|
||||
"gateway/egress/entrypoint.sh",
|
||||
"contrib/claude/Dockerfile",
|
||||
"contrib/claude/package.json",
|
||||
"contrib/claude/package-lock.json",
|
||||
"contrib/codex/Dockerfile",
|
||||
"contrib/codex/install.sh.sha256",
|
||||
"contrib/pi/Dockerfile",
|
||||
"contrib/pi/package.json",
|
||||
"contrib/pi/package-lock.json",
|
||||
"backend/firecracker/netpool.defaults.env",
|
||||
"backend/macos_container/nested-containers-init.sh",
|
||||
]
|
||||
|
||||
@@ -24,6 +24,7 @@ _BUNDLED_RESOURCES = (
|
||||
"Dockerfile.gateway",
|
||||
"Dockerfile.orchestrator",
|
||||
"Dockerfile.orchestrator.fc",
|
||||
"requirements.gateway.lock",
|
||||
"nix/firecracker-netpool.nix",
|
||||
"scripts/firecracker-netpool.sh",
|
||||
)
|
||||
|
||||
@@ -121,5 +121,54 @@ class TestCommitContainer(unittest.TestCase):
|
||||
self.assertIn("my-tag:v1", die.call_args.args[0])
|
||||
|
||||
|
||||
class TestBuildImage(unittest.TestCase):
|
||||
def test_passes_build_args_without_mutating_the_value(self):
|
||||
with patch.object(
|
||||
docker_mod.subprocess, "run", return_value=_ok(),
|
||||
) as run, patch.object(docker_mod, "info"):
|
||||
docker_mod.build_image(
|
||||
"derived:test",
|
||||
"/context",
|
||||
dockerfile="Dockerfile.derived",
|
||||
build_args={"BASE_IMAGE": "sha256:" + "a" * 64},
|
||||
)
|
||||
self.assertEqual(
|
||||
[
|
||||
"docker", "build", "-t", "derived:test",
|
||||
"-f", "Dockerfile.derived",
|
||||
"--build-arg", "BASE_IMAGE=sha256:" + "a" * 64,
|
||||
"/context",
|
||||
],
|
||||
run.call_args.args[0],
|
||||
)
|
||||
|
||||
|
||||
class TestImageId(unittest.TestCase):
|
||||
def test_returns_content_addressed_local_id(self):
|
||||
expected = "sha256:" + "b" * 64
|
||||
with patch.object(
|
||||
docker_mod, "run_docker", return_value=_ok(stdout=expected + "\n"),
|
||||
) as run:
|
||||
self.assertEqual(expected, docker_mod.image_id("base:mutable"))
|
||||
self.assertEqual(
|
||||
[
|
||||
"docker", "image", "inspect", "--format", "{{.Id}}",
|
||||
"base:mutable",
|
||||
],
|
||||
run.call_args.args[0],
|
||||
)
|
||||
|
||||
def test_rejects_failed_or_non_content_addressed_inspect(self):
|
||||
for result in (_fail("missing"), _ok(stdout="base:latest\n")):
|
||||
with self.subTest(result=result), patch.object(
|
||||
docker_mod, "run_docker", return_value=result,
|
||||
), patch.object(
|
||||
docker_mod, "die", side_effect=SystemExit("die"),
|
||||
) as die:
|
||||
with self.assertRaises(SystemExit):
|
||||
docker_mod.image_id("base:latest")
|
||||
die.assert_called_once()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -107,7 +107,12 @@ class TestEnsureBuilt(unittest.TestCase):
|
||||
|
||||
def test_local_mode_builds_orchestrator_before_its_fc_image(self):
|
||||
with patch.dict(os.environ, {"BOT_BOTTLE_INFRA_BUILD": "local"}), \
|
||||
patch.object(infra_vm.docker_mod, "build_image") as build:
|
||||
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:
|
||||
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.
|
||||
@@ -116,6 +121,11 @@ 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)
|
||||
self.assertEqual(
|
||||
{"ORCHESTRATOR_BASE_IMAGE": "sha256:" + "a" * 64},
|
||||
build.call_args_list[-1].kwargs["build_args"],
|
||||
)
|
||||
|
||||
|
||||
class TestStop(unittest.TestCase):
|
||||
|
||||
@@ -110,6 +110,7 @@ class TestVersionInputs(unittest.TestCase):
|
||||
for name in ("Dockerfile.orchestrator", "Dockerfile.orchestrator.fc",
|
||||
"Dockerfile.gateway"):
|
||||
(root / name).write_text(f"FROM scratch # {name}\n")
|
||||
(root / "requirements.gateway.lock").write_text("mitmproxy==11.1.3\n")
|
||||
(root / "pyproject.toml").write_text("[project]\nname = 'bot-bottle'\n")
|
||||
|
||||
def test_pyproject_toml_change_bumps_version(self) -> None:
|
||||
@@ -146,6 +147,21 @@ class TestVersionInputs(unittest.TestCase):
|
||||
after = ia.infra_artifact_version("init", _ROLE, repo_root=root)
|
||||
self.assertNotEqual(before, after)
|
||||
|
||||
def test_gateway_lock_change_bumps_gateway_version(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
root = Path(d)
|
||||
self._fake_repo(root)
|
||||
before = ia.infra_artifact_version(
|
||||
"init", "gateway", repo_root=root,
|
||||
)
|
||||
(root / "requirements.gateway.lock").write_text(
|
||||
"mitmproxy==11.1.3 --hash=sha256:changed\n",
|
||||
)
|
||||
after = ia.infra_artifact_version(
|
||||
"init", "gateway", repo_root=root,
|
||||
)
|
||||
self.assertNotEqual(before, after)
|
||||
|
||||
def test_pyc_and_pycache_ignored(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
root = Path(d)
|
||||
|
||||
Reference in New Issue
Block a user