feat(infra): pull artifacts pinned by package release

This commit is contained in:
2026-07-27 15:13:52 +00:00
parent 63595f123a
commit 1a9056c648
29 changed files with 621 additions and 56 deletions
+67
View File
@@ -0,0 +1,67 @@
# Package bot-bottle only after immutable OCI and Firecracker artifacts have
# been published. The inputs are the release boundary: the resulting wheel
# carries these exact identities and production startup never chooses another.
name: package-release
on:
workflow_dispatch:
inputs:
orchestrator_image:
description: Digest-pinned orchestrator OCI reference
required: true
gateway_image:
description: Digest-pinned gateway OCI reference
required: true
firecracker_orchestrator_version:
description: Published orchestrator rootfs package version
required: true
firecracker_orchestrator_sha256:
description: SHA-256 of orchestrator rootfs.ext4.gz
required: true
firecracker_gateway_version:
description: Published gateway rootfs package version
required: true
firecracker_gateway_sha256:
description: SHA-256 of gateway rootfs.ext4.gz
required: true
jobs:
package:
runs-on: ubuntu-latest
steps:
- name: Checkout the release commit
uses: actions/checkout@v4
- name: Install package build tooling
run: python3 -m pip install --break-system-packages build
- name: Generate immutable release manifest
run: |
python3 scripts/generate_release_manifest.py \
--source-commit "$GITHUB_SHA" \
--orchestrator-image '${{ inputs.orchestrator_image }}' \
--gateway-image '${{ inputs.gateway_image }}' \
--firecracker-orchestrator-version '${{ inputs.firecracker_orchestrator_version }}' \
--firecracker-orchestrator-sha256 '${{ inputs.firecracker_orchestrator_sha256 }}' \
--firecracker-gateway-version '${{ inputs.firecracker_gateway_version }}' \
--firecracker-gateway-sha256 '${{ inputs.firecracker_gateway_sha256 }}' \
--output release-manifest.json
- name: Build package with assigned infrastructure pins
env:
BOT_BOTTLE_RELEASE_MANIFEST: ${{ github.workspace }}/release-manifest.json
run: python3 -m build
- name: Verify packaged manifest
run: |
python3 -m venv verify-venv
verify-venv/bin/pip install --no-deps dist/*.whl
verify-venv/bin/python -c \
'from bot_bottle.release_manifest import load_manifest; print(load_manifest())'
- name: Upload release package
uses: actions/upload-artifact@v3
with:
name: bot-bottle-release
path: dist/
+8 -1
View File
@@ -191,9 +191,16 @@ BOT_BOTTLE_BACKEND=firecracker bot-bottle start <agent>
> **CI:** Firecracker integration runs in the manually dispatched `.gitea/workflows/pre-release-test.yml` on a self-hosted runner labelled `kvm`; privileged KVM hosts never execute unreviewed PR code automatically. Provision it like a normal Firecracker host: `firecracker` on `PATH`, `/dev/kvm`, the cached guest kernel and static dropbear, and the persistent TAP/nft pool. The required pull-request workflow runs unit plus the complete Docker integration suite on `ubuntu-latest`; see `docs/ci.md`.
```sh
bot-bottle start <agent> # builds the image on first run, drops you into claude
bot-bottle start <agent> # prepares the selected agent image, then attaches
```
Packaged releases carry immutable orchestrator and gateway identities. Docker
and Apple Container pull the package-selected OCI digests; Firecracker pulls
the matching checksum-verified rootfs artifacts. A source checkout uses local
infrastructure builds for development. Set `BOT_BOTTLE_INFRA_BUILD=local` to
make that development override explicit when diagnosing a packaged release;
production never falls back to a build after an artifact pull fails.
## Manifest
Bottles and agents are Markdown files with YAML frontmatter under `~/.bot-bottle/`. The Markdown body is the system prompt. Bottles live in `~/.bot-bottle/bottles/`; agents may also be shipped by a repo at `<repo>/.bot-bottle/agents/<name>.md`.
+4
View File
@@ -89,6 +89,10 @@ class DockerGateway(Gateway):
when no dockerfile is configured (a pre-pulled image). BOT_BOTTLE_NO_CACHE
forces a full rebuild (parity with `start --no-cache`)."""
if self._dockerfile is None:
proc = run_docker(["docker", "pull", self.image_ref])
if proc.returncode != 0:
raise GatewayError(
f"gateway image pull failed: {proc.stderr.strip()}")
return
context = self._build_context or resources.build_root()
argv = ["docker", "build", "-t", self.image_ref,
+14 -2
View File
@@ -34,6 +34,7 @@ from .orchestrator import (
ORCHESTRATOR_NETWORK,
)
from ... import resources
from ... import release_manifest
from ...gateway import (
GATEWAY_IMAGE,
GATEWAY_NAME,
@@ -90,6 +91,14 @@ class DockerInfraService(InfraService):
self._orchestrator_name = orchestrator_name
self._orchestrator_label = orchestrator_label
self._gateway_name = gateway_name
resolved_orchestrator, orchestrator_local = release_manifest.oci_image(
"orchestrator", orchestrator_image)
resolved_gateway, gateway_local = release_manifest.oci_image(
"gateway", gateway_image)
self.orchestrator_image = resolved_orchestrator
self.gateway_image = resolved_gateway
self._orchestrator_local = orchestrator_local
self._gateway_local = gateway_local
def orchestrator(self) -> DockerOrchestrator:
"""The control-plane service. Cheap to reconstruct — `ensure_built`
@@ -104,6 +113,8 @@ class DockerInfraService(InfraService):
repo_root=self._repo_root,
host_root=self._host_root,
root_mount_source=self._root_mount_source,
dockerfile=(
"Dockerfile.orchestrator" if self._orchestrator_local else None),
)
def gateway(self) -> DockerGateway:
@@ -119,6 +130,7 @@ class DockerInfraService(InfraService):
control_network=self.control_network,
build_context=self._repo_root,
ca_mount_source=self._gateway_ca_mount_source,
dockerfile="Dockerfile.gateway" if self._gateway_local else None,
)
def ensure_running(
@@ -132,8 +144,8 @@ class DockerInfraService(InfraService):
gateway = self.gateway()
# Build both images (cache-aware; a no-op when nothing changed) before
# bringing either plane up.
orchestrator.ensure_built()
gateway.ensure_built()
orchestrator.ensure_available()
gateway.ensure_available()
orchestrator.ensure_running(startup_timeout=startup_timeout)
@@ -125,6 +125,10 @@ class DockerOrchestrator(Orchestrator):
no-op when nothing changed). No-op when no dockerfile is configured (a
pre-pulled image). BOT_BOTTLE_NO_CACHE forces a full rebuild."""
if self._dockerfile is None:
proc = run_docker(["docker", "pull", self.image_ref])
if proc.returncode != 0:
raise GatewayError(
f"orchestrator image pull failed: {proc.stderr.strip()}")
return
argv = ["docker", "build", "-t", self.image_ref,
"-f", str(self._repo_root / self._dockerfile),
+1 -1
View File
@@ -61,7 +61,7 @@ class FirecrackerInfraService(InfraService):
return url
# Clear stale/hung/OUTDATED VMs holding either link before booting fresh.
infra_vm.stop()
infra_vm.ensure_built()
infra_vm.ensure_available()
# Orchestrator first — the gateway daemons reach the control plane at
# startup. Each service owns its own boot; the orchestrator (which
# holds the signing key) mints the role-scoped `gateway` JWT for the
@@ -195,7 +195,9 @@ def _sha256_file(path: Path) -> str:
return h.hexdigest()
def ensure_artifact_gz(version: str, *, role: str) -> Path:
def ensure_artifact_gz(
version: str, *, role: str, expected_sha256: str | None = None,
) -> Path:
"""The verified, cached `rootfs.ext4.gz` for `role` at `version` —
downloading it (and its `.sha256`) once, then reusing it. Fail-closed on a
checksum mismatch: the partial is removed and we die rather than boot an
@@ -222,6 +224,11 @@ def ensure_artifact_gz(version: str, *, role: str) -> Path:
if not gz.is_file() or not sha.is_file():
die(f"infra candidate bundle is incomplete: {root}")
expected = sha.read_text().split()[0].strip().lower()
if expected_sha256 is not None and expected != expected_sha256:
die(
f"infra candidate packaged checksum mismatch ({role}) for {version}:\n"
f" packaged {expected_sha256}\n candidate {expected}"
)
actual = _sha256_file(gz)
if actual != expected:
die(
@@ -235,7 +242,13 @@ def ensure_artifact_gz(version: str, *, role: str) -> Path:
gz = root / _GZ_NAME
ok = root / ".verified"
if gz.is_file() and ok.is_file():
return gz
actual = _sha256_file(gz)
recorded = ok.read_text(encoding="utf-8").strip()
wanted = expected_sha256 or recorded
if actual == recorded == wanted:
return gz
gz.unlink(missing_ok=True)
ok.unlink(missing_ok=True)
info(f"pulling infra rootfs artifact {_package(role)}/{version}")
_download(artifact_url(version, _GZ_NAME, role=role), gz)
@@ -243,6 +256,14 @@ def ensure_artifact_gz(version: str, *, role: str) -> Path:
_download(artifact_url(version, _SHA_NAME, role=role), sha)
expected = sha.read_text().split()[0].strip().lower()
if expected_sha256 is not None and expected != expected_sha256:
gz.unlink(missing_ok=True)
sha.unlink(missing_ok=True)
die(
f"infra artifact published checksum mismatch ({role}) for {version}:\n"
f" packaged {expected_sha256}\n"
f" published {expected}"
)
actual = _sha256_file(gz)
if actual != expected:
gz.unlink(missing_ok=True)
@@ -253,15 +274,22 @@ def ensure_artifact_gz(version: str, *, role: str) -> Path:
f" actual {actual}\n"
f" refusing to boot an unverified rootfs."
)
ok.write_text("ok\n")
ok.write_text(actual + "\n")
return gz
def materialize_ext4(version: str, dest: Path, *, role: str) -> None:
def materialize_ext4(
version: str,
dest: Path,
*,
role: str,
expected_sha256: str | None = None,
) -> None:
"""Ensure the verified `role` artifact is cached, then gunzip it to `dest` —
a fresh, writable per-boot rootfs (the VM mutates it; the cached `.gz` stays
pristine). Atomic via a `.part` sibling."""
gz = ensure_artifact_gz(version, role=role)
gz = ensure_artifact_gz(
version, role=role, expected_sha256=expected_sha256)
tmp = dest.with_suffix(dest.suffix + ".part")
info(f"expanding {role} infra rootfs -> {dest}")
with gzip.open(gz, "rb") as src, open(tmp, "wb") as out:
+25 -4
View File
@@ -43,6 +43,7 @@ from pathlib import Path
from typing import Generator
from ... import resources
from ... import release_manifest
from ...log import die, info
from ..docker import util as docker_mod
from . import firecracker_vm, infra_artifact, netpool, util
@@ -108,6 +109,18 @@ def _role_version(role: str) -> str:
return infra_artifact.infra_artifact_version(role_init(role), role)
def _role_release(role: str) -> tuple[str, str | None]:
manifest = release_manifest.packaged_manifest()
if manifest is None or release_manifest.local_build_requested():
return _role_version(role), None
artifact = (
manifest.firecracker_orchestrator
if role == "orchestrator"
else manifest.firecracker_gateway
)
return artifact.version, artifact.sha256
def ensure_built() -> None:
"""Ensure both infra rootfs artifacts are available before boot.
@@ -117,11 +130,16 @@ def ensure_built() -> None:
`BOT_BOTTLE_INFRA_BUILD=local` instead builds the images from source with
host Docker (the orchestrator-fc image is `FROM` the orchestrator image, so
it must exist first) — for iterating on the Dockerfiles."""
if infra_artifact.local_build_requested():
if release_manifest.local_build_requested():
build_infra_images_with_docker()
return
for role in infra_artifact.ROLES:
infra_artifact.ensure_artifact_gz(_role_version(role), role=role)
version, expected = _role_release(role)
infra_artifact.ensure_artifact_gz(
version, role=role, expected_sha256=expected)
ensure_available = ensure_built
def build_infra_images_with_docker() -> None:
@@ -214,7 +232,9 @@ def boot_vm(
else:
# Prebuilt artifact already carries the role's build slack; expand it to
# a fresh writable rootfs for this boot.
infra_artifact.materialize_ext4(_role_version(role), rootfs, role=role)
version, expected = _role_release(role)
infra_artifact.materialize_ext4(
version, rootfs, role=role, expected_sha256=expected)
private_key, pubkey = _stable_keypair()
info(f"booting {role} VM on {slot.iface} (guest {slot.guest_ip})")
@@ -264,7 +284,8 @@ def _version_file() -> Path:
def expected_version() -> str:
"""The combined marker for the running pair: both per-plane artifact
versions, so a change to either rootfs dislodges the adopted pair."""
return " ".join(f"{role}={_role_version(role)}" for role in infra_artifact.ROLES)
return " ".join(
f"{role}={_role_release(role)[0]}" for role in infra_artifact.ROLES)
def adoptable(key: Path, url: str, want: str) -> bool:
@@ -84,6 +84,7 @@ class MacosGateway(Gateway):
egress_network: str = GATEWAY_EGRESS_NETWORK,
control_network: str = CONTROL_NETWORK,
repo_root: Path | None = None,
local_build: bool = True,
) -> None:
self.image_ref = image_ref
self.name = name
@@ -93,6 +94,7 @@ class MacosGateway(Gateway):
# Build context: the repo root in a checkout, a staged copy from the
# installed wheel otherwise (bot_bottle.resources).
self._repo_root = repo_root if repo_root is not None else resources.build_root()
self._local_build = local_build
# Set by `connect_to_orchestrator`: the URL the daemons resolve policy
# against + the pre-minted `gateway` token they present. The gateway
# never mints, so it never holds the signing key (#469).
@@ -101,8 +103,11 @@ class MacosGateway(Gateway):
def ensure_built(self) -> None:
"""Build the data-plane image from `Dockerfile.gateway`."""
container_mod.build_image(
self.image_ref, str(self._repo_root), dockerfile="Dockerfile.gateway")
if self._local_build:
container_mod.build_image(
self.image_ref, str(self._repo_root), dockerfile="Dockerfile.gateway")
else:
container_mod.pull_image(self.image_ref)
def connect_to_orchestrator(self, orchestrator_url: str, gateway_token: str) -> None:
"""Bind the gateway to this orchestrator and (re)start it, dual-homed on
+13 -2
View File
@@ -25,6 +25,7 @@ from __future__ import annotations
from pathlib import Path
from ... import resources
from ... import release_manifest
from ...orchestrator.lifecycle import (
DEFAULT_PORT,
DEFAULT_STARTUP_TIMEOUT_SECONDS,
@@ -86,6 +87,14 @@ class MacosInfraService(InfraService):
self._orchestrator_name = orchestrator_name
self._gateway_name = gateway_name
self._db_volume = db_volume
resolved_orchestrator, orchestrator_local = release_manifest.oci_image(
"orchestrator", orchestrator_image)
resolved_gateway, gateway_local = release_manifest.oci_image(
"gateway", gateway_image)
self.orchestrator_image = resolved_orchestrator
self.gateway_image = resolved_gateway
self._orchestrator_local = orchestrator_local
self._gateway_local = gateway_local
def orchestrator(self) -> MacosOrchestrator:
"""The control-plane service on the host-only control network. Cheap to
@@ -98,6 +107,7 @@ class MacosInfraService(InfraService):
control_network=self.control_network,
repo_root=self._repo_root,
db_volume=self._db_volume,
local_build=self._orchestrator_local,
)
def gateway(self) -> MacosGateway:
@@ -112,6 +122,7 @@ class MacosInfraService(InfraService):
egress_network=self.egress_network,
control_network=self.control_network,
repo_root=self._repo_root,
local_build=self._gateway_local,
)
def ensure_running(
@@ -127,8 +138,8 @@ class MacosInfraService(InfraService):
orchestrator = self.orchestrator()
gateway = self.gateway()
orchestrator.ensure_built()
gateway.ensure_built()
orchestrator.ensure_available()
gateway.ensure_available()
orchestrator.ensure_running(startup_timeout=startup_timeout)
@@ -63,6 +63,7 @@ class MacosOrchestrator(Orchestrator):
control_network: str = CONTROL_NETWORK,
repo_root: Path | None = None,
db_volume: str = ORCHESTRATOR_DB_VOLUME,
local_build: bool = True,
) -> None:
self.image_ref = image_ref
self.name = name
@@ -73,6 +74,7 @@ class MacosOrchestrator(Orchestrator):
# staged copy from the installed wheel otherwise (bot_bottle.resources).
self._repo_root = repo_root if repo_root is not None else resources.build_root()
self._db_volume = db_volume
self._local_build = local_build
def url(self) -> str:
"""The orchestrator's control-network address (host CLI + registration),
@@ -114,8 +116,12 @@ class MacosOrchestrator(Orchestrator):
"""Build the control-plane image. The source is bind-mounted so a code
change takes effect without a rebuild; the image still carries the
package for its entrypoint."""
container_mod.build_image(
self.image_ref, str(self._repo_root), dockerfile="Dockerfile.orchestrator")
if self._local_build:
container_mod.build_image(
self.image_ref, str(self._repo_root),
dockerfile="Dockerfile.orchestrator")
else:
container_mod.pull_image(self.image_ref)
def ensure_running(
self, *, startup_timeout: float = DEFAULT_STARTUP_TIMEOUT_SECONDS,
@@ -146,11 +152,6 @@ class MacosOrchestrator(Orchestrator):
"--dns", container_mod.dns_server(),
# Container-only DB volume: exactly one kernel writes bot-bottle.db.
"--volume", f"{self._db_volume}:{_DB_ROOT_IN_CONTAINER}",
# Live control-plane source (a code change takes effect on relaunch).
"--mount",
container_mod.bind_mount_spec(
str(self._repo_root), _SRC_IN_CONTAINER, readonly=True),
"--env", f"PYTHONPATH={_SRC_IN_CONTAINER}",
"--env", f"BOT_BOTTLE_ROOT={_DB_ROOT_IN_CONTAINER}",
# Detect a real control-plane code change and recreate.
"--env", f"BOT_BOTTLE_SOURCE_HASH={current_hash}",
@@ -161,6 +162,14 @@ class MacosOrchestrator(Orchestrator):
# Dockerfile.orchestrator ENTRYPOINT is `-m bot_bottle.orchestrator`.
"--host", "0.0.0.0", "--port", str(self.port), "--broker", "stub",
]
if self._local_build:
image_index = argv.index(self.image_ref)
argv[image_index:image_index] = [
"--mount",
container_mod.bind_mount_spec(
str(self._repo_root), _SRC_IN_CONTAINER, readonly=True),
"--env", f"PYTHONPATH={_SRC_IN_CONTAINER}",
]
result = container_mod.run_container_argv(
argv, env={**os.environ, ORCHESTRATOR_TOKEN_ENV: _signing_key})
if result.returncode != 0:
@@ -101,6 +101,19 @@ def build_image(
subprocess.run(args, check=True)
def pull_image(ref: str) -> None:
"""Acquire an immutable OCI reference through Apple Container."""
result = subprocess.run(
[_CONTAINER, "image", "pull", ref],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
detail = (result.stderr or result.stdout or "").strip()
raise RuntimeError(f"container image pull failed for {ref}: {detail}")
def verify_agent_image(image: str, argv: tuple[str, ...]) -> None:
"""Run `argv` inside a throwaway container of a freshly built agent
image and die loudly if it fails, instead of shipping an image
+5 -3
View File
@@ -115,10 +115,12 @@ class Gateway(abc.ABC):
name: str
def ensure_available(self) -> None:
"""Acquire the exact image/rootfs selected for this application."""
self.ensure_built()
def ensure_built(self) -> None:
"""Ensure the gateway's image / rootfs exists, building it if needed.
Default: nothing to build (e.g. a stub or a pre-pulled image). Call
before `connect_to_orchestrator`."""
"""Local-build implementation hook retained for backend compatibility."""
return
@abc.abstractmethod
+5 -3
View File
@@ -63,10 +63,12 @@ class Orchestrator(abc.ABC):
# orchestrator never starts without its signing key.
provisioning: ControlPlaneProvisioning = ControlPlaneProvisioning()
def ensure_available(self) -> None:
"""Acquire the exact image/rootfs selected for this application."""
self.ensure_built()
def ensure_built(self) -> None:
"""Ensure the orchestrator's image / rootfs exists, building it if
needed. Default: nothing to build (e.g. a pre-pulled image). Call before
`ensure_running`."""
"""Local-build implementation hook retained for backend compatibility."""
return
@abc.abstractmethod
+1
View File
@@ -0,0 +1 @@
{"schema": 1, "development": true}
+149
View File
@@ -0,0 +1,149 @@
"""Immutable infrastructure identities assigned when bot-bottle is packaged."""
from __future__ import annotations
import json
import os
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from . import resources
_MANIFEST_NAME = "release-manifest.json"
_OCI_RE = re.compile(r"^[^@\s]+@sha256:([0-9a-f]{64})$")
_SHA_RE = re.compile(r"^[0-9a-f]{64}$")
_COMMIT_RE = re.compile(r"^[0-9a-f]{40}$")
class ReleaseManifestError(RuntimeError):
"""The installed package has no usable infrastructure release identity."""
@dataclass(frozen=True)
class FirecrackerArtifact:
version: str
sha256: str
@dataclass(frozen=True)
class ReleaseManifest:
source_commit: str
orchestrator_image: str
gateway_image: str
firecracker_orchestrator: FirecrackerArtifact
firecracker_gateway: FirecrackerArtifact
def manifest_path() -> Path:
override = os.environ.get("BOT_BOTTLE_RELEASE_MANIFEST", "").strip()
return Path(override) if override else Path(__file__).resolve().parent / _MANIFEST_NAME
def _artifact(value: Any, role: str) -> FirecrackerArtifact:
if not isinstance(value, dict):
raise ReleaseManifestError(f"release manifest firecracker.{role} must be an object")
version = value.get("version")
sha256 = value.get("sha256")
if not isinstance(version, str) or not version.strip():
raise ReleaseManifestError(
f"release manifest firecracker.{role}.version must be non-empty")
if not isinstance(sha256, str) or _SHA_RE.fullmatch(sha256) is None:
raise ReleaseManifestError(
f"release manifest firecracker.{role}.sha256 must be 64 lowercase hex")
return FirecrackerArtifact(version.strip(), sha256)
def parse_manifest(data: Any) -> ReleaseManifest:
if not isinstance(data, dict) or data.get("schema") != 1:
raise ReleaseManifestError("release manifest must use schema 1")
commit = data.get("source_commit")
if not isinstance(commit, str) or _COMMIT_RE.fullmatch(commit) is None:
raise ReleaseManifestError(
"release manifest source_commit must be 40 lowercase hex")
oci = data.get("oci")
if not isinstance(oci, dict):
raise ReleaseManifestError("release manifest oci must be an object")
images: dict[str, str] = {}
for role in ("orchestrator", "gateway"):
ref = oci.get(role)
if not isinstance(ref, str) or _OCI_RE.fullmatch(ref) is None:
raise ReleaseManifestError(
f"release manifest oci.{role} must be digest-pinned")
images[role] = ref
firecracker = data.get("firecracker")
if not isinstance(firecracker, dict):
raise ReleaseManifestError("release manifest firecracker must be an object")
return ReleaseManifest(
source_commit=commit,
orchestrator_image=images["orchestrator"],
gateway_image=images["gateway"],
firecracker_orchestrator=_artifact(
firecracker.get("orchestrator"), "orchestrator"),
firecracker_gateway=_artifact(firecracker.get("gateway"), "gateway"),
)
def load_manifest() -> ReleaseManifest:
path = manifest_path()
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise ReleaseManifestError(
f"bot-bottle was packaged without valid infrastructure pins "
f"({path}): {exc}") from exc
return parse_manifest(data)
def packaged_manifest() -> ReleaseManifest | None:
"""Release identity, or ``None`` for checkouts/ad-hoc development wheels."""
if resources.is_source_checkout() and not os.environ.get(
"BOT_BOTTLE_RELEASE_MANIFEST"
):
return None
path = manifest_path()
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return load_manifest() # raise the detailed, fail-closed error
if isinstance(raw, dict) and raw.get("development") is True:
return None
return load_manifest()
def local_build_requested() -> bool:
return os.environ.get("BOT_BOTTLE_INFRA_BUILD", "").strip().lower() == "local"
def oci_image(role: str, local_default: str) -> tuple[str, bool]:
"""Return ``(reference, should_build_locally)`` for a fixed OCI service."""
override_name = f"BOT_BOTTLE_{role.upper()}_IMAGE"
override = os.environ.get(override_name, "").strip()
manifest = packaged_manifest()
local = local_build_requested() or manifest is None
if override:
if not local and _OCI_RE.fullmatch(override) is None:
raise ReleaseManifestError(
f"{override_name} must be digest-pinned outside local build mode")
return override, local
if local:
return local_default, True
assert manifest is not None
return (
manifest.orchestrator_image if role == "orchestrator"
else manifest.gateway_image,
False,
)
__all__ = [
"FirecrackerArtifact",
"ReleaseManifest",
"ReleaseManifestError",
"load_manifest",
"local_build_requested",
"oci_image",
"packaged_manifest",
"parse_manifest",
]
+10 -8
View File
@@ -110,10 +110,11 @@ non-empty Firecracker versions, 64-character lowercase SHA-256 values, and a
full source commit. It returns immutable value objects rather than passing raw
dictionaries through backend code.
The repository carries no pretend production pins. A source checkout without a
generated manifest is a development tree and must explicitly select local
infrastructure builds. A built distribution without valid generated metadata
is invalid and fails during infrastructure preparation.
The repository carries no pretend production pins. A source checkout or ad-hoc
Git wheel carries a `development: true` marker and selects local infrastructure
builds, preserving the contributor and current `install.sh` paths. The release
packaging workflow must replace that marker with the validated schema above;
it refuses mutable or incomplete release inputs.
### Packaging order
@@ -145,10 +146,11 @@ before starting either plane.
The gateway receives the same contract so startup does not continue to rebuild
the adjacent half of the fixed infrastructure pair.
An explicit `BOT_BOTTLE_INFRA_BUILD=local` development mode selects the existing
builders. Local mode is allowed from a checkout and deliberately bypasses
release metadata. Packaged production startup never silently falls back from a
failed pull to a local build.
A source checkout is itself a development boundary and selects the existing
builders. `BOT_BOTTLE_INFRA_BUILD=local` provides the same explicit override
for release debugging. Both paths deliberately bypass release metadata.
Packaged production startup never silently falls back from a failed pull to a
local build.
### Docker
+1
View File
@@ -36,6 +36,7 @@ include = ["bot_bottle*"]
# files shipped under bot_bottle/; test_pyproject.py asserts they exist.
[tool.setuptools.package-data]
bot_bottle = [
"release-manifest.json",
"gateway/egress/entrypoint.sh",
"contrib/claude/Dockerfile",
"contrib/claude/package.json",
+49
View File
@@ -0,0 +1,49 @@
"""Generate the validated infrastructure manifest consumed by package builds."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
from bot_bottle.release_manifest import parse_manifest
def parser() -> argparse.ArgumentParser:
result = argparse.ArgumentParser()
result.add_argument("--source-commit", required=True)
result.add_argument("--orchestrator-image", required=True)
result.add_argument("--gateway-image", required=True)
for role in ("orchestrator", "gateway"):
result.add_argument(f"--firecracker-{role}-version", required=True)
result.add_argument(f"--firecracker-{role}-sha256", required=True)
result.add_argument("--output", type=Path, required=True)
return result
def main(argv: list[str] | None = None) -> int:
args = parser().parse_args(argv)
data = {
"schema": 1,
"source_commit": args.source_commit,
"oci": {
"orchestrator": args.orchestrator_image,
"gateway": args.gateway_image,
},
"firecracker": {
role: {
"version": getattr(args, f"firecracker_{role}_version"),
"sha256": getattr(args, f"firecracker_{role}_sha256"),
}
for role in ("orchestrator", "gateway")
},
}
parse_manifest(data)
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(
json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+14
View File
@@ -10,6 +10,8 @@ Kept in sync with ``bot_bottle.resources.BUNDLED_RESOURCES`` — the
from __future__ import annotations
import json
import os
import shutil
from pathlib import Path
@@ -44,6 +46,18 @@ class _BundleResources(build_py):
dst = pkg_resources / rel
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dst)
manifest_dest = Path(self.build_lib) / "bot_bottle" / "release-manifest.json"
manifest_input = os.environ.get("BOT_BOTTLE_RELEASE_MANIFEST", "").strip()
if manifest_input:
shutil.copy2(manifest_input, manifest_dest)
else:
# Wheels built ad hoc retain the contributor/install.sh local-build
# behavior. The release workflow always replaces this marker with
# validated immutable artifact identities.
manifest_dest.write_text(
json.dumps({"schema": 1, "development": True}) + "\n",
encoding="utf-8",
)
setup(cmdclass={"build_py": _BundleResources})
+15 -2
View File
@@ -46,8 +46,8 @@ class TestDockerInfraService(unittest.TestCase):
url = self.svc.ensure_running()
self.assertEqual("http://127.0.0.1:8099", url)
# Both images built; the control plane comes up before the gateway.
self.orch.ensure_built.assert_called_once()
self.gw.ensure_built.assert_called_once()
self.orch.ensure_available.assert_called_once()
self.gw.ensure_available.assert_called_once()
self.orch.ensure_running.assert_called_once()
def test_ensure_running_connects_gateway_with_minted_token(self) -> None:
@@ -76,6 +76,19 @@ class TestDockerInfraService(unittest.TestCase):
self.assertEqual("registry-volume", svc.orchestrator()._root_mount_source)
self.assertEqual("ca-volume", svc.gateway()._ca_mount_source)
def test_packaged_images_disable_local_infra_dockerfiles(self) -> None:
refs = [
("registry/orchestrator@sha256:" + "a" * 64, False),
("registry/gateway@sha256:" + "b" * 64, False),
]
with patch(
"bot_bottle.backend.docker.infra.release_manifest.oci_image",
side_effect=refs,
):
svc = DockerInfraService()
self.assertIsNone(svc.orchestrator()._dockerfile)
self.assertIsNone(svc.gateway()._dockerfile)
def test_host_path_and_named_root_mount_are_mutually_exclusive(self) -> None:
with self.assertRaisesRegex(ValueError, "host_root or root_mount_source"):
DockerInfraService(
+5 -4
View File
@@ -231,11 +231,12 @@ class TestDockerOrchestrator(unittest.TestCase):
with self.assertRaises(GatewayError):
self.orch.ensure_built()
def test_ensure_built_noop_without_dockerfile(self) -> None:
orch = DockerOrchestrator(dockerfile=None)
with patch(_RUN) as run:
def test_ensure_built_pulls_without_dockerfile(self) -> None:
orch = DockerOrchestrator("registry/orchestrator@sha256:" + "a" * 64,
dockerfile=None)
with patch(_RUN, return_value=_proc()) as run:
orch.ensure_built()
run.assert_not_called()
run.assert_called_once_with(["docker", "pull", orch.image_ref])
def test_is_running_reads_docker_ps(self) -> None:
with patch(_RUN, return_value=_proc(stdout=ORCHESTRATOR_NAME)):
+4 -4
View File
@@ -53,7 +53,7 @@ class TestEnsureRunning(unittest.TestCase):
patch.object(infra_vm, "_health_ok", return_value=True), \
patch.object(infra_vm, "_pidfile_alive", return_value=True), \
patch.object(infra_vm, "stop") as stop, \
patch.object(infra_vm, "ensure_built") as built:
patch.object(infra_vm, "ensure_available") as built:
url = FirecrackerInfraService().ensure_running()
stop.assert_not_called()
built.assert_not_called()
@@ -72,7 +72,7 @@ class TestEnsureRunning(unittest.TestCase):
patch.object(infra_vm, "_health_ok", return_value=True), \
patch.object(infra_vm, "_pidfile_alive", return_value=True), \
patch.object(infra_vm, "stop") as stop, \
patch.object(infra_vm, "ensure_built"), \
patch.object(infra_vm, "ensure_available"), \
patch(_ORCH_CLS) as orch_cls, patch(_GW_CLS) as gw_cls:
orch_cls.return_value.gateway_url.return_value = "http://10.243.255.1:8099"
orch_cls.return_value.mint_gateway_token.return_value = "gw.jwt"
@@ -98,7 +98,7 @@ class TestEnsureRunning(unittest.TestCase):
patch.object(infra_vm, "_health_ok", return_value=True), \
patch.object(infra_vm, "_pidfile_alive", return_value=False), \
patch.object(infra_vm, "stop") as stop, \
patch.object(infra_vm, "ensure_built"), \
patch.object(infra_vm, "ensure_available"), \
patch(_ORCH_CLS) as orch_cls, patch(_GW_CLS) as gw_cls:
FirecrackerInfraService().ensure_running()
stop.assert_called_once()
@@ -111,7 +111,7 @@ class TestEnsureRunning(unittest.TestCase):
patch.object(infra_vm, "expected_version", return_value="v-current"), \
patch.object(infra_vm, "_health_ok", return_value=False), \
patch.object(infra_vm, "stop") as stop, \
patch.object(infra_vm, "ensure_built") as built, \
patch.object(infra_vm, "ensure_available") as built, \
patch(_ORCH_CLS) as orch_cls, patch(_GW_CLS) as gw_cls:
FirecrackerInfraService().ensure_running()
stop.assert_called_once() # clear stale VMs first
@@ -0,0 +1,28 @@
from __future__ import annotations
import json
import tempfile
import unittest
from pathlib import Path
from scripts.generate_release_manifest import main
class TestGenerateReleaseManifest(unittest.TestCase):
def test_writes_validated_manifest(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
output = Path(tmp) / "release.json"
rc = main([
"--source-commit", "1" * 40,
"--orchestrator-image",
"registry/orchestrator@sha256:" + "a" * 64,
"--gateway-image", "registry/gateway@sha256:" + "b" * 64,
"--firecracker-orchestrator-version", "orch-v1",
"--firecracker-orchestrator-sha256", "c" * 64,
"--firecracker-gateway-version", "gateway-v1",
"--firecracker-gateway-sha256", "d" * 64,
"--output", str(output),
])
self.assertEqual(0, rc)
data = json.loads(output.read_text(encoding="utf-8"))
self.assertEqual("orch-v1", data["firecracker"]["orchestrator"]["version"])
+14 -2
View File
@@ -33,8 +33,8 @@ class TestInfraEnsureRunning(unittest.TestCase):
with patch(f"{_INFRA}.ensure_networks") as nets:
url = self.svc.ensure_running()
nets.assert_called_once()
self.orch.ensure_built.assert_called_once()
self.gw.ensure_built.assert_called_once()
self.orch.ensure_available.assert_called_once()
self.gw.ensure_available.assert_called_once()
self.orch.ensure_running.assert_called_once()
# Returns the host control-plane URL (the InfraService contract).
self.assertEqual("http://192.168.128.2:8099", url)
@@ -51,6 +51,18 @@ class TestInfraEnsureRunning(unittest.TestCase):
self.assertTrue(self.svc.is_healthy())
self.orch.is_healthy.assert_called_once()
def test_packaged_images_disable_local_infra_builds(self) -> None:
refs = [
("registry/orchestrator@sha256:" + "a" * 64, False),
("registry/gateway@sha256:" + "b" * 64, False),
]
with patch(
f"{_INFRA}.release_manifest.oci_image", side_effect=refs,
):
svc = MacosInfraService(repo_root=Path("/r"))
self.assertFalse(svc.orchestrator()._local_build)
self.assertFalse(svc.gateway()._local_build)
class TestCaCertPem(unittest.TestCase):
def test_delegates_to_the_gateway_service(self) -> None:
+17 -2
View File
@@ -30,14 +30,16 @@ def _spec(src: str, tgt: str, readonly: bool = False) -> str:
class TestMacosOrchestratorRun(unittest.TestCase):
def _run(self) -> list[str]:
def _run(self, *, local_build: bool = True) -> list[str]:
run = Mock(return_value=_ok())
with patch(f"{_ORCH}.container_mod") as mod, \
patch("bot_bottle.trust_domain.host_signing_key", return_value="k"):
mod.dns_server.return_value = "1.1.1.1"
mod.bind_mount_spec.side_effect = _spec
mod.run_container_argv = run
MacosOrchestrator(repo_root=Path("/r"))._run_container("h1")
MacosOrchestrator(
repo_root=Path("/r"), local_build=local_build,
)._run_container("h1")
return run.call_args.args[0]
def test_runs_on_the_control_network_only(self) -> None:
@@ -63,6 +65,11 @@ class TestMacosOrchestratorRun(unittest.TestCase):
def test_source_hash_is_labelled_for_recreate(self) -> None:
self.assertIn("BOT_BOTTLE_SOURCE_HASH=h1", self._run())
def test_packaged_image_does_not_overlay_host_source(self) -> None:
argv = self._run(local_build=False)
self.assertNotIn("--mount", argv)
self.assertFalse(any(arg.startswith("PYTHONPATH=") for arg in argv))
def test_start_failure_raises(self) -> None:
with patch(f"{_ORCH}.container_mod") as mod, \
patch("bot_bottle.trust_domain.host_signing_key", return_value="k"):
@@ -145,6 +152,14 @@ class TestMacosOrchestratorBuildStop(unittest.TestCase):
_args, kwargs = mod.build_image.call_args
self.assertEqual("Dockerfile.orchestrator", kwargs["dockerfile"])
def test_ensure_built_pulls_packaged_image(self) -> None:
image = "registry/orchestrator@sha256:" + "a" * 64
orch = MacosOrchestrator(image, local_build=False)
with patch(f"{_ORCH}.container_mod") as mod:
orch.ensure_built()
mod.pull_image.assert_called_once_with(image)
mod.build_image.assert_not_called()
def test_stop_removes_the_container(self) -> None:
orch = MacosOrchestrator()
with patch(f"{_ORCH}.container_mod") as mod:
+4 -4
View File
@@ -427,11 +427,11 @@ class TestDockerGatewayBuild(unittest.TestCase):
builds = [c for c in calls if c[:2] == ["docker", "build"]]
self.assertIn("--no-cache", builds[0])
def test_ensure_built_noop_when_no_dockerfile(self) -> None:
sc = DockerGateway("busybox", dockerfile=None)
with patch(_RUN_DOCKER) as m:
def test_ensure_built_pulls_when_no_dockerfile(self) -> None:
sc = DockerGateway("registry/gateway@sha256:" + "a" * 64, dockerfile=None)
with patch(_RUN_DOCKER, return_value=_proc()) as m:
sc.ensure_built()
m.assert_not_called()
m.assert_called_once_with(["docker", "pull", sc.image_ref])
def test_ensure_built_raises_on_build_failure(self) -> None:
with patch(_RUN_DOCKER, return_value=_proc(returncode=1, stderr="build boom")):
+86
View File
@@ -0,0 +1,86 @@
from __future__ import annotations
import unittest
from unittest.mock import patch
from bot_bottle.release_manifest import (
ReleaseManifestError,
oci_image,
packaged_manifest,
parse_manifest,
)
def manifest() -> dict[str, object]:
return {
"schema": 1,
"source_commit": "1" * 40,
"oci": {
"orchestrator": "registry/orchestrator@sha256:" + "a" * 64,
"gateway": "registry/gateway@sha256:" + "b" * 64,
},
"firecracker": {
"orchestrator": {"version": "orch-v1", "sha256": "c" * 64},
"gateway": {"version": "gateway-v1", "sha256": "d" * 64},
},
}
class TestParseManifest(unittest.TestCase):
def test_parses_all_backend_artifacts(self) -> None:
parsed = parse_manifest(manifest())
self.assertTrue(parsed.orchestrator_image.endswith("a" * 64))
self.assertEqual("orch-v1", parsed.firecracker_orchestrator.version)
def test_rejects_mutable_oci_reference(self) -> None:
data = manifest()
assert isinstance(data["oci"], dict)
data["oci"]["orchestrator"] = "registry/orchestrator:latest"
with self.assertRaisesRegex(ReleaseManifestError, "digest-pinned"):
parse_manifest(data)
def test_rejects_malformed_firecracker_checksum(self) -> None:
data = manifest()
assert isinstance(data["firecracker"], dict)
artifact = data["firecracker"]["gateway"]
assert isinstance(artifact, dict)
artifact["sha256"] = "nope"
with self.assertRaisesRegex(ReleaseManifestError, "64 lowercase hex"):
parse_manifest(data)
class TestImageSelection(unittest.TestCase):
def test_development_marker_selects_local_builds(self) -> None:
with patch(
"bot_bottle.release_manifest.resources.is_source_checkout",
return_value=False,
), patch(
"bot_bottle.release_manifest.manifest_path",
) as path:
path.return_value.read_text.return_value = (
'{"schema": 1, "development": true}')
self.assertIsNone(packaged_manifest())
def test_packaged_install_uses_manifest_reference_without_build(self) -> None:
parsed = parse_manifest(manifest())
with patch(
"bot_bottle.release_manifest.packaged_manifest", return_value=parsed,
), patch(
"bot_bottle.release_manifest.local_build_requested", return_value=False,
), patch.dict("os.environ", {}, clear=True):
ref, local = oci_image("orchestrator", "local:latest")
self.assertEqual(parsed.orchestrator_image, ref)
self.assertFalse(local)
def test_mutable_override_fails_outside_local_mode(self) -> None:
parsed = parse_manifest(manifest())
with patch(
"bot_bottle.release_manifest.packaged_manifest", return_value=parsed,
), patch(
"bot_bottle.release_manifest.local_build_requested", return_value=False,
), patch.dict(
"os.environ", {"BOT_BOTTLE_ORCHESTRATOR_IMAGE": "local:latest"},
clear=True,
):
with self.assertRaisesRegex(ReleaseManifestError, "digest-pinned"):
oci_image("orchestrator", "fallback:latest")
+9
View File
@@ -117,6 +117,15 @@ class TestWheelInstall(unittest.TestCase):
proc = self._run(["-c", script])
self.assertIn("SELF_CONTAINED_OK", proc.stdout, msg=proc.stderr)
def test_ad_hoc_wheel_is_marked_for_local_infrastructure_builds(self):
script = (
"from bot_bottle.release_manifest import packaged_manifest\n"
"assert packaged_manifest() is None\n"
"print('DEVELOPMENT_MANIFEST_OK')\n"
)
proc = self._run(["-c", script])
self.assertIn("DEVELOPMENT_MANIFEST_OK", proc.stdout, msg=proc.stderr)
if __name__ == "__main__":
unittest.main()