build: centralize pinned base image arguments
This commit is contained in:
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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: |
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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 \
|
||||||
|
|||||||
@@ -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,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 \
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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")):
|
||||||
|
|||||||
@@ -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", ())
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user