diff --git a/.gitea/workflows/package-release.yml b/.gitea/workflows/package-release.yml new file mode 100644 index 00000000..beefe37e --- /dev/null +++ b/.gitea/workflows/package-release.yml @@ -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/ diff --git a/README.md b/README.md index ea2f6c62..6d427852 100644 --- a/README.md +++ b/README.md @@ -191,9 +191,16 @@ BOT_BOTTLE_BACKEND=firecracker bot-bottle start > **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 # builds the image on first run, drops you into claude +bot-bottle start # 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 `/.bot-bottle/agents/.md`. diff --git a/bot_bottle/backend/docker/gateway.py b/bot_bottle/backend/docker/gateway.py index 4d591dac..58a569a8 100644 --- a/bot_bottle/backend/docker/gateway.py +++ b/bot_bottle/backend/docker/gateway.py @@ -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, diff --git a/bot_bottle/backend/docker/infra.py b/bot_bottle/backend/docker/infra.py index b0a74484..a6904b1a 100644 --- a/bot_bottle/backend/docker/infra.py +++ b/bot_bottle/backend/docker/infra.py @@ -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) diff --git a/bot_bottle/backend/docker/orchestrator.py b/bot_bottle/backend/docker/orchestrator.py index a4e890da..aa6a4f7a 100644 --- a/bot_bottle/backend/docker/orchestrator.py +++ b/bot_bottle/backend/docker/orchestrator.py @@ -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), diff --git a/bot_bottle/backend/firecracker/infra.py b/bot_bottle/backend/firecracker/infra.py index 327a0018..7b369063 100644 --- a/bot_bottle/backend/firecracker/infra.py +++ b/bot_bottle/backend/firecracker/infra.py @@ -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 diff --git a/bot_bottle/backend/firecracker/infra_artifact.py b/bot_bottle/backend/firecracker/infra_artifact.py index 078c67ed..1613cdfe 100644 --- a/bot_bottle/backend/firecracker/infra_artifact.py +++ b/bot_bottle/backend/firecracker/infra_artifact.py @@ -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: diff --git a/bot_bottle/backend/firecracker/infra_vm.py b/bot_bottle/backend/firecracker/infra_vm.py index 04c4b66e..94916aa1 100644 --- a/bot_bottle/backend/firecracker/infra_vm.py +++ b/bot_bottle/backend/firecracker/infra_vm.py @@ -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: diff --git a/bot_bottle/backend/macos_container/gateway.py b/bot_bottle/backend/macos_container/gateway.py index 4b84193e..04264369 100644 --- a/bot_bottle/backend/macos_container/gateway.py +++ b/bot_bottle/backend/macos_container/gateway.py @@ -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 diff --git a/bot_bottle/backend/macos_container/infra.py b/bot_bottle/backend/macos_container/infra.py index 7d250cd5..ab7c6675 100644 --- a/bot_bottle/backend/macos_container/infra.py +++ b/bot_bottle/backend/macos_container/infra.py @@ -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) diff --git a/bot_bottle/backend/macos_container/orchestrator.py b/bot_bottle/backend/macos_container/orchestrator.py index bd14d6ca..23f935ab 100644 --- a/bot_bottle/backend/macos_container/orchestrator.py +++ b/bot_bottle/backend/macos_container/orchestrator.py @@ -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: diff --git a/bot_bottle/backend/macos_container/util.py b/bot_bottle/backend/macos_container/util.py index d51a7e8a..76bfb90d 100644 --- a/bot_bottle/backend/macos_container/util.py +++ b/bot_bottle/backend/macos_container/util.py @@ -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 diff --git a/bot_bottle/gateway/__init__.py b/bot_bottle/gateway/__init__.py index 27925748..b967b8b5 100644 --- a/bot_bottle/gateway/__init__.py +++ b/bot_bottle/gateway/__init__.py @@ -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 diff --git a/bot_bottle/orchestrator/lifecycle.py b/bot_bottle/orchestrator/lifecycle.py index 01e722ad..51f5d5c1 100644 --- a/bot_bottle/orchestrator/lifecycle.py +++ b/bot_bottle/orchestrator/lifecycle.py @@ -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 diff --git a/bot_bottle/release-manifest.json b/bot_bottle/release-manifest.json new file mode 100644 index 00000000..fa6a0a1e --- /dev/null +++ b/bot_bottle/release-manifest.json @@ -0,0 +1 @@ +{"schema": 1, "development": true} diff --git a/bot_bottle/release_manifest.py b/bot_bottle/release_manifest.py new file mode 100644 index 00000000..65a7be23 --- /dev/null +++ b/bot_bottle/release_manifest.py @@ -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", +] diff --git a/docs/prds/prd-new-packaged-infra-artifacts.md b/docs/prds/prd-new-packaged-infra-artifacts.md index 88eab62e..0962438c 100644 --- a/docs/prds/prd-new-packaged-infra-artifacts.md +++ b/docs/prds/prd-new-packaged-infra-artifacts.md @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 9a2a1308..0fc9a4e2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/scripts/generate_release_manifest.py b/scripts/generate_release_manifest.py new file mode 100644 index 00000000..2b1f2244 --- /dev/null +++ b/scripts/generate_release_manifest.py @@ -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()) diff --git a/setup.py b/setup.py index 21efa872..73107708 100644 --- a/setup.py +++ b/setup.py @@ -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}) diff --git a/tests/unit/test_docker_infra.py b/tests/unit/test_docker_infra.py index f7f3a7a7..01384a40 100644 --- a/tests/unit/test_docker_infra.py +++ b/tests/unit/test_docker_infra.py @@ -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( diff --git a/tests/unit/test_docker_orchestrator.py b/tests/unit/test_docker_orchestrator.py index 604b417b..a0511f37 100644 --- a/tests/unit/test_docker_orchestrator.py +++ b/tests/unit/test_docker_orchestrator.py @@ -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)): diff --git a/tests/unit/test_firecracker_infra.py b/tests/unit/test_firecracker_infra.py index f7ee8905..0753f5bc 100644 --- a/tests/unit/test_firecracker_infra.py +++ b/tests/unit/test_firecracker_infra.py @@ -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 diff --git a/tests/unit/test_generate_release_manifest.py b/tests/unit/test_generate_release_manifest.py new file mode 100644 index 00000000..79b2ae1b --- /dev/null +++ b/tests/unit/test_generate_release_manifest.py @@ -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"]) diff --git a/tests/unit/test_macos_infra.py b/tests/unit/test_macos_infra.py index a5b1e655..c3935b9d 100644 --- a/tests/unit/test_macos_infra.py +++ b/tests/unit/test_macos_infra.py @@ -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: diff --git a/tests/unit/test_macos_orchestrator.py b/tests/unit/test_macos_orchestrator.py index 11fad373..754bd168 100644 --- a/tests/unit/test_macos_orchestrator.py +++ b/tests/unit/test_macos_orchestrator.py @@ -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: diff --git a/tests/unit/test_orchestrator_gateway.py b/tests/unit/test_orchestrator_gateway.py index 03b0b370..d665308d 100644 --- a/tests/unit/test_orchestrator_gateway.py +++ b/tests/unit/test_orchestrator_gateway.py @@ -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")): diff --git a/tests/unit/test_release_manifest.py b/tests/unit/test_release_manifest.py new file mode 100644 index 00000000..d6e8c01e --- /dev/null +++ b/tests/unit/test_release_manifest.py @@ -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") diff --git a/tests/unit/test_wheel_install.py b/tests/unit/test_wheel_install.py index 3f0fad5e..67fde0bd 100644 --- a/tests/unit/test_wheel_install.py +++ b/tests/unit/test_wheel_install.py @@ -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()