build: centralize pinned base image arguments
This commit is contained in:
@@ -96,6 +96,11 @@ class DockerGateway(Gateway):
|
||||
str(context)]
|
||||
if os.environ.get("BOT_BOTTLE_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)
|
||||
if proc.returncode != 0:
|
||||
raise GatewayError(f"gateway image build failed: {proc.stderr.strip()}")
|
||||
|
||||
@@ -131,6 +131,11 @@ class DockerOrchestrator(Orchestrator):
|
||||
str(self._repo_root)]
|
||||
if os.environ.get("BOT_BOTTLE_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)
|
||||
if proc.returncode != 0:
|
||||
raise GatewayError(
|
||||
|
||||
@@ -10,6 +10,7 @@ import shutil
|
||||
import subprocess
|
||||
from typing import Iterator
|
||||
|
||||
from ... import resources
|
||||
from ...log import die, info
|
||||
from ...util import slugify as _slugify
|
||||
|
||||
@@ -145,7 +146,12 @@ def build_image(
|
||||
args.append("--no-cache")
|
||||
if 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.append(context)
|
||||
subprocess.run(args, check=True)
|
||||
|
||||
@@ -19,12 +19,14 @@ from __future__ import annotations
|
||||
import fcntl
|
||||
import hashlib
|
||||
import os
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Generator
|
||||
|
||||
from ... import resources
|
||||
from ...log import die, info
|
||||
from . import util
|
||||
from .infra import FirecrackerInfraService
|
||||
@@ -55,6 +57,11 @@ def _rootfs_digest(dockerfile: Path) -> str:
|
||||
h = hashlib.sha256()
|
||||
h.update(_dockerfile_hash(dockerfile).encode())
|
||||
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())
|
||||
return h.hexdigest()[:16]
|
||||
|
||||
@@ -147,7 +154,13 @@ def _build_in_infra(
|
||||
if prep.returncode != 0:
|
||||
die(f"preparing build dir in the infra VM failed: {prep.stderr.strip()}")
|
||||
_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)
|
||||
_stream_rootfs(key, ip, tag, export_ctr, base)
|
||||
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()}")
|
||||
|
||||
|
||||
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
|
||||
# docker backend's `docker build`), so a long first build (base pull +
|
||||
# apt/npm installs) shows live progress instead of a silent wait. The
|
||||
# remote stderr is where buildah writes its `STEP i/n` lines.
|
||||
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(
|
||||
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,
|
||||
)
|
||||
if rc != 0:
|
||||
|
||||
@@ -50,8 +50,16 @@ _ARTIFACT_FORMAT = "1"
|
||||
# orchestrator rootfs carries buildah), so the versions are hashed separately.
|
||||
ROLES = ("orchestrator", "gateway")
|
||||
_BUILD_INPUTS = {
|
||||
"orchestrator": ("Dockerfile.orchestrator", "Dockerfile.orchestrator.fc"),
|
||||
"gateway": ("Dockerfile.gateway", "requirements.gateway.lock"),
|
||||
"orchestrator": (
|
||||
"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"
|
||||
|
||||
@@ -13,6 +13,7 @@ import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Iterable
|
||||
|
||||
from ... import resources
|
||||
from ...log import die, info
|
||||
|
||||
|
||||
@@ -60,7 +61,13 @@ def dns_server() -> str:
|
||||
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`.
|
||||
|
||||
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):
|
||||
dockerfile = os.path.join(context, 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)
|
||||
subprocess.run(args, check=True)
|
||||
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
# 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.
|
||||
FROM node:22.23.1-trixie-slim@sha256:e6d9a389d34ff9678438af985c9913fbd1eb6ed36e80fea56644f4b4f6dd70ba
|
||||
ARG NODE_BASE_IMAGE
|
||||
FROM ${NODE_BASE_IMAGE}
|
||||
|
||||
ARG DEBIAN_SNAPSHOT=20260724T000000Z
|
||||
RUN sed -i \
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
# Mirrors the default Claude image shape: Node LTS, git/network tooling,
|
||||
# non-root node user, and the provider CLI installed for that user.
|
||||
|
||||
FROM node:22.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
|
||||
# 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.
|
||||
|
||||
FROM node:22.23.1-trixie-slim@sha256:e6d9a389d34ff9678438af985c9913fbd1eb6ed36e80fea56644f4b4f6dd70ba
|
||||
ARG NODE_BASE_IMAGE
|
||||
FROM ${NODE_BASE_IMAGE}
|
||||
|
||||
ARG DEBIAN_SNAPSHOT=20260724T000000Z
|
||||
RUN sed -i \
|
||||
|
||||
@@ -20,7 +20,9 @@ from __future__ import annotations
|
||||
|
||||
import fcntl
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
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).
|
||||
BUNDLED_RESOURCES: tuple[str, ...] = (
|
||||
"pyproject.toml",
|
||||
"image-build-args.json",
|
||||
"Dockerfile.gateway",
|
||||
"Dockerfile.orchestrator",
|
||||
"Dockerfile.orchestrator.fc",
|
||||
@@ -48,6 +51,8 @@ BUNDLED_RESOURCES: tuple[str, ...] = (
|
||||
# Present at a checkout root, never in a bare installed package — the cheap
|
||||
# tell for which layout we're in.
|
||||
_CHECKOUT_MARKER = "Dockerfile.gateway"
|
||||
_IMAGE_BUILD_ARGS_FILE = "image-build-args.json"
|
||||
_CENTRAL_BUILD_ARG_NAMES = frozenset({"NODE_BASE_IMAGE", "PYTHON_BASE_IMAGE"})
|
||||
|
||||
|
||||
class ResourceError(RuntimeError):
|
||||
@@ -79,6 +84,59 @@ def dockerfile(name: str) -> Path:
|
||||
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:
|
||||
"""Absolute path to the firecracker netpool NixOS module."""
|
||||
return build_root() / "nix" / "firecracker-netpool.nix"
|
||||
|
||||
Reference in New Issue
Block a user