feat(infra): pull artifacts pinned by package release
This commit is contained in:
@@ -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/
|
||||
@@ -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`.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"schema": 1, "development": true}
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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())
|
||||
@@ -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})
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)):
|
||||
|
||||
@@ -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"])
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")):
|
||||
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user