build: centralize pinned base image arguments

This commit is contained in:
2026-07-26 17:09:15 +00:00
committed by didericis
parent 9e83ff1992
commit 33b7bcd082
31 changed files with 467 additions and 48 deletions
+1
View File
@@ -10,6 +10,7 @@ on:
- "bot_bottle/contrib/*/package-lock.json" - "bot_bottle/contrib/*/package-lock.json"
- "bot_bottle/contrib/codex/install.sh.sha256" - "bot_bottle/contrib/codex/install.sh.sha256"
- "requirements.gateway.*" - "requirements.gateway.*"
- "image-build-args.json"
- ".pylintrc" - ".pylintrc"
- ".gitea/workflows/lint.yml" - ".gitea/workflows/lint.yml"
+20 -2
View File
@@ -13,6 +13,7 @@ on:
- 'bot_bottle/contrib/claude/package.json' - 'bot_bottle/contrib/claude/package.json'
- 'bot_bottle/contrib/pi/package.json' - 'bot_bottle/contrib/pi/package.json'
- 'bot_bottle/contrib/codex/Dockerfile' - 'bot_bottle/contrib/codex/Dockerfile'
- 'image-build-args.json'
- '.gitea/workflows/refresh-image-locks.yml' - '.gitea/workflows/refresh-image-locks.yml'
permissions: permissions:
@@ -25,15 +26,32 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Resolve image runtime versions
id: runtimes
run: |
python3 - <<'PY' >> "$GITHUB_OUTPUT"
import json
import re
inputs = json.load(open("image-build-args.json"))
for output, name in (
("python-version", "PYTHON_BASE_IMAGE"),
("node-version", "NODE_BASE_IMAGE"),
):
match = re.search(r":(\d+\.\d+\.\d+)-", inputs[name])
if match is None:
raise SystemExit(f"cannot resolve runtime version from {name}")
print(f"{output}={match.group(1)}")
PY
- name: Use the image Python version - name: Use the image Python version
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
python-version: '3.12.13' python-version: '${{ steps.runtimes.outputs.python-version }}'
- name: Use the image Node version - name: Use the image Node version
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: '22.23.1' node-version: '${{ steps.runtimes.outputs.node-version }}'
- name: Compile gateway Python lock - name: Compile gateway Python lock
run: | run: |
+23 -7
View File
@@ -22,6 +22,7 @@ on:
- 'scripts/**/*.py' - 'scripts/**/*.py'
- 'scripts/firecracker-netpool.sh' - 'scripts/firecracker-netpool.sh'
- 'Dockerfile*' - 'Dockerfile*'
- 'image-build-args.json'
- 'pyproject.toml' - 'pyproject.toml'
- 'requirements-dev.txt' - 'requirements-dev.txt'
- 'requirements.gateway.*' - 'requirements.gateway.*'
@@ -45,6 +46,7 @@ on:
- 'scripts/**/*.py' - 'scripts/**/*.py'
- 'scripts/firecracker-netpool.sh' - 'scripts/firecracker-netpool.sh'
- 'Dockerfile*' - 'Dockerfile*'
- 'image-build-args.json'
- 'pyproject.toml' - 'pyproject.toml'
- 'requirements-dev.txt' - 'requirements-dev.txt'
- 'requirements.gateway.*' - 'requirements.gateway.*'
@@ -149,8 +151,13 @@ jobs:
- name: Verify shared bases cover supported architectures - name: Verify shared bases cover supported architectures
run: | run: |
python_ref='python:3.12.13-slim-trixie@sha256:57cd7c3a7a273101a6485ba99423ee568157882804b1124b4dd04266317710de' set -euo pipefail
node_ref='node:22.23.1-trixie-slim@sha256:e6d9a389d34ff9678438af985c9913fbd1eb6ed36e80fea56644f4b4f6dd70ba' python_ref=$(python3 -c \
'import json; print(json.load(open("image-build-args.json"))["PYTHON_BASE_IMAGE"])')
node_ref=$(python3 -c \
'import json; print(json.load(open("image-build-args.json"))["NODE_BASE_IMAGE"])')
test -n "$python_ref"
test -n "$node_ref"
for ref in "$python_ref" "$node_ref"; do for ref in "$python_ref" "$node_ref"; do
docker buildx imagetools inspect --raw "$ref" | docker buildx imagetools inspect --raw "$ref" |
python3 -c ' python3 -c '
@@ -179,8 +186,13 @@ jobs:
claude="bot-bottle-claude-inputs:${suffix}" claude="bot-bottle-claude-inputs:${suffix}"
codex="bot-bottle-codex-inputs:${suffix}" codex="bot-bottle-codex-inputs:${suffix}"
pi="bot-bottle-pi-inputs:${suffix}" pi="bot-bottle-pi-inputs:${suffix}"
python_base=$(python3 -c \
'import json; print(json.load(open("image-build-args.json"))["PYTHON_BASE_IMAGE"])')
node_base=$(python3 -c \
'import json; print(json.load(open("image-build-args.json"))["NODE_BASE_IMAGE"])')
docker build -t "$orchestrator" -f Dockerfile.orchestrator . docker build --build-arg "PYTHON_BASE_IMAGE=$python_base" \
-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:}" orchestrator_base="bot-bottle-orchestrator-inputs:sha256-${orchestrator_id#sha256:}"
@@ -188,13 +200,17 @@ jobs:
test "$( test "$(
docker image inspect --format '{{.Id}}' "$orchestrator_base" docker image inspect --format '{{.Id}}' "$orchestrator_base"
)" = "$orchestrator_id" )" = "$orchestrator_id"
docker build -t "$gateway" -f Dockerfile.gateway . docker build --build-arg "PYTHON_BASE_IMAGE=$python_base" \
-t "$gateway" -f Dockerfile.gateway .
docker build \ docker build \
--build-arg "ORCHESTRATOR_BASE_IMAGE=$orchestrator_base" \ --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 --build-arg "NODE_BASE_IMAGE=$node_base" \
docker build -t "$codex" -f bot_bottle/contrib/codex/Dockerfile . -t "$claude" -f bot_bottle/contrib/claude/Dockerfile .
docker build -t "$pi" -f bot_bottle/contrib/pi/Dockerfile . docker build --build-arg "NODE_BASE_IMAGE=$node_base" \
-t "$codex" -f bot_bottle/contrib/codex/Dockerfile .
docker build --build-arg "NODE_BASE_IMAGE=$node_base" \
-t "$pi" -f bot_bottle/contrib/pi/Dockerfile .
docker run --rm --entrypoint python3 "$orchestrator" -c \ docker run --rm --entrypoint python3 "$orchestrator" -c \
'import bot_bottle.orchestrator' 'import bot_bottle.orchestrator'
+2 -1
View File
@@ -41,7 +41,8 @@
# `mitmproxy/mitmproxy` image (Debian bookworm), matching the trixie base the # `mitmproxy/mitmproxy` image (Debian bookworm), matching the trixie base the
# orchestrator image needs for buildah (Dockerfile.orchestrator.fc). mitmproxy # orchestrator image needs for buildah (Dockerfile.orchestrator.fc). mitmproxy
# is pip-installed to the same effect as the upstream image. # is pip-installed to the same effect as the upstream image.
FROM python:3.12.13-slim-trixie@sha256:57cd7c3a7a273101a6485ba99423ee568157882804b1124b4dd04266317710de ARG PYTHON_BASE_IMAGE
FROM ${PYTHON_BASE_IMAGE}
# Freeze apt's package universe as well as the base filesystem. Without a # Freeze apt's package universe as well as the base filesystem. Without a
# snapshot, the same Dockerfile resolves different package versions over time. # snapshot, the same Dockerfile resolves different package versions over time.
+2 -1
View File
@@ -20,7 +20,8 @@
# image. The version-qualified tag keeps the human-readable upstream version; # image. The version-qualified tag keeps the human-readable upstream version;
# the digest makes the bytes immutable. # the digest makes the bytes immutable.
FROM python:3.12.13-slim-trixie@sha256:57cd7c3a7a273101a6485ba99423ee568157882804b1124b4dd04266317710de ARG PYTHON_BASE_IMAGE
FROM ${PYTHON_BASE_IMAGE}
WORKDIR /app WORKDIR /app
+1
View File
@@ -4,6 +4,7 @@
include Dockerfile.gateway include Dockerfile.gateway
include Dockerfile.orchestrator include Dockerfile.orchestrator
include Dockerfile.orchestrator.fc include Dockerfile.orchestrator.fc
include image-build-args.json
include requirements.gateway.in include requirements.gateway.in
include requirements.gateway.lock include requirements.gateway.lock
include nix/firecracker-netpool.nix include nix/firecracker-netpool.nix
+5
View File
@@ -96,6 +96,11 @@ class DockerGateway(Gateway):
str(context)] str(context)]
if os.environ.get("BOT_BOTTLE_NO_CACHE"): if os.environ.get("BOT_BOTTLE_NO_CACHE"):
argv.insert(2, "--no-cache") argv.insert(2, "--no-cache")
for name, value in resources.image_build_args(
self._dockerfile,
context=context,
).items():
argv[-1:-1] = ["--build-arg", f"{name}={value}"]
proc = run_docker(argv) proc = run_docker(argv)
if proc.returncode != 0: if proc.returncode != 0:
raise GatewayError(f"gateway image build failed: {proc.stderr.strip()}") raise GatewayError(f"gateway image build failed: {proc.stderr.strip()}")
@@ -131,6 +131,11 @@ class DockerOrchestrator(Orchestrator):
str(self._repo_root)] str(self._repo_root)]
if os.environ.get("BOT_BOTTLE_NO_CACHE"): if os.environ.get("BOT_BOTTLE_NO_CACHE"):
argv.insert(2, "--no-cache") argv.insert(2, "--no-cache")
for name, value in resources.image_build_args(
self._dockerfile,
context=self._repo_root,
).items():
argv[-1:-1] = ["--build-arg", f"{name}={value}"]
proc = run_docker(argv) proc = run_docker(argv)
if proc.returncode != 0: if proc.returncode != 0:
raise GatewayError( raise GatewayError(
+7 -1
View File
@@ -10,6 +10,7 @@ import shutil
import subprocess import subprocess
from typing import Iterator from typing import Iterator
from ... import resources
from ...log import die, info from ...log import die, info
from ...util import slugify as _slugify from ...util import slugify as _slugify
@@ -145,7 +146,12 @@ def build_image(
args.append("--no-cache") args.append("--no-cache")
if dockerfile: if dockerfile:
args.extend(["-f", dockerfile]) args.extend(["-f", dockerfile])
for name, value in (build_args or {}).items(): effective_build_args = resources.image_build_args(
dockerfile,
context=context,
) if dockerfile else {}
effective_build_args.update(build_args or {})
for name, value in effective_build_args.items():
args.extend(["--build-arg", f"{name}={value}"]) args.extend(["--build-arg", f"{name}={value}"])
args.append(context) args.append(context)
subprocess.run(args, check=True) subprocess.run(args, check=True)
@@ -19,12 +19,14 @@ from __future__ import annotations
import fcntl import fcntl
import hashlib import hashlib
import os import os
import shlex
import shutil import shutil
import subprocess import subprocess
from contextlib import contextmanager from contextlib import contextmanager
from pathlib import Path from pathlib import Path
from typing import Generator from typing import Generator
from ... import resources
from ...log import die, info from ...log import die, info
from . import util from . import util
from .infra import FirecrackerInfraService from .infra import FirecrackerInfraService
@@ -55,6 +57,11 @@ def _rootfs_digest(dockerfile: Path) -> str:
h = hashlib.sha256() h = hashlib.sha256()
h.update(_dockerfile_hash(dockerfile).encode()) h.update(_dockerfile_hash(dockerfile).encode())
h.update(b"\0") h.update(b"\0")
for name, value in resources.image_build_args(dockerfile).items():
h.update(name.encode())
h.update(b"=")
h.update(value.encode())
h.update(b"\0")
h.update(util._GUEST_INIT.encode()) h.update(util._GUEST_INIT.encode())
return h.hexdigest()[:16] return h.hexdigest()[:16]
@@ -147,7 +154,13 @@ def _build_in_infra(
if prep.returncode != 0: if prep.returncode != 0:
die(f"preparing build dir in the infra VM failed: {prep.stderr.strip()}") die(f"preparing build dir in the infra VM failed: {prep.stderr.strip()}")
_send_dockerfile(key, ip, dockerfile, ctx) _send_dockerfile(key, ip, dockerfile, ctx)
_buildah_build(key, ip, ctx, tag) _buildah_build(
key,
ip,
ctx,
tag,
resources.image_build_args(dockerfile),
)
_smoke_test(key, ip, tag, smoke_ctr, smoke_test) _smoke_test(key, ip, tag, smoke_ctr, smoke_test)
_stream_rootfs(key, ip, tag, export_ctr, base) _stream_rootfs(key, ip, tag, export_ctr, base)
finally: finally:
@@ -184,15 +197,26 @@ def _send_dockerfile(private_key: Path, guest_ip: str, dockerfile: Path, ctx: st
f"{proc.stderr.decode(errors='replace').strip()}") f"{proc.stderr.decode(errors='replace').strip()}")
def _buildah_build(private_key: Path, guest_ip: str, ctx: str, tag: str) -> None: def _buildah_build(
private_key: Path,
guest_ip: str,
ctx: str,
tag: str,
build_args: dict[str, str],
) -> None:
# Stream buildah's step-by-step output straight to our stderr (like the # Stream buildah's step-by-step output straight to our stderr (like the
# docker backend's `docker build`), so a long first build (base pull + # docker backend's `docker build`), so a long first build (base pull +
# apt/npm installs) shows live progress instead of a silent wait. The # apt/npm installs) shows live progress instead of a silent wait. The
# remote stderr is where buildah writes its `STEP i/n` lines. # remote stderr is where buildah writes its `STEP i/n` lines.
info(f"buildah build {tag} in the infra VM (streaming output)") info(f"buildah build {tag} in the infra VM (streaming output)")
arg_flags = " ".join(
f"--build-arg {shlex.quote(f'{name}={value}')}"
for name, value in build_args.items()
)
rc = _ssh_streamed( rc = _ssh_streamed(
private_key, guest_ip, private_key, guest_ip,
f"buildah build {_BUILD_FLAGS} -t {tag} -f {ctx}/Dockerfile {ctx}/ctx", f"buildah build {_BUILD_FLAGS} {arg_flags} "
f"-t {tag} -f {ctx}/Dockerfile {ctx}/ctx",
timeout=_BUILD_TIMEOUT_SECONDS, timeout=_BUILD_TIMEOUT_SECONDS,
) )
if rc != 0: if rc != 0:
@@ -50,8 +50,16 @@ _ARTIFACT_FORMAT = "1"
# orchestrator rootfs carries buildah), so the versions are hashed separately. # orchestrator rootfs carries buildah), so the versions are hashed separately.
ROLES = ("orchestrator", "gateway") ROLES = ("orchestrator", "gateway")
_BUILD_INPUTS = { _BUILD_INPUTS = {
"orchestrator": ("Dockerfile.orchestrator", "Dockerfile.orchestrator.fc"), "orchestrator": (
"gateway": ("Dockerfile.gateway", "requirements.gateway.lock"), "image-build-args.json",
"Dockerfile.orchestrator",
"Dockerfile.orchestrator.fc",
),
"gateway": (
"image-build-args.json",
"Dockerfile.gateway",
"requirements.gateway.lock",
),
} }
_DEFAULT_BASE = "https://gitea.dideric.is" _DEFAULT_BASE = "https://gitea.dideric.is"
+15 -1
View File
@@ -13,6 +13,7 @@ import time
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Iterable from typing import Iterable
from ... import resources
from ...log import die, info from ...log import die, info
@@ -60,7 +61,13 @@ def dns_server() -> str:
return _host_ipv4_dns() or _DEFAULT_DNS return _host_ipv4_dns() or _DEFAULT_DNS
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:
"""Build an OCI image with Apple's BuildKit-backed `container build`. """Build an OCI image with Apple's BuildKit-backed `container build`.
Set `BOT_BOTTLE_NO_CACHE=1` (the `start --no-cache` flag) to force Set `BOT_BOTTLE_NO_CACHE=1` (the `start --no-cache` flag) to force
@@ -83,6 +90,13 @@ def build_image(ref: str, context: str, *, dockerfile: str = "") -> None:
if not os.path.isabs(dockerfile): if not os.path.isabs(dockerfile):
dockerfile = os.path.join(context, dockerfile) dockerfile = os.path.join(context, dockerfile)
args.extend(["-f", dockerfile]) args.extend(["-f", dockerfile])
effective_build_args = resources.image_build_args(
dockerfile,
context=context,
) if dockerfile else {}
effective_build_args.update(build_args or {})
for name, value in effective_build_args.items():
args.extend(["--build-arg", f"{name}={value}"])
args.append(context) args.append(context)
subprocess.run(args, check=True) subprocess.run(args, check=True)
+2 -1
View File
@@ -9,7 +9,8 @@
# changes to the rest of the repo (or to the CMD) don't bust it. # changes to the rest of the repo (or to the CMD) don't bust it.
# Version-qualified Node LTS, pinned to its multi-architecture manifest. # Version-qualified Node LTS, pinned to its multi-architecture manifest.
FROM node:22.23.1-trixie-slim@sha256:e6d9a389d34ff9678438af985c9913fbd1eb6ed36e80fea56644f4b4f6dd70ba ARG NODE_BASE_IMAGE
FROM ${NODE_BASE_IMAGE}
ARG DEBIAN_SNAPSHOT=20260724T000000Z ARG DEBIAN_SNAPSHOT=20260724T000000Z
RUN sed -i \ RUN sed -i \
+2 -1
View File
@@ -3,7 +3,8 @@
# Mirrors the default Claude image shape: Node LTS, git/network tooling, # Mirrors the default Claude image shape: Node LTS, git/network tooling,
# non-root node user, and the provider CLI installed for that user. # non-root node user, and the provider CLI installed for that user.
FROM node:22.23.1-trixie-slim@sha256:e6d9a389d34ff9678438af985c9913fbd1eb6ed36e80fea56644f4b4f6dd70ba ARG NODE_BASE_IMAGE
FROM ${NODE_BASE_IMAGE}
# The standalone installer is used below because remote-control requires its # The standalone installer is used below because remote-control requires its
# managed package layout. Keep this exact release in sync with the verified # managed package layout. Keep this exact release in sync with the verified
+2 -1
View File
@@ -2,7 +2,8 @@
# #
# Node LTS, git/network tooling, and the Pi coding-agent CLI installed globally. # Node LTS, git/network tooling, and the Pi coding-agent CLI installed globally.
FROM node:22.23.1-trixie-slim@sha256:e6d9a389d34ff9678438af985c9913fbd1eb6ed36e80fea56644f4b4f6dd70ba ARG NODE_BASE_IMAGE
FROM ${NODE_BASE_IMAGE}
ARG DEBIAN_SNAPSHOT=20260724T000000Z ARG DEBIAN_SNAPSHOT=20260724T000000Z
RUN sed -i \ RUN sed -i \
+58
View File
@@ -20,7 +20,9 @@ from __future__ import annotations
import fcntl import fcntl
import hashlib import hashlib
import json
import os import os
import re
import shutil import shutil
import tempfile import tempfile
from pathlib import Path from pathlib import Path
@@ -37,6 +39,7 @@ _BUNDLED = _PKG / "_resources" # wheel-shipped copies
# the two lists in sync (``test_resources`` guards that every entry exists). # the two lists in sync (``test_resources`` guards that every entry exists).
BUNDLED_RESOURCES: tuple[str, ...] = ( BUNDLED_RESOURCES: tuple[str, ...] = (
"pyproject.toml", "pyproject.toml",
"image-build-args.json",
"Dockerfile.gateway", "Dockerfile.gateway",
"Dockerfile.orchestrator", "Dockerfile.orchestrator",
"Dockerfile.orchestrator.fc", "Dockerfile.orchestrator.fc",
@@ -48,6 +51,8 @@ BUNDLED_RESOURCES: tuple[str, ...] = (
# Present at a checkout root, never in a bare installed package — the cheap # Present at a checkout root, never in a bare installed package — the cheap
# tell for which layout we're in. # tell for which layout we're in.
_CHECKOUT_MARKER = "Dockerfile.gateway" _CHECKOUT_MARKER = "Dockerfile.gateway"
_IMAGE_BUILD_ARGS_FILE = "image-build-args.json"
_CENTRAL_BUILD_ARG_NAMES = frozenset({"NODE_BASE_IMAGE", "PYTHON_BASE_IMAGE"})
class ResourceError(RuntimeError): class ResourceError(RuntimeError):
@@ -79,6 +84,59 @@ def dockerfile(name: str) -> Path:
return build_root() / name return build_root() / name
def image_build_args(
dockerfile_path: str | Path,
*,
context: str | Path | None = None,
) -> dict[str, str]:
"""Return centralized arguments declared by ``dockerfile_path``.
Base-image arguments deliberately have no Dockerfile defaults. Their
digest-pinned values live in one repository input file and every supported
build path calls this helper before invoking its OCI builder. Explicit
caller-supplied arguments may still override this returned mapping.
"""
path = Path(dockerfile_path)
if not path.is_absolute() and context is not None:
path = Path(context) / path
try:
text = path.read_text(encoding="utf-8")
except OSError:
# Generic callers and tests may build an ephemeral Dockerfile outside
# bot-bottle. The builder will report a genuinely missing file.
return {}
declared = set(re.findall(
r"(?m)^\s*ARG\s+([A-Za-z_][A-Za-z0-9_]*)\s*$",
text,
))
wanted = declared & _CENTRAL_BUILD_ARG_NAMES
if not wanted:
return {}
root = Path(context) if context is not None else build_root()
inputs_path = root / _IMAGE_BUILD_ARGS_FILE
try:
inputs = json.loads(inputs_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise ResourceError(
f"cannot read centralized image build arguments from {inputs_path}: {exc}"
) from exc
if not isinstance(inputs, dict):
raise ResourceError(f"{inputs_path} must contain a JSON object")
missing = wanted - inputs.keys()
if missing:
raise ResourceError(
f"{inputs_path} lacks required image build arguments: "
f"{', '.join(sorted(missing))}"
)
invalid = [name for name in wanted if not isinstance(inputs[name], str)]
if invalid:
raise ResourceError(
f"{inputs_path} has non-string image build arguments: "
f"{', '.join(sorted(invalid))}"
)
return {name: inputs[name] for name in sorted(wanted)}
def nix_netpool_module() -> Path: def nix_netpool_module() -> Path:
"""Absolute path to the firecracker netpool NixOS module.""" """Absolute path to the firecracker netpool NixOS module."""
return build_root() / "nix" / "firecracker-netpool.nix" return build_root() / "nix" / "firecracker-netpool.nix"
+7 -5
View File
@@ -4,8 +4,10 @@ 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 inputs on Linux amd64 and arm64. The repository enforces four layers of
immutability: immutability:
- Python and Node base images use version-qualified tags plus multi-platform - Python and Node base images are required, defaultless Docker build arguments.
OCI index digests. 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.
- 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`. The snapshot endpoint uses HTTP so a 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 fresh image does not need a host-specific TLS interception CA; APT still
@@ -31,9 +33,9 @@ BuildKit, which treats a bare image ID in `FROM` as a registry repository.
Make refreshes on a feature branch and review them like an application Make refreshes on a feature branch and review them like an application
dependency update: dependency update:
1. For a Python or Node base update, select a version-qualified tag and record 1. For a Python or Node base update, select a version-qualified tag and update
its multi-platform index digest in every Dockerfile that shares the base. its one digest-pinned value in `image-build-args.json`. Confirm the index
Confirm the index still contains `linux/amd64` and `linux/arm64`. still contains `linux/amd64` and `linux/arm64`.
2. For Debian packages, advance `DEBIAN_SNAPSHOT` to one fixed UTC timestamp in 2. For Debian packages, advance `DEBIAN_SNAPSHOT` to one fixed UTC timestamp in
every Dockerfile that uses apt. Do not use the moving Debian mirrors. every Dockerfile that uses apt. Do not use the moving Debian mirrors.
3. Change direct Python versions in `requirements.gateway.in`, direct npm 3. Change direct Python versions in `requirements.gateway.in`, direct npm
+4
View File
@@ -0,0 +1,4 @@
{
"NODE_BASE_IMAGE": "node:22.23.1-trixie-slim@sha256:e6d9a389d34ff9678438af985c9913fbd1eb6ed36e80fea56644f4b4f6dd70ba",
"PYTHON_BASE_IMAGE": "python:3.12.13-slim-trixie@sha256:57cd7c3a7a273101a6485ba99423ee568157882804b1124b4dd04266317710de"
}
+73 -8
View File
@@ -21,6 +21,8 @@ NPM_MANIFESTS = (
Path("bot_bottle/contrib/claude/package.json"), Path("bot_bottle/contrib/claude/package.json"),
Path("bot_bottle/contrib/pi/package.json"), 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"})
_DIGEST = re.compile(r"^[0-9a-f]{64}$") _DIGEST = re.compile(r"^[0-9a-f]{64}$")
_EXACT_NPM_VERSION = re.compile( _EXACT_NPM_VERSION = re.compile(
@@ -38,7 +40,11 @@ def _logical_lines(text: str) -> str:
return re.sub(r"\\\r?\n", " ", text) return re.sub(r"\\\r?\n", " ", text)
def check_dockerfile(path: Path, text: str) -> list[str]: def check_dockerfile(
path: Path,
text: str,
image_build_args: dict[str, str] | None = None,
) -> list[str]:
"""Return policy violations for one Dockerfile.""" """Return policy violations for one Dockerfile."""
problems: list[str] = [] problems: list[str] = []
logical = _logical_lines(text) logical = _logical_lines(text)
@@ -46,19 +52,47 @@ def check_dockerfile(path: Path, text: str) -> list[str]:
line for line in logical.splitlines() line for line in logical.splitlines()
if not line.lstrip().startswith("#") if not line.lstrip().startswith("#")
) )
from_values = re.findall(r"(?im)^\s*FROM\s+(\S+)", logical) from_matches = list(re.finditer(r"(?im)^\s*FROM\s+(\S+)", logical))
from_values = [match.group(1) for match in from_matches]
preamble = logical[:from_matches[0].start()] if from_matches else logical
base_args = {
match.group(1): match.group(2)
for match in re.finditer(
r"(?im)^\s*ARG\s+([A-Za-z_][A-Za-z0-9_]*)(?:=(\S+))?\s*$",
preamble,
)
}
if not from_values: if not from_values:
problems.append(f"{path}: missing FROM") problems.append(f"{path}: missing FROM")
for value in from_values: for value in from_values:
if value == "${ORCHESTRATOR_BASE_IMAGE}": variable = re.fullmatch(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}", value)
if not re.search( if variable and variable.group(1) == "ORCHESTRATOR_BASE_IMAGE":
r"(?m)^\s*ARG\s+ORCHESTRATOR_BASE_IMAGE\s*$", if (
text, "ORCHESTRATOR_BASE_IMAGE" not in base_args
or base_args["ORCHESTRATOR_BASE_IMAGE"] is not None
): ):
problems.append( problems.append(
f"{path}: local base argument must have no mutable default", f"{path}: local base argument must have no mutable default",
) )
continue continue
if variable:
name = variable.group(1)
if name not in base_args:
problems.append(
f"{path}: dynamic base argument {name} is not declared before FROM",
)
continue
if base_args[name] is not None:
problems.append(
f"{path}: base argument {name} must not define a default",
)
continue
if image_build_args is None or name not in image_build_args:
problems.append(
f"{path}: base argument {name} lacks a centralized value",
)
continue
value = image_build_args[name]
if "$" in value: if "$" in value:
problems.append(f"{path}: dynamic base image is not content-pinned: {value}") problems.append(f"{path}: dynamic base image is not content-pinned: {value}")
continue continue
@@ -103,6 +137,37 @@ def check_dockerfile(path: Path, text: str) -> list[str]:
return problems return problems
def load_image_build_args(root: Path) -> tuple[dict[str, str], list[str]]:
"""Load and validate the one repository location for base-image args."""
path = root / IMAGE_BUILD_ARGS
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
return {}, [f"{IMAGE_BUILD_ARGS}: cannot read image build arguments: {exc}"]
if not isinstance(data, dict):
return {}, [f"{IMAGE_BUILD_ARGS}: must contain a JSON object"]
problems: list[str] = []
names = set(data)
missing = REQUIRED_BASE_ARGS - names
unexpected = names - REQUIRED_BASE_ARGS
if missing:
problems.append(
f"{IMAGE_BUILD_ARGS}: missing arguments: {', '.join(sorted(missing))}",
)
if unexpected:
problems.append(
f"{IMAGE_BUILD_ARGS}: unexpected arguments: "
f"{', '.join(sorted(unexpected))}",
)
for name, value in data.items():
if not isinstance(value, str):
problems.append(f"{IMAGE_BUILD_ARGS}: {name} must be a string")
return {
name: value for name, value in data.items()
if isinstance(name, str) and isinstance(value, str)
}, problems
def check_npm_manifest(root: Path, manifest_path: Path) -> list[str]: def check_npm_manifest(root: Path, manifest_path: Path) -> list[str]:
"""Validate exact direct versions and integrity-complete npm locks.""" """Validate exact direct versions and integrity-complete npm locks."""
problems: list[str] = [] problems: list[str] = []
@@ -181,14 +246,14 @@ def check_python_lock(root: Path) -> list[str]:
def check_repo(root: Path = REPO_ROOT) -> list[str]: def check_repo(root: Path = REPO_ROOT) -> list[str]:
"""Return every repository image-input policy violation.""" """Return every repository image-input policy violation."""
problems: list[str] = [] image_build_args, problems = load_image_build_args(root)
for path in DOCKERFILES: for path in DOCKERFILES:
try: try:
text = (root / path).read_text(encoding="utf-8") text = (root / path).read_text(encoding="utf-8")
except OSError as exc: except OSError as exc:
problems.append(f"{path}: cannot read Dockerfile: {exc}") problems.append(f"{path}: cannot read Dockerfile: {exc}")
continue continue
problems.extend(check_dockerfile(path, text)) problems.extend(check_dockerfile(path, text, image_build_args))
for manifest in NPM_MANIFESTS: for manifest in NPM_MANIFESTS:
problems.extend(check_npm_manifest(root, manifest)) problems.extend(check_npm_manifest(root, manifest))
problems.extend(check_python_lock(root)) problems.extend(check_python_lock(root))
+8 -1
View File
@@ -35,5 +35,12 @@ chmod 600 "$fake_key_dir/fake-key"
# Build the image graph quietly so the recorded run shows only the # Build the image graph quietly so the recorded run shows only the
# bottle launch and the four `!` probes, not BuildKit progress. # bottle launch and the four `!` probes, not BuildKit progress.
docker build -q -f bot_bottle/contrib/claude/Dockerfile -t bot-bottle-claude:latest . >/dev/null 2>&1 || true node_base_image=$(
python3 -c \
'import json; print(json.load(open("image-build-args.json"))["NODE_BASE_IMAGE"])'
)
docker build -q \
--build-arg "NODE_BASE_IMAGE=$node_base_image" \
-f bot_bottle/contrib/claude/Dockerfile \
-t bot-bottle-claude:latest . >/dev/null 2>&1 || true
docker build -q -f Dockerfile.git-gate -t bot-bottle-git-gate:latest . >/dev/null 2>&1 || true docker build -q -f Dockerfile.git-gate -t bot-bottle-git-gate:latest . >/dev/null 2>&1 || true
+1
View File
@@ -21,6 +21,7 @@ _ROOT = Path(__file__).resolve().parent
# Must match bot_bottle.resources.BUNDLED_RESOURCES (paths relative to root). # Must match bot_bottle.resources.BUNDLED_RESOURCES (paths relative to root).
_BUNDLED_RESOURCES = ( _BUNDLED_RESOURCES = (
"pyproject.toml", "pyproject.toml",
"image-build-args.json",
"Dockerfile.gateway", "Dockerfile.gateway",
"Dockerfile.orchestrator", "Dockerfile.orchestrator",
"Dockerfile.orchestrator.fc", "Dockerfile.orchestrator.fc",
+11 -1
View File
@@ -23,6 +23,7 @@ import os
import subprocess import subprocess
import unittest import unittest
from bot_bottle import resources
from tests._backend import skip_unless_backend from tests._backend import skip_unless_backend
@@ -38,9 +39,18 @@ class TestGatewayImage(unittest.TestCase):
@classmethod @classmethod
def setUpClass(cls) -> None: def setUpClass(cls) -> None:
repo_root = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) repo_root = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
build_args = resources.image_build_args(
_DOCKERFILE,
context=repo_root,
)
arg_flags = [
item
for name, value in build_args.items()
for item in ("--build-arg", f"{name}={value}")
]
proc = subprocess.run( proc = subprocess.run(
["docker", "build", "-t", _IMAGE, ["docker", "build", "-t", _IMAGE,
"-f", _DOCKERFILE, "."], "-f", _DOCKERFILE, *arg_flags, "."],
cwd=repo_root, cwd=repo_root,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
check=False, check=False,
+23 -10
View File
@@ -2,30 +2,43 @@
from __future__ import annotations from __future__ import annotations
import json
import re import re
import unittest import unittest
from pathlib import Path from pathlib import Path
_CONTRIB_DIR = Path(__file__).resolve().parents[2] / "bot_bottle/contrib" _REPO_ROOT = Path(__file__).resolve().parents[2]
_CONTRIB_DIR = _REPO_ROOT / "bot_bottle/contrib"
_AGENT_DOCKERFILES = tuple(sorted(_CONTRIB_DIR.glob("*/Dockerfile"))) _AGENT_DOCKERFILES = tuple(sorted(_CONTRIB_DIR.glob("*/Dockerfile")))
class TestBuiltinAgentImages(unittest.TestCase): class TestBuiltinAgentImages(unittest.TestCase):
def test_all_share_one_digest_pinned_node_trixie_base(self): def test_all_share_one_digest_pinned_node_trixie_base(self):
self.assertTrue(_AGENT_DOCKERFILES) self.assertTrue(_AGENT_DOCKERFILES)
bases = [] inputs = json.loads((_REPO_ROOT / "image-build-args.json").read_text())
self.assertRegex(
inputs["NODE_BASE_IMAGE"],
r"^node:22\.\d+\.\d+-trixie-slim@sha256:[0-9a-f]{64}$",
)
for dockerfile in _AGENT_DOCKERFILES: for dockerfile in _AGENT_DOCKERFILES:
with self.subTest(provider=dockerfile.parent.name): with self.subTest(provider=dockerfile.parent.name):
match = re.search( text = dockerfile.read_text()
r"(?m)^FROM " self.assertRegex(text, r"(?m)^ARG NODE_BASE_IMAGE$")
r"(node:22\.\d+\.\d+-trixie-slim@sha256:[0-9a-f]{64})\s*$", self.assertIn("FROM ${NODE_BASE_IMAGE}", text)
dockerfile.read_text(),
def test_orchestrator_and_gateway_share_configurable_python_base(self):
inputs = json.loads((_REPO_ROOT / "image-build-args.json").read_text())
self.assertRegex(
inputs["PYTHON_BASE_IMAGE"],
r"^python:3\.12\.\d+-slim-trixie@sha256:[0-9a-f]{64}$",
) )
if match is None: for name in ("Dockerfile.orchestrator", "Dockerfile.gateway"):
self.fail(f"{dockerfile} does not use a digest-pinned Node base") dockerfile = _REPO_ROOT / name
bases.append(match.group(1)) text = dockerfile.read_text()
self.assertEqual(1, len(set(bases))) with self.subTest(dockerfile=name):
self.assertRegex(text, r"(?m)^ARG PYTHON_BASE_IMAGE$")
self.assertIn("FROM ${PYTHON_BASE_IMAGE}", text)
def test_none_install_podman(self): def test_none_install_podman(self):
# podman lives in the nested-containers derived layer (nested_containers.py), # podman lives in the nested-containers derived layer (nested_containers.py),
+4
View File
@@ -221,6 +221,10 @@ class TestDockerOrchestrator(unittest.TestCase):
builds = [c.args[0] for c in run.call_args_list if c.args[0][:2] == ["docker", "build"]] builds = [c.args[0] for c in run.call_args_list if c.args[0][:2] == ["docker", "build"]]
self.assertEqual(1, len(builds)) self.assertEqual(1, len(builds))
self.assertTrue(any(a.endswith("Dockerfile.orchestrator") for a in builds[0])) self.assertTrue(any(a.endswith("Dockerfile.orchestrator") for a in builds[0]))
arg_index = builds[0].index("--build-arg")
self.assertTrue(
builds[0][arg_index + 1].startswith("PYTHON_BASE_IMAGE=python:"),
)
def test_ensure_built_raises_on_build_failure(self) -> None: def test_ensure_built_raises_on_build_failure(self) -> None:
with patch(_RUN, return_value=_proc(returncode=1, stderr="no space left")): with patch(_RUN, return_value=_proc(returncode=1, stderr="no space left")):
+21
View File
@@ -122,6 +122,27 @@ class TestCommitContainer(unittest.TestCase):
class TestBuildImage(unittest.TestCase): class TestBuildImage(unittest.TestCase):
def test_passes_centralized_image_build_args(self):
with patch.object(
docker_mod.resources,
"image_build_args",
return_value={"NODE_BASE_IMAGE": "node:pinned"},
), patch.object(
docker_mod.subprocess, "run", return_value=_ok(),
) as run, patch.object(docker_mod, "info"):
docker_mod.build_image(
"agent:test",
"/context",
dockerfile="Dockerfile.agent",
)
self.assertIn(
["--build-arg", "NODE_BASE_IMAGE=node:pinned"],
[
run.call_args.args[0][index:index + 2]
for index in range(len(run.call_args.args[0]) - 1)
],
)
def test_passes_build_args_without_mutating_the_value(self): def test_passes_build_args_without_mutating_the_value(self):
with patch.object( with patch.object(
docker_mod.subprocess, "run", return_value=_ok(), docker_mod.subprocess, "run", return_value=_ok(),
@@ -75,8 +75,39 @@ class TestBuildAgentRootfsDir(unittest.TestCase):
other.write_text("FROM python:3.12-slim\n") other.write_text("FROM python:3.12-slim\n")
self.assertNotEqual(base, image_builder._rootfs_digest(other)) self.assertNotEqual(base, image_builder._rootfs_digest(other))
def test_rootfs_digest_tracks_centralized_build_args(self):
with patch.object(
image_builder.resources,
"image_build_args",
return_value={"NODE_BASE_IMAGE": "node:first"},
):
first = image_builder._rootfs_digest(self.dockerfile)
with patch.object(
image_builder.resources,
"image_build_args",
return_value={"NODE_BASE_IMAGE": "node:second"},
):
second = image_builder._rootfs_digest(self.dockerfile)
self.assertNotEqual(first, second)
class TestSmokeTest(unittest.TestCase): class TestSmokeTest(unittest.TestCase):
def test_buildah_receives_centralized_image_build_args(self):
with patch.object(
image_builder,
"_ssh_streamed",
return_value=0,
) as ssh, patch.object(image_builder, "info"):
image_builder._buildah_build(
Path("/k"),
"10.0.0.1",
"/tmp/context",
"agent:test",
{"NODE_BASE_IMAGE": "node:pinned"},
)
script = ssh.call_args.args[2]
self.assertIn("--build-arg NODE_BASE_IMAGE=node:pinned", script)
def test_empty_argv_is_noop(self): def test_empty_argv_is_noop(self):
with patch.object(image_builder, "_ssh") as ssh: with patch.object(image_builder, "_ssh") as ssh:
image_builder._smoke_test(Path("/k"), "10.0.0.1", "tag", "ctr", ()) image_builder._smoke_test(Path("/k"), "10.0.0.1", "tag", "ctr", ())
+34 -1
View File
@@ -14,10 +14,26 @@ from scripts import check_image_inputs as policy
class TestDockerfilePolicy(unittest.TestCase): class TestDockerfilePolicy(unittest.TestCase):
_NODE_BASE = {
"NODE_BASE_IMAGE":
"node:22.23.1-trixie-slim@sha256:" + "a" * 64,
}
def test_accepts_versioned_digest_base(self): def test_accepts_versioned_digest_base(self):
text = "FROM node:22.23.1-trixie-slim@sha256:" + "a" * 64 + "\n" text = "FROM node:22.23.1-trixie-slim@sha256:" + "a" * 64 + "\n"
self.assertEqual([], policy.check_dockerfile(Path("Dockerfile"), text)) self.assertEqual([], policy.check_dockerfile(Path("Dockerfile"), text))
def test_accepts_defaultless_base_argument_with_centralized_value(self):
text = "ARG NODE_BASE_IMAGE\nFROM ${NODE_BASE_IMAGE}\n"
self.assertEqual(
[],
policy.check_dockerfile(
Path("Dockerfile"),
text,
self._NODE_BASE,
),
)
def test_rejects_mutable_and_latest_bases(self): def test_rejects_mutable_and_latest_bases(self):
for value in ( for value in (
"node:22-trixie-slim", "node:22-trixie-slim",
@@ -61,7 +77,15 @@ class TestDockerfilePolicy(unittest.TestCase):
def test_rejects_missing_dynamic_and_malformed_bases(self): def test_rejects_missing_dynamic_and_malformed_bases(self):
cases = ( cases = (
("RUN true\n", "missing FROM"), ("RUN true\n", "missing FROM"),
("FROM ${BASE_IMAGE}\n", "dynamic base"), ("FROM ${BASE_IMAGE}\n", "not declared before FROM"),
(
"ARG BASE_IMAGE=node:latest\nFROM ${BASE_IMAGE}\n",
"must not define a default",
),
(
"ARG BASE_IMAGE\nFROM ${BASE_IMAGE}\n",
"lacks a centralized value",
),
( (
"ARG ORCHESTRATOR_BASE_IMAGE=base:latest\n" "ARG ORCHESTRATOR_BASE_IMAGE=base:latest\n"
"FROM ${ORCHESTRATOR_BASE_IMAGE}\n", "FROM ${ORCHESTRATOR_BASE_IMAGE}\n",
@@ -74,6 +98,15 @@ class TestDockerfilePolicy(unittest.TestCase):
problems = policy.check_dockerfile(Path("Dockerfile"), text) problems = policy.check_dockerfile(Path("Dockerfile"), text)
self.assertTrue(any(expected in item for item in problems)) self.assertTrue(any(expected in item for item in problems))
def test_rejects_mutable_centralized_base_value(self):
text = "ARG NODE_BASE_IMAGE\nFROM ${NODE_BASE_IMAGE}\n"
problems = policy.check_dockerfile(
Path("Dockerfile"),
text,
{"NODE_BASE_IMAGE": "node:latest"},
)
self.assertTrue(any("lacks a sha256 digest" in item for item in problems))
def test_requires_gateway_lock_and_verified_codex_markers(self): def test_requires_gateway_lock_and_verified_codex_markers(self):
base = "FROM node:22.23.1@sha256:" + "a" * 64 + "\n" base = "FROM node:22.23.1@sha256:" + "a" * 64 + "\n"
gateway = policy.check_dockerfile(Path("Dockerfile.gateway"), base) gateway = policy.check_dockerfile(Path("Dockerfile.gateway"), base)
+20
View File
@@ -110,6 +110,9 @@ class TestVersionInputs(unittest.TestCase):
for name in ("Dockerfile.orchestrator", "Dockerfile.orchestrator.fc", for name in ("Dockerfile.orchestrator", "Dockerfile.orchestrator.fc",
"Dockerfile.gateway"): "Dockerfile.gateway"):
(root / name).write_text(f"FROM scratch # {name}\n") (root / name).write_text(f"FROM scratch # {name}\n")
(root / "image-build-args.json").write_text(
'{"PYTHON_BASE_IMAGE": "python:pinned"}\n',
)
(root / "requirements.gateway.lock").write_text("mitmproxy==11.1.3\n") (root / "requirements.gateway.lock").write_text("mitmproxy==11.1.3\n")
(root / "pyproject.toml").write_text("[project]\nname = 'bot-bottle'\n") (root / "pyproject.toml").write_text("[project]\nname = 'bot-bottle'\n")
@@ -162,6 +165,23 @@ class TestVersionInputs(unittest.TestCase):
) )
self.assertNotEqual(before, after) self.assertNotEqual(before, after)
def test_base_image_argument_change_bumps_both_role_versions(self) -> None:
with tempfile.TemporaryDirectory() as d:
root = Path(d)
self._fake_repo(root)
before = {
role: ia.infra_artifact_version("init", role, repo_root=root)
for role in ia.ROLES
}
(root / "image-build-args.json").write_text(
'{"PYTHON_BASE_IMAGE": "python:different"}\n',
)
after = {
role: ia.infra_artifact_version("init", role, repo_root=root)
for role in ia.ROLES
}
self.assertTrue(all(before[role] != after[role] for role in ia.ROLES))
def test_pyc_and_pycache_ignored(self) -> None: def test_pyc_and_pycache_ignored(self) -> None:
with tempfile.TemporaryDirectory() as d: with tempfile.TemporaryDirectory() as d:
root = Path(d) root = Path(d)
+31
View File
@@ -115,6 +115,37 @@ resolver #2
run.call_args_list[-1].args[0], run.call_args_list[-1].args[0],
) )
def test_build_image_passes_centralized_image_build_args(self):
status = util.subprocess.CompletedProcess(
args=[],
returncode=0,
stdout=(
'[{"status":{"state":"running"},'
'"configuration":{"dns":{"nameservers":["9.9.9.9"]}}}]'
),
stderr="",
)
with patch.object(util.subprocess, "run", return_value=status) as run, \
patch.object(
util.resources,
"image_build_args",
return_value={"NODE_BASE_IMAGE": "node:pinned"},
), patch.object(util.os, "environ", {
"BOT_BOTTLE_MACOS_CONTAINER_DNS": "9.9.9.9",
}):
util.build_image(
"bot-bottle-agent:latest",
"/repo",
dockerfile="Dockerfile.agent",
)
self.assertIn(
["--build-arg", "NODE_BASE_IMAGE=node:pinned"],
[
run.call_args_list[-1].args[0][index:index + 2]
for index in range(len(run.call_args_list[-1].args[0]) - 1)
],
)
def test_commit_container_execs_tar_and_builds_image(self): def test_commit_container_execs_tar_and_builds_image(self):
# stderr is bytes because subprocess.run uses stderr=PIPE without text=True # stderr is bytes because subprocess.run uses stderr=PIPE without text=True
completed = util.subprocess.CompletedProcess( completed = util.subprocess.CompletedProcess(
+4
View File
@@ -323,6 +323,10 @@ class TestDockerGatewayBuild(unittest.TestCase):
self.assertEqual(1, len(builds)) self.assertEqual(1, len(builds))
self.assertIn(self.sc.image_ref, builds[0]) self.assertIn(self.sc.image_ref, builds[0])
self.assertTrue(any(a.endswith("Dockerfile.gateway") for a in builds[0])) self.assertTrue(any(a.endswith("Dockerfile.gateway") for a in builds[0]))
arg_index = builds[0].index("--build-arg")
self.assertTrue(
builds[0][arg_index + 1].startswith("PYTHON_BASE_IMAGE=python:"),
)
self.assertNotIn("--no-cache", builds[0]) self.assertNotIn("--no-cache", builds[0])
def test_ensure_built_no_cache_env_forces_full_rebuild(self) -> None: def test_ensure_built_no_cache_env_forces_full_rebuild(self) -> None:
+12
View File
@@ -38,6 +38,18 @@ class TestCheckoutMode(unittest.TestCase):
self.assertTrue(resources.nix_netpool_module().is_file()) self.assertTrue(resources.nix_netpool_module().is_file())
self.assertTrue(resources.netpool_script().is_file()) self.assertTrue(resources.netpool_script().is_file())
def test_image_build_args_come_from_the_central_input_file(self):
root = resources.build_root()
args = resources.image_build_args(
"Dockerfile.gateway",
context=root,
)
self.assertEqual({"PYTHON_BASE_IMAGE"}, set(args))
self.assertRegex(
args["PYTHON_BASE_IMAGE"],
r"^python:\d+\.\d+\.\d+-.+@sha256:[0-9a-f]{64}$",
)
def test_bundled_resources_all_exist_at_root(self): def test_bundled_resources_all_exist_at_root(self):
# Drift guard: every path setup.py bundles must exist in the checkout. # Drift guard: every path setup.py bundles must exist in the checkout.
root = resources.build_root() root = resources.build_root()