feat(prd-0081): reprovision CA, git-gate, and egress tokens on gateway bring-up
prd-number-check / require-numbered-prds (pull_request) Successful in 13s
tracker-policy-pr / check-pr (pull_request) Successful in 7s
test / image-input-builds (pull_request) Successful in 41s
test / unit (pull_request) Successful in 51s
test / integration-docker (pull_request) Successful in 58s
test / coverage (pull_request) Successful in 41s
test / image-input-builds (push) Successful in 48s
test / unit (push) Successful in 56s
lint / lint (push) Successful in 1m2s
Update Quality Badges / update-badges (push) Successful in 1m4s
test / integration-docker (push) Failing after 2m53s
test / coverage (push) Has been skipped

On a Firecracker gateway cold boot, reconcile every live agent VM against
the fresh gateway: push the new mitmproxy CA into each agent's trust store,
re-provision git-gate repos/creds from the persisted upstreams snapshot,
and restore egress tokens. Per-bottle failures are logged and skipped so
one unreachable VM does not block the rest.

New modules / changes:
- backend/firecracker/reconcile.py: attach_bottled_agents_to_gateway,
  _push_ca, _reprovision_git_gate, _guest_ip_from_config
- git_gate/provision.py: write upstreams.json after key provisioning so
  the bring-up reconcile can reconstruct the upstream table without the
  manifest
- backend/firecracker/infra.py: call attach_bottled_agents_to_gateway in
  the cold-boot branch of ensure_running()
- backend/base.py: no-op default on BottleBackend
- backend/firecracker/consolidated_launch.py: remove superseded
  _reprovision_running_bottles / _guest_ip_from_config
- orchestrator: OrchestratorCore.update_agent_secret + POST
  /bottles/<id>/secret + client.update_agent_secret (single-secret
  in-place update, the reusable primitive from the 2026-07-26 hotfix)
- tests: 15 new tests in test_firecracker_reconcile.py; updated
  test_firecracker_infra.py cold-boot cases; stale FC reprovision tests
  removed from test_backend_secret_reprovision.py

Closes #516.
This commit was merged in pull request #517.
This commit is contained in:
2026-07-28 01:50:15 +00:00
parent 6f997bf118
commit dee0121e8d
69 changed files with 937 additions and 4015 deletions
+14
View File
@@ -496,6 +496,20 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
(macos-container) die with a pointer — the default here."""
die(f"backend {self.name!r} has no orchestrator control plane")
def attach_bottled_agents_to_gateway(self) -> None:
"""Reconcile all running bottles against the current gateway.
Called when the gateway is (re)brought up (cold-boot path) to restore
the three gateway-dependent services for every live bottle: CA trust,
git-gate repos/creds, and egress tokens. Per-bottle failures must be
logged and skipped — one unreachable agent must not block the rest.
Default: no-op. Backends that run a gateway (Firecracker, docker,
macOS) override this with a backend-native implementation that reaches
running agents via their transport (SSH for Firecracker, exec/cp for
docker and macOS). PRD 0081."""
return
@abstractmethod
def prepare_cleanup(self) -> CleanupT:
"""Enumerate orphaned resources from previous bottles. No side
-4
View File
@@ -89,10 +89,6 @@ 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,
+2 -14
View File
@@ -34,7 +34,6 @@ from .orchestrator import (
ORCHESTRATOR_NETWORK,
)
from ... import resources
from ... import release_manifest
from ...gateway import (
GATEWAY_IMAGE,
GATEWAY_NAME,
@@ -91,14 +90,6 @@ 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`
@@ -113,8 +104,6 @@ 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:
@@ -130,7 +119,6 @@ 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(
@@ -144,8 +132,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_available()
gateway.ensure_available()
orchestrator.ensure_built()
gateway.ensure_built()
orchestrator.ensure_running(startup_timeout=startup_timeout)
-9
View File
@@ -84,15 +84,6 @@ def build_or_load_images(plan: DockerBottlePlan) -> BottleImages:
)
info(f"using cached agent image {plan.image!r}")
return BottleImages(agent=plan.image)
if "@sha256:" in plan.image:
pulled = docker_mod.run_docker(["docker", "pull", plan.image])
if pulled.returncode != 0:
die(f"pulling packaged agent image {plan.image!r} failed: "
f"{pulled.stderr.strip()}")
docker_mod.verify_agent_image(
plan.image, runtime_for(plan.agent_provider_template).smoke_test,
)
return BottleImages(agent=plan.image)
docker_mod.build_image(plan.image, str(resources.build_root()), dockerfile=plan.dockerfile_path)
docker_mod.verify_agent_image(
plan.image, runtime_for(plan.agent_provider_template).smoke_test,
@@ -125,10 +125,6 @@ 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),
@@ -26,22 +26,19 @@ The TAP slot allocation, rootfs build, and VM boot are the caller's job.
from __future__ import annotations
import json
import subprocess
from dataclasses import dataclass
from pathlib import Path
from ...egress import EgressPlan
from ...git_gate import GitGatePlan
from ...log import info
from ...orchestrator.client import OrchestratorClient, OrchestratorClientError
from ...orchestrator.client import OrchestratorClient
from ...orchestrator.lifecycle import (
OrchestratorStartError, # re-exported so callers can catch it
)
from ...orchestrator.reprovision import reprovision_bottles
from ...orchestrator.store.secret_store import ENV_VAR_SECRET_NAME
from ..provision_bottle import deprovision_bottle, provision_bottle
from . import cleanup, util
from . import util
from .gateway import FirecrackerGateway
from .infra import FirecrackerInfraService
@@ -64,17 +61,6 @@ class LaunchContext:
env_var_secret: str = "" # encryption key injected into the agent's env
def _guest_ip_from_config(config_path: Path) -> str:
"""Read the kernel's configured guest IP from a Firecracker config."""
try:
config = json.loads(config_path.read_text())
args = config["boot-source"]["boot_args"]
ip_arg = next(part for part in args.split() if part.startswith("ip="))
return ip_arg.removeprefix("ip=").split(":", 1)[0]
except (OSError, ValueError, KeyError, TypeError, StopIteration):
return ""
def persist_env_var_secret(private_key: Path, guest_ip: str, secret: str) -> None:
"""Mirror the exec-time key into guest tmpfs for restart recovery."""
proc = subprocess.run(
@@ -89,29 +75,6 @@ def persist_env_var_secret(private_key: Path, guest_ip: str, secret: str) -> Non
)
def _reprovision_running_bottles(client: OrchestratorClient) -> None:
"""Read keys from live agent VMs and restore the restarted gateway."""
try:
secrets_by_ip: dict[str, str] = {}
for run_dir in cleanup.live_run_dirs():
guest_ip = _guest_ip_from_config(run_dir / "config.json")
private_key = run_dir / "bottle_id_ed25519"
if not guest_ip or not private_key.is_file():
continue
proc = subprocess.run(
util.ssh_base_argv(private_key, guest_ip)
+ [f"cat {_ENV_VAR_SECRET_PATH}"],
capture_output=True, text=True, check=False,
)
if proc.returncode == 0 and proc.stdout.strip():
secrets_by_ip[guest_ip] = proc.stdout.strip()
count = reprovision_bottles(client, secrets_by_ip)
if count:
info(f"reprovisioned egress tokens for {count} Firecracker bottle(s)")
except (OSError, OrchestratorClientError) as exc:
info(f"egress token reprovision skipped: {exc}")
def launch_consolidated(
egress_plan: EgressPlan,
git_gate_plan: GitGatePlan,
@@ -126,7 +89,6 @@ def launch_consolidated(
service = FirecrackerInfraService()
url = service.ensure_running()
client = OrchestratorClient(url)
_reprovision_running_bottles(client)
# Read the gateway's provisioning transport + CA off the Gateway service.
gateway = service.gateway()
+8 -179
View File
@@ -19,7 +19,6 @@ from __future__ import annotations
import fcntl
import hashlib
import os
import re
import shlex
import shutil
import subprocess
@@ -42,72 +41,19 @@ _BUILD_TIMEOUT_SECONDS = 900.0
def _dockerfile_hash(dockerfile: Path) -> str:
"""The Dockerfile's content hash. Agent Dockerfiles mostly COPY nothing
from the build context, so their text nearly determines the built image;
any files they *do* COPY are folded into `_rootfs_digest` (so a changed
input busts the cache) and shipped to the VM-side context by
`_send_build_context`."""
"""The Dockerfile's content hash. The shipped agent Dockerfiles COPY
nothing from the build context (see .dockerignore), so their content fully
determines the built image; a Dockerfile that adds COPY will want the
context folded in here too."""
return hashlib.sha256(dockerfile.read_bytes()).hexdigest()[:16]
def _context_copy_sources(dockerfile: Path) -> list[str]:
"""The build-context-relative paths a Dockerfile ``COPY``s in.
Agent Dockerfiles are meant to COPY nothing from the context (the VM-side
build ships only the Dockerfile), but one may pin an input by COPYing a
committed file — a checksum list, an npm lockfile. Return those source
paths so the builder can both ship them to the VM context and fold them
into the cache key. ``COPY --from=<stage>`` reads a build stage, not the
context, so it is excluded; the JSON/exec COPY form is unused by the
shipped images and is skipped rather than mis-parsed."""
joined = re.sub(r"\\\n", " ", dockerfile.read_text(encoding="utf-8"))
sources: list[str] = []
for line in joined.splitlines():
stripped = line.strip()
if not re.match(r"(?i)^COPY\s", stripped):
continue
tokens = stripped.split()[1:]
if any(t.startswith("--from=") for t in tokens):
continue
args = [t for t in tokens if not t.startswith("--")]
if len(args) < 2 or args[0].startswith("["):
continue
sources.extend(args[:-1])
return sources
def _context_files(dockerfile: Path) -> list[tuple[str, Path]]:
"""``(context-relative path, host path)`` for every existing file a
Dockerfile COPYs from the build root — globs expanded, sorted, de-duped.
Absolute or traversing (`..`) sources are dropped: the shipped context
only ever mirrors files under the build root."""
root = resources.build_root()
resolved: dict[str, Path] = {}
for src in _context_copy_sources(dockerfile):
if src.startswith("/") or ".." in Path(src).parts:
continue
if any(ch in src for ch in "*?["):
matches = [p for p in root.glob(src) if p.is_file()]
else:
candidate = root / src
if candidate.is_dir():
matches = [path for path in candidate.rglob("*") if path.is_file()]
else:
matches = [candidate] if candidate.is_file() else []
for path in matches:
resolved[str(path.relative_to(root))] = path
return sorted(resolved.items())
def _rootfs_digest(dockerfile: Path) -> str:
"""Cache key for the built AND boot-injected agent rootfs. Its inputs are
the Dockerfile (the image), the centralized build args, the guest init
injected into it (`util._GUEST_INIT`), and the content of any files the
Dockerfile COPYs from the build context. Folding the init in means a fix to
"""Cache key for the built AND boot-injected agent rootfs. Two inputs
determine the on-disk rootfs: the Dockerfile (the image) and the guest init
injected into it (`util._GUEST_INIT`). Folding the init in means a fix to
it — e.g. making /tmp world-writable — busts the cache instead of silently
reusing a stale rootfs built with the old init; folding the COPYed context
files in means a repinned input (e.g. a changed checksum list) rebuilds
rather than reusing a rootfs baked from the old bytes."""
reusing a stale rootfs built with the old init."""
h = hashlib.sha256()
h.update(_dockerfile_hash(dockerfile).encode())
h.update(b"\0")
@@ -117,11 +63,6 @@ def _rootfs_digest(dockerfile: Path) -> str:
h.update(value.encode())
h.update(b"\0")
h.update(util._GUEST_INIT.encode())
for rel, path in _context_files(dockerfile):
h.update(b"\0")
h.update(rel.encode())
h.update(b"\0")
h.update(path.read_bytes())
return h.hexdigest()[:16]
@@ -131,46 +72,6 @@ def cached_agent_rootfs_dir(dockerfile: Path) -> Path | None:
return base if (base / ".bb-ready").is_file() else None
def _image_rootfs_digest(image: str) -> str:
h = hashlib.sha256()
h.update(image.encode())
h.update(b"\0")
h.update(util._GUEST_INIT.encode())
return h.hexdigest()[:16]
def cached_agent_image_rootfs_dir(image: str) -> Path | None:
"""Return a ready rootfs exported from an immutable OCI image."""
base = util.cache_dir() / "rootfs" / f"agent-image-{_image_rootfs_digest(image)}"
return base if (base / ".bb-ready").is_file() else None
def acquire_agent_image_rootfs_dir(
image: str, *, smoke_test: tuple[str, ...] = (),
) -> Path:
"""Pull a digest-pinned image in the infra VM and export its rootfs."""
if "@sha256:" not in image:
die(f"prebuilt Firecracker agent image is not digest-pinned: {image}")
digest = _image_rootfs_digest(image)
base = util.cache_dir() / "rootfs" / f"agent-image-{digest}"
cached = cached_agent_image_rootfs_dir(image)
if cached is not None:
info(f"using cached agent rootfs {cached.name}")
return cached
with _build_lock():
if (base / ".bb-ready").is_file():
return base
staging = util.cache_dir() / "rootfs" / f".pulling-{digest}"
shutil.rmtree(staging, ignore_errors=True)
staging.mkdir(parents=True)
_pull_in_infra(image, staging, smoke_test, digest)
util.inject_guest_boot(staging)
(staging / ".bb-ready").write_text("ok\n")
shutil.rmtree(base, ignore_errors=True)
os.rename(staging, base)
return base
def build_agent_rootfs_dir(
dockerfile: Path, *, image_tag: str, smoke_test: tuple[str, ...] = (),
) -> Path:
@@ -253,7 +154,6 @@ def _build_in_infra(
if prep.returncode != 0:
die(f"preparing build dir in the infra VM failed: {prep.stderr.strip()}")
_send_dockerfile(key, ip, dockerfile, ctx)
_send_build_context(key, ip, dockerfile, ctx)
_buildah_build(
key,
ip,
@@ -267,43 +167,6 @@ def _build_in_infra(
_cleanup()
def _pull_in_infra(
image: str, base: Path, smoke_test: tuple[str, ...], digest: str,
) -> None:
"""Pull and export a published agent image inside the orchestrator VM."""
service = FirecrackerInfraService()
service.ensure_running()
key, ip = service.orchestrator().ssh_target()
tag = f"bot-bottle-agent-pull-{digest}"
smoke_ctr, export_ctr = f"{tag}-smoke", f"{tag}-export"
quoted_image = shlex.quote(image)
def cleanup() -> None:
_ssh(
key,
ip,
f"buildah rm {smoke_ctr} {export_ctr} >/dev/null 2>&1; "
f"buildah rmi {_STORE_FLAG} {tag} >/dev/null 2>&1",
timeout=60,
)
cleanup()
try:
result = _ssh(
key,
ip,
f"buildah pull {_STORE_FLAG} {quoted_image} && "
f"buildah tag {_STORE_FLAG} {quoted_image} {tag}",
timeout=_BUILD_TIMEOUT_SECONDS,
)
if result.returncode != 0:
die(f"pulling pinned agent image failed: {result.stderr.strip()}")
_smoke_test(key, ip, tag, smoke_ctr, smoke_test)
_stream_rootfs(key, ip, tag, export_ctr, base)
finally:
cleanup()
def _ssh(private_key: Path, guest_ip: str, script: str,
*, timeout: float = 60.0) -> subprocess.CompletedProcess[str]:
return subprocess.run(
@@ -334,40 +197,6 @@ def _send_dockerfile(private_key: Path, guest_ip: str, dockerfile: Path, ctx: st
f"{proc.stderr.decode(errors='replace').strip()}")
def _send_build_context(private_key: Path, guest_ip: str, dockerfile: Path, ctx: str) -> None:
"""Ship the files ``dockerfile`` COPYs from the build root into the infra
VM's ``{ctx}/ctx``, preserving their build-root-relative paths.
Usually a no-op — agent Dockerfiles COPY nothing — so `{ctx}/ctx` stays the
empty context the build otherwise runs against. It exists so a Dockerfile
that pins an input by COPYing a committed file (a checksum list, an npm
lockfile) still finds that file in the VM-side context. Streamed as a tar
so directories and multiple files land in one round trip."""
files = _context_files(dockerfile)
if not files:
return
root = resources.build_root()
rels = [rel for rel, _ in files]
tar = subprocess.Popen(
["tar", "-C", str(root), "-cf", "-", "--", *rels],
stdout=subprocess.PIPE,
)
try:
proc = subprocess.run(
util.ssh_base_argv(private_key, guest_ip) + [f"tar -C {ctx}/ctx -xf -"],
stdin=tar.stdout, capture_output=True, timeout=120, check=False,
)
finally:
if tar.stdout is not None:
tar.stdout.close()
tar.wait()
if tar.returncode != 0:
die(f"packing the agent build context failed (tar exit {tar.returncode})")
if proc.returncode != 0:
die("sending the agent build context to the infra VM failed: "
f"{proc.stderr.decode(errors='replace').strip() or '<no stderr>'}")
def _buildah_build(
private_key: Path,
guest_ip: str,
+5 -2
View File
@@ -17,6 +17,7 @@ from ...orchestrator.lifecycle import DEFAULT_STARTUP_TIMEOUT_SECONDS
from . import infra_vm
from .gateway import FirecrackerGateway
from .orchestrator import FirecrackerOrchestrator
from .reconcile import attach_bottled_agents_to_gateway
class FirecrackerInfraService(InfraService):
@@ -61,15 +62,17 @@ class FirecrackerInfraService(InfraService):
return url
# Clear stale/hung/OUTDATED VMs holding either link before booting fresh.
infra_vm.stop()
infra_vm.ensure_available()
infra_vm.ensure_built()
# 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
# gateway, which never sees the key (#469).
orchestrator.ensure_running(startup_timeout=startup_timeout)
self.gateway().connect_to_orchestrator(
gateway = self.gateway()
gateway.connect_to_orchestrator(
orchestrator.gateway_url(), orchestrator.mint_gateway_token())
infra_vm.record_booted_version(want)
attach_bottled_agents_to_gateway(url, gateway)
return url
def stop(self) -> None:
@@ -195,9 +195,7 @@ def _sha256_file(path: Path) -> str:
return h.hexdigest()
def ensure_artifact_gz(
version: str, *, role: str, expected_sha256: str | None = None,
) -> Path:
def ensure_artifact_gz(version: str, *, role: str) -> 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
@@ -224,11 +222,6 @@ def ensure_artifact_gz(
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(
@@ -242,13 +235,7 @@ def ensure_artifact_gz(
gz = root / _GZ_NAME
ok = root / ".verified"
if gz.is_file() and ok.is_file():
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)
return gz
info(f"pulling infra rootfs artifact {_package(role)}/{version}")
_download(artifact_url(version, _GZ_NAME, role=role), gz)
@@ -256,14 +243,6 @@ def ensure_artifact_gz(
_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)
@@ -274,22 +253,15 @@ def ensure_artifact_gz(
f" actual {actual}\n"
f" refusing to boot an unverified rootfs."
)
ok.write_text(actual + "\n")
ok.write_text("ok\n")
return gz
def materialize_ext4(
version: str,
dest: Path,
*,
role: str,
expected_sha256: str | None = None,
) -> None:
def materialize_ext4(version: str, dest: Path, *, role: str) -> 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, expected_sha256=expected_sha256)
gz = ensure_artifact_gz(version, role=role)
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:
+4 -25
View File
@@ -43,7 +43,6 @@ 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
@@ -109,18 +108,6 @@ 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.
@@ -130,16 +117,11 @@ 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 release_manifest.local_build_requested():
if infra_artifact.local_build_requested():
build_infra_images_with_docker()
return
for role in infra_artifact.ROLES:
version, expected = _role_release(role)
infra_artifact.ensure_artifact_gz(
version, role=role, expected_sha256=expected)
ensure_available = ensure_built
infra_artifact.ensure_artifact_gz(_role_version(role), role=role)
def build_infra_images_with_docker() -> None:
@@ -232,9 +214,7 @@ def boot_vm(
else:
# Prebuilt artifact already carries the role's build slack; expand it to
# a fresh writable rootfs for this boot.
version, expected = _role_release(role)
infra_artifact.materialize_ext4(
version, rootfs, role=role, expected_sha256=expected)
infra_artifact.materialize_ext4(_role_version(role), rootfs, role=role)
private_key, pubkey = _stable_keypair()
info(f"booting {role} VM on {slot.iface} (guest {slot.guest_ip})")
@@ -284,8 +264,7 @@ 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_release(role)[0]}" for role in infra_artifact.ROLES)
return " ".join(f"{role}={_role_version(role)}" for role in infra_artifact.ROLES)
def adoptable(key: Path, url: str, want: str) -> bool:
+2 -16
View File
@@ -228,13 +228,8 @@ def build_or_load_agent_base(plan: FirecrackerBottlePlan) -> Path:
info(f"resuming from committed rootfs {committed_tar}")
return util.build_committed_rootfs_dir(committed_tar)
dockerfile = Path(plan.dockerfile_path)
prebuilt = "@sha256:" in plan.image
if plan.spec.image_policy == "cached":
cached = (
image_builder.cached_agent_image_rootfs_dir(plan.image)
if prebuilt
else image_builder.cached_agent_rootfs_dir(dockerfile)
)
cached = image_builder.cached_agent_rootfs_dir(dockerfile)
if cached is None:
die(
f"cached agent rootfs for {plan.image!r} not found; "
@@ -242,11 +237,6 @@ def build_or_load_agent_base(plan: FirecrackerBottlePlan) -> Path:
)
info(f"using cached agent rootfs {cached.name}")
return cached
if prebuilt:
return image_builder.acquire_agent_image_rootfs_dir(
plan.image,
smoke_test=runtime_for(plan.agent_provider_template).smoke_test,
)
return image_builder.build_agent_rootfs_dir(
dockerfile,
image_tag=plan.image,
@@ -263,11 +253,7 @@ def stale_checks(plan: FirecrackerBottlePlan) -> None:
if committed and committed_tar.is_file():
check_stale_path(f"agent rootfs {committed_tar}", committed_tar)
return
cached = (
image_builder.cached_agent_image_rootfs_dir(plan.image)
if "@sha256:" in plan.image
else image_builder.cached_agent_rootfs_dir(Path(plan.dockerfile_path))
)
cached = image_builder.cached_agent_rootfs_dir(Path(plan.dockerfile_path))
if cached is not None:
check_stale_path(f"agent rootfs {cached}", cached / ".bb-ready")
+199
View File
@@ -0,0 +1,199 @@
"""Bring-up reconcile: re-attach all running agent VMs to a freshly-booted gateway.
Called from the cold-boot branch of `FirecrackerInfraService.ensure_running()`
after `gateway.connect_to_orchestrator()` completes. Restores the three
gateway-dependent services for every live bottle:
- **CA** push the current (freshly-minted) gateway CA to each agent's
trust store and run `update-ca-certificates`, so the agent trusts the
new CA on its next egress call.
- **git-gate** re-provision each bottle's bare repos and per-repo creds
in the gateway, rebuilt from the persisted host-side state dir.
- **egress tokens** read the agent's `ENV_VAR_SECRET` and feed
`reprovision_bottles` to restore the orchestrator's in-memory tokens.
Per-bottle failures are caught, logged, and skipped so one unreachable VM
does not block the others or the gateway coming up (PRD 0081).
"""
from __future__ import annotations
import json
import subprocess
from pathlib import Path
from ...bottle_state import git_gate_state_dir
from ...gateway import GatewayError
from ...gateway.git_gate.render import GitGateUpstream
from ...git_gate.plan import GitGatePlan
from ...log import info
from ...orchestrator.client import OrchestratorClient, OrchestratorClientError
from ...orchestrator.reprovision import reprovision_bottles
from ..provision_gateway import provision_git_gate
from ..util import AGENT_CA_PATH
from . import cleanup, util
from .gateway import FirecrackerGateway
# Where persist_env_var_secret writes the key on the agent VM (matches
# consolidated_launch._ENV_VAR_SECRET_PATH — duplicated to avoid a
# circular import through infra.py).
_ENV_VAR_SECRET_PATH = "/run/bot-bottle/env-var-secret"
def _guest_ip_from_config(config_path: Path) -> str:
"""Read the agent VM's guest IP from its Firecracker config file."""
try:
config = json.loads(config_path.read_text())
args = config["boot-source"]["boot_args"]
ip_arg = next(p for p in args.split() if p.startswith("ip="))
return ip_arg.removeprefix("ip=").split(":", 1)[0]
except (OSError, ValueError, KeyError, TypeError, StopIteration):
return ""
def attach_bottled_agents_to_gateway(
orchestrator_url: str, gateway: FirecrackerGateway,
) -> None:
"""Reconcile all live agent VMs against the freshly-booted gateway.
Fetches the new CA, lists registered bottles, then for each live run dir
pushes the CA, re-provisions git-gate, and restores egress tokens. Each
per-bottle step is wrapped so a failure is logged and skipped."""
try:
ca_pem = gateway.ca_cert_pem()
except GatewayError as exc:
info(f"bring-up reconcile: could not fetch gateway CA, skipping: {exc}")
return
client = OrchestratorClient(orchestrator_url)
try:
bottles = client.list_bottles()
except OrchestratorClientError as exc:
info(f"bring-up reconcile: could not list bottles, skipping: {exc}")
return
source_ip_to_bottle_id: dict[str, str] = {}
for b in bottles:
source_ip = b.get("source_ip")
bottle_id = b.get("bottle_id")
if isinstance(source_ip, str) and isinstance(bottle_id, str):
source_ip_to_bottle_id[source_ip] = bottle_id
transport = gateway.provisioning_transport()
secrets_by_ip: dict[str, str] = {}
for run_dir in cleanup.live_run_dirs():
slug = run_dir.name
guest_ip = _guest_ip_from_config(run_dir / "config.json")
private_key = run_dir / "bottle_id_ed25519"
if not guest_ip or not private_key.is_file():
continue
# CA: push the gateway's current certificate to the agent's trust store.
try:
_push_ca(private_key, guest_ip, ca_pem)
except Exception as exc:
info(f"bring-up reconcile: CA push to {slug!r} failed: {exc}")
# git-gate: re-provision repos + creds from the persisted state dir.
bottle_id = source_ip_to_bottle_id.get(guest_ip)
if bottle_id:
try:
_reprovision_git_gate(transport, bottle_id, slug)
except Exception as exc:
info(f"bring-up reconcile: git-gate for {slug!r} failed: {exc}")
# egress tokens: read ENV_VAR_SECRET from the agent's tmpfs.
proc = subprocess.run(
util.ssh_base_argv(private_key, guest_ip) + [f"cat {_ENV_VAR_SECRET_PATH}"],
capture_output=True, text=True, check=False,
)
if proc.returncode == 0 and proc.stdout.strip():
secrets_by_ip[guest_ip] = proc.stdout.strip()
# Restore in-memory egress tokens for all bottles that exposed a key.
if secrets_by_ip:
try:
count = reprovision_bottles(client, secrets_by_ip)
if count:
info(
f"bring-up reconcile: restored egress tokens for {count} bottle(s)"
)
except OrchestratorClientError as exc:
info(f"bring-up reconcile: egress token restore failed: {exc}")
def _push_ca(private_key: Path, guest_ip: str, ca_pem: str) -> None:
"""SSH the gateway CA PEM into the agent VM and update its trust store.
Runs as the SSH root user (the agent VM's dropbear accepts root). Each
step uses check=True so a failure raises and the caller's except clause
logs and continues."""
ssh = util.ssh_base_argv(private_key, guest_ip)
# mkdir is idempotent; rootfs builds may not preserve the target dir.
subprocess.run(
ssh + ["mkdir -p /usr/local/share/ca-certificates"],
capture_output=True, check=True,
)
subprocess.run(
ssh + [f"cat > {AGENT_CA_PATH}"],
input=ca_pem, text=True, capture_output=True, check=True,
)
subprocess.run(
ssh + [f"chmod 644 {AGENT_CA_PATH} && update-ca-certificates"],
capture_output=True, check=True,
)
def _reprovision_git_gate(
transport: object, bottle_id: str, slug: str,
) -> None:
"""Re-provision one bottle's git-gate repos and creds from its state dir.
Reads `upstreams.json` (written by `provision_git_gate_dynamic_keys` at
launch) to reconstruct the GitGateUpstream table, then calls
`provision_git_gate` which places the credential files and inits the bare
repos in the gateway. No-op when there is no `upstreams.json` (the bottle
has no git upstreams)."""
state_dir = git_gate_state_dir(slug)
upstreams_file = state_dir / "upstreams.json"
if not upstreams_file.exists():
return
raw = json.loads(upstreams_file.read_text())
upstreams: list[GitGateUpstream] = []
for u in raw:
name = u["name"]
# The key in the state dir is preferred: gitea deploy keys are written
# there by provision_git_gate_dynamic_keys; static keys keep their
# original manifest path.
key_in_state = state_dir / f"{name}-key"
identity_file = (
str(key_in_state) if key_in_state.is_file()
else u.get("identity_file", "")
)
known_hosts = state_dir / f"{name}-known_hosts"
upstreams.append(GitGateUpstream(
name=name,
upstream_url=u["upstream_url"],
upstream_host=u.get("upstream_host", ""),
upstream_port=u.get("upstream_port", ""),
identity_file=identity_file,
known_host_key=u.get("known_host_key", ""),
known_hosts_file=known_hosts if known_hosts.is_file() else Path(),
))
if not upstreams:
return
plan = GitGatePlan(
slug=slug,
entrypoint_script=state_dir / "git_gate_entrypoint.sh",
hook_script=state_dir / "git_gate_pre_receive.sh",
access_hook_script=state_dir / "git_gate_access_hook.sh",
upstreams=tuple(upstreams),
)
provision_git_gate(transport, bottle_id, plan) # type: ignore[arg-type]
__all__ = ["attach_bottled_agents_to_gateway"]
+2 -5
View File
@@ -20,7 +20,6 @@ import subprocess
import sys
from pathlib import Path
from ... import invocation
from ... import resources
from . import netpool
from . import util
@@ -180,11 +179,9 @@ def _setup_systemd() -> None:
f"sudo systemctl daemon-reload\n"
f"sudo systemctl enable --now {netpool.SYSTEMD_UNIT}\n"
)
# Absolute path, not `sudo bot-bottle`: sudo's secure_path drops
# ~/.local/bin, where both pipx and install.sh put the entry point.
sys.stderr.write(
f"\n(Or re-run this as root to install it directly:\n"
f" {invocation.sudo_command('backend', 'setup', '--backend=firecracker')})\n"
f"\n(Or re-run this as root to install it directly: "
f"sudo bot-bottle backend setup --backend=firecracker)\n"
)
@@ -84,7 +84,6 @@ 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
@@ -94,7 +93,6 @@ 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).
@@ -103,11 +101,8 @@ class MacosGateway(Gateway):
def ensure_built(self) -> None:
"""Build the data-plane image from `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)
container_mod.build_image(
self.image_ref, str(self._repo_root), dockerfile="Dockerfile.gateway")
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
+2 -13
View File
@@ -25,7 +25,6 @@ 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,
@@ -87,14 +86,6 @@ 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
@@ -107,7 +98,6 @@ 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:
@@ -122,7 +112,6 @@ 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(
@@ -138,8 +127,8 @@ class MacosInfraService(InfraService):
orchestrator = self.orchestrator()
gateway = self.gateway()
orchestrator.ensure_available()
gateway.ensure_available()
orchestrator.ensure_built()
gateway.ensure_built()
orchestrator.ensure_running(startup_timeout=startup_timeout)
+1 -9
View File
@@ -38,7 +38,6 @@ import subprocess
from contextlib import ExitStack, contextmanager
from typing import Callable, Generator
from ...agent_provider import runtime_for
from ...bottle_state import (
egress_state_dir,
git_gate_state_dir,
@@ -94,14 +93,7 @@ def _agent_image(plan: MacosContainerBottlePlan) -> str:
)
info(f"using cached agent image {plan.image!r}")
return plan.image
if "@sha256:" in plan.image:
container_mod.pull_image(plan.image)
container_mod.verify_agent_image(
plan.image, runtime_for(plan.agent_provider_template).smoke_test,
)
return plan.image
container_mod.build_image(
plan.image, str(resources.build_root()), dockerfile=plan.dockerfile_path)
container_mod.build_image(plan.image, str(resources.build_root()), dockerfile=plan.dockerfile_path)
return plan.image
@@ -63,7 +63,6 @@ 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
@@ -74,7 +73,6 @@ 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),
@@ -116,12 +114,8 @@ 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."""
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)
container_mod.build_image(
self.image_ref, str(self._repo_root), dockerfile="Dockerfile.orchestrator")
def ensure_running(
self, *, startup_timeout: float = DEFAULT_STARTUP_TIMEOUT_SECONDS,
@@ -152,6 +146,11 @@ 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}",
@@ -162,14 +161,6 @@ 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,19 +101,6 @@ 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
+1 -7
View File
@@ -9,7 +9,7 @@ backend-specific final resolution.
from __future__ import annotations
import os
from dataclasses import dataclass, replace
from dataclasses import dataclass
from typing import TYPE_CHECKING, Protocol
from ..agent_provider import AgentProvisionPlan, build_agent_provision_plan, get_provider
@@ -17,7 +17,6 @@ from ..egress import EgressPlan
from ..env import ResolvedEnv, resolve_env
from ..git_gate import GitGate, GitGatePlan
from ..manifest import Manifest
from ..release_manifest import oci_image
from ..supervisor.plan import SupervisePlan
from ..workspace import workspace_plan
from .resolve_common import (
@@ -113,11 +112,6 @@ class BottlePreparationPlanner:
color=spec.color,
provider_settings=provider_config.settings,
)
if not provider_config.dockerfile:
image, local = oci_image(
f"agent_{provider_config.template}", provision.image)
if not local:
provision = replace(provision, image=image)
provision = merge_provision_env_vars(provision)
return PreparedBottle(
manifest=manifest,
+3 -5
View File
@@ -115,12 +115,10 @@ 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:
"""Local-build implementation hook retained for backend compatibility."""
"""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`."""
return
@abc.abstractmethod
+27 -1
View File
@@ -8,6 +8,7 @@ imported (`deploy_key_provisioner`) to keep its cost off the host path.
from __future__ import annotations
import json
import os
import dataclasses
from pathlib import Path
@@ -138,7 +139,32 @@ def provision_git_gate_dynamic_keys(
if upstream.name not in updated_names:
updated.append(upstream)
return dataclasses.replace(plan, upstreams=tuple(updated))
final_plan = dataclasses.replace(plan, upstreams=tuple(updated))
_write_upstreams_snapshot(stage_dir, final_plan.upstreams)
return final_plan
def _write_upstreams_snapshot(
stage_dir: Path, upstreams: tuple[GitGateUpstream, ...]
) -> None:
"""Persist the fully-resolved upstream table to `upstreams.json` in
`stage_dir` so the bring-up reconcile can re-provision git-gate without
the manifest. Written after dynamic keys are provisioned so every
identity_file is set."""
data = [
{
"name": u.name,
"upstream_url": u.upstream_url,
"upstream_host": u.upstream_host,
"upstream_port": u.upstream_port,
"identity_file": u.identity_file,
"known_host_key": u.known_host_key,
}
for u in upstreams
]
snapshot = stage_dir / "upstreams.json"
snapshot.write_text(json.dumps(data, indent=2))
snapshot.chmod(0o600)
__all__ = [
-48
View File
@@ -1,48 +0,0 @@
"""How to tell a user to re-run this CLI.
`bot-bottle ` is the right thing to print for anything the user runs as
themselves it is on their PATH, since that is how they got here.
Under `sudo` it is not. sudo replaces PATH with sudoers' `secure_path`
(`/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin` on Debian and
Ubuntu, similar elsewhere), which deliberately excludes user-writable
directories. Both supported install paths put the entry point in one of those:
pipx uses `~/.local/bin`, and `install.sh`'s venv fallback symlinks there too.
So `sudo bot-bottle ` fails with "command not found" for exactly the users who
followed the documented install, while working for anyone who happened to
install system-wide which is why it survives review so easily.
Naming the absolute path sidesteps secure_path entirely.
"""
from __future__ import annotations
import os
import shutil
import sys
def self_path() -> str:
"""Absolute path to this CLI's entry point.
Falls back to the bare name when the entry point cannot be resolved (an
unusual invocation such as `python -m`), because a slightly wrong hint is
better than a traceback while reporting an unrelated problem.
"""
argv0 = sys.argv[0] or "bot-bottle"
resolved = shutil.which(argv0) or argv0
if not os.path.isabs(resolved):
if os.path.exists(resolved):
resolved = os.path.abspath(resolved)
else:
return "bot-bottle"
return resolved
def sudo_command(*args: str) -> str:
"""A copy-pasteable `sudo …` invocation of this CLI.
>>> sudo_command("backend", "setup", "--backend=firecracker")
'sudo /home/u/.local/bin/bot-bottle backend setup --backend=firecracker'
"""
return " ".join(["sudo", self_path(), *args])
+21
View File
@@ -50,6 +50,12 @@ class ReprovisionBody(_StrictModel):
env_var_secret: StrictStr
class SecretBody(_StrictModel):
name: StrictStr
value: StrictStr
env_var_secret: StrictStr
class ReconcileBody(_StrictModel):
live_source_ips: list[StrictStr]
grace_seconds: float | None = None
@@ -259,6 +265,21 @@ def create_app(orch: OrchestratorCore, *, signing_key: str) -> FastAPI:
return {"reprovisioned": True}
raise HTTPException(404, "no stored secrets for this bottle")
@app.post("/bottles/{bottle_id}/secret")
def update_secret(bottle_id: str, body: SecretBody) -> dict[str, object]:
# Update ONE egress token for a running bottle in place — the
# single-secret form of reprovision, for refreshing a short-lived host
# credential (e.g. the Codex access token) without a relaunch. cli-only
# (not in _GATEWAY_ROUTES): a bottle must never set its own tokens.
if orch.update_agent_secret(
bottle_id,
_required(body.name, "name"),
_required(body.value, "value"),
_required(body.env_var_secret, "env_var_secret"),
):
return {"updated": True}
raise HTTPException(404, "no such bottle")
@app.delete("/bottles/{bottle_id}")
def teardown(bottle_id: str) -> dict[str, object]:
if orch.teardown_bottle(bottle_id):
+21
View File
@@ -184,6 +184,27 @@ class OrchestratorClient:
)
return True
def update_agent_secret(
self, bottle_id: str, name: str, value: str, env_var_secret: str,
) -> bool:
"""Update ONE egress token for a running bottle in place
(`POST /bottles/<id>/secret`) the single-secret form of
`reprovision_gateway`, for pushing a freshly-refreshed host credential
into a bottle without a relaunch. Returns True on success, False when the
orchestrator doesn't know the bottle (404)."""
status, _ = self._request(
"POST",
f"/bottles/{bottle_id}/secret",
{"name": name, "value": value, "env_var_secret": env_var_secret},
)
if status == 404:
return False
if not 200 <= status < 300:
raise OrchestratorClientError(
f"update_agent_secret {bottle_id}: HTTP {status}"
)
return True
def teardown_bottle(self, bottle_id: str) -> bool:
"""Tear a bottle down (`DELETE /bottles/<id>`). False if the
orchestrator didn't know it (404) — idempotent for cleanup paths."""
+3 -5
View File
@@ -63,12 +63,10 @@ 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:
"""Local-build implementation hook retained for backend compatibility."""
"""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`."""
return
@abc.abstractmethod
+24
View File
@@ -379,6 +379,30 @@ class OrchestratorCore:
self._tokens[bottle_id] = decrypted
return True
def update_agent_secret(
self, bottle_id: str, name: str, value: str, env_var_secret: str,
) -> bool:
"""Update ONE egress token for a known bottle in place — the
single-secret form of ``reprovision_from_secret``.
Sets the in-memory token AND upserts the single re-encrypted row under
*env_var_secret* (the same key the rest of the rows are encrypted with, so
the whole set stays decryptable by a later ``reprovision_from_secret``),
leaving every other token untouched. Returns False if the bottle is
unknown.
Unlike ``reprovision_from_secret`` (which restores the values captured at
launch), this pushes a *caller-supplied* value used to refresh a
short-lived host credential (e.g. the Codex access token) into a
still-running bottle without a relaunch."""
from .store.secret_store import encrypt_value
if self.registry.get(bottle_id) is None:
return False
self._tokens.setdefault(bottle_id, {})[name] = value
self.registry.store_agent_secret(
bottle_id, name, encrypt_value(env_var_secret, value))
return True
# --- consolidated gateway ----------------------------------------------
def gateway_status(self) -> dict[str, object]:
@@ -370,6 +370,30 @@ class RegistryStore(DbStore):
)
self._chmod()
def store_agent_secret(
self,
bottle_id: str,
key: str,
encrypted_value: str,
secret_type: str = "injected_env_var",
) -> None:
"""Upsert ONE encrypted secret row (env-var name → ciphertext) for
*bottle_id*, leaving the bottle's other secrets untouched — the per-key
counterpart of ``store_agent_secrets``' replace-all. Delete-then-insert
because the table carries no unique constraint to `ON CONFLICT` against."""
with self._connection() as conn:
conn.execute(
"DELETE FROM bottled_agent_secrets "
"WHERE bottled_agent_id = ? AND key = ? AND type = ?",
(bottle_id, key, secret_type),
)
conn.execute(
"INSERT INTO bottled_agent_secrets "
"(bottled_agent_id, key, value, type) VALUES (?, ?, ?, ?)",
(bottle_id, key, encrypted_value, secret_type),
)
self._chmod()
def get_agent_secrets(
self,
bottle_id: str,
-1
View File
@@ -1 +0,0 @@
{"schema": 1, "development": true}
-127
View File
@@ -1,127 +0,0 @@
"""External, commit-addressed release bundle metadata."""
from __future__ import annotations
import hashlib
import json
import re
from pathlib import Path
from typing import Any
from .release_manifest import ReleaseManifestError, parse_manifest
_COMMIT_RE = re.compile(r"^[0-9a-f]{40}$")
_SHA_RE = re.compile(r"^[0-9a-f]{64}$")
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as stream:
for chunk in iter(lambda: stream.read(1 << 20), b""):
digest.update(chunk)
return digest.hexdigest()
def build_bundle_index(
manifest_data: Any,
*,
wheel: Path,
wheel_url: str,
workflow_run: str,
published_at: str,
) -> dict[str, Any]:
"""Build and validate the immutable external index for one source commit."""
manifest = parse_manifest(manifest_data)
if not wheel.is_file() or wheel.suffix != ".whl":
raise ReleaseManifestError("release bundle wheel must be one .whl file")
if not wheel_url.startswith("https://") or not wheel_url.endswith(wheel.name):
raise ReleaseManifestError(
"release bundle wheel URL must be HTTPS and end with the wheel filename")
if not workflow_run.strip() or not published_at.strip():
raise ReleaseManifestError(
"release bundle provenance requires workflow_run and published_at")
return {
"schema": 1,
"source_commit": manifest.source_commit,
"wheel": {
"filename": wheel.name,
"url": wheel_url,
"sha256": sha256_file(wheel),
},
"oci": {
"orchestrator": manifest.orchestrator_image,
"gateway": manifest.gateway_image,
**{
f"agent_{role}": reference
for role, reference in manifest.agent_images.items()
},
},
"firecracker": {
"orchestrator": {
"version": manifest.firecracker_orchestrator.version,
"sha256": manifest.firecracker_orchestrator.sha256,
},
"gateway": {
"version": manifest.firecracker_gateway.version,
"sha256": manifest.firecracker_gateway.sha256,
},
},
"provenance": {
"workflow_run": workflow_run.strip(),
"published_at": published_at.strip(),
},
"qualifications": [],
}
def parse_bundle_index(data: Any) -> dict[str, Any]:
"""Validate an index before publication or installer consumption."""
if not isinstance(data, dict) or data.get("schema") != 1:
raise ReleaseManifestError("release bundle index must use schema 1")
commit = data.get("source_commit")
if not isinstance(commit, str) or _COMMIT_RE.fullmatch(commit) is None:
raise ReleaseManifestError(
"release bundle source_commit must be 40 lowercase hex")
wheel = data.get("wheel")
if not isinstance(wheel, dict):
raise ReleaseManifestError("release bundle wheel must be an object")
filename, url, digest = (
wheel.get("filename"), wheel.get("url"), wheel.get("sha256"))
if not isinstance(filename, str) or not filename.endswith(".whl"):
raise ReleaseManifestError("release bundle wheel filename must end in .whl")
if not isinstance(url, str) or not url.startswith("https://") or not url.endswith(filename):
raise ReleaseManifestError(
"release bundle wheel URL must be HTTPS and match its filename")
if not isinstance(digest, str) or _SHA_RE.fullmatch(digest) is None:
raise ReleaseManifestError(
"release bundle wheel sha256 must be 64 lowercase hex")
manifest_data = {
"schema": 1,
"source_commit": commit,
"oci": data.get("oci"),
"firecracker": data.get("firecracker"),
}
parse_manifest(manifest_data)
provenance = data.get("provenance")
if not isinstance(provenance, dict) or not all(
isinstance(provenance.get(key), str) and provenance[key].strip()
for key in ("workflow_run", "published_at")
):
raise ReleaseManifestError("release bundle provenance is incomplete")
qualifications = data.get("qualifications")
if not isinstance(qualifications, list):
raise ReleaseManifestError("release bundle qualifications must be an array")
return data
def canonical_bytes(data: Any) -> bytes:
parse_bundle_index(data)
return (json.dumps(data, indent=2, sort_keys=True) + "\n").encode("utf-8")
__all__ = [
"build_bundle_index",
"canonical_bytes",
"parse_bundle_index",
"sha256_file",
]
-154
View File
@@ -1,154 +0,0 @@
"""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
agent_images: dict[str, 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", "agent_claude", "agent_codex", "agent_pi"):
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"],
agent_images={
role: images[f"agent_{role}"] for role in ("claude", "codex", "pi")
},
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 if role == "gateway"
else manifest.agent_images[role.removeprefix("agent_")],
False,
)
__all__ = [
"FirecrackerArtifact",
"ReleaseManifest",
"ReleaseManifestError",
"load_manifest",
"local_build_requested",
"oci_image",
"packaged_manifest",
"parse_manifest",
]
-148
View File
@@ -1,148 +0,0 @@
"""Durable publication of immutable, commit-addressed release bundles."""
from __future__ import annotations
import hashlib
import os
import urllib.error
import urllib.request
from pathlib import Path
from .release_bundle import canonical_bytes, parse_bundle_index, sha256_file
from .release_manifest import ReleaseManifestError
HTTP_TIMEOUT_SECONDS = 60.0
INDEX_NAME = "bundle-index.json"
PACKAGE_NAME = "bot-bottle-builds"
def config() -> tuple[str, str, str]:
"""Return generic-package base URL, owner, and write token."""
base = os.environ.get(
"BOT_BOTTLE_RELEASE_BASE", "https://gitea.dideric.is").rstrip("/")
owner = os.environ.get("BOT_BOTTLE_RELEASE_OWNER", "didericis").strip()
token = os.environ.get("BOT_BOTTLE_RELEASE_TOKEN", "")
return base, owner, token
def bundle_url(source_commit: str, filename: str) -> str:
base, owner, _ = config()
return (
f"{base}/api/packages/{owner}/generic/{PACKAGE_NAME}/"
f"{source_commit}/{filename}"
)
def request(url: str, *, method: str = "GET", data: object | None = None,
length: int | None = None) -> urllib.request.Request:
result = urllib.request.Request(url, data=data, method=method) # type: ignore[arg-type]
_, _, token = config()
if token:
result.add_header("Authorization", f"token {token}")
if length is not None:
result.add_header("Content-Length", str(length))
result.add_header("Content-Type", "application/octet-stream")
return result
def remote_bytes(url: str) -> bytes | None:
try:
with urllib.request.urlopen(
request(url), timeout=HTTP_TIMEOUT_SECONDS,
) as response:
return response.read()
except urllib.error.HTTPError as exc:
if exc.code == 404:
return None
raise ReleaseManifestError(
f"checking release bundle failed (HTTP {exc.code}): {url}") from exc
except urllib.error.URLError as exc:
raise ReleaseManifestError(
f"release registry unreachable: {url} ({exc.reason})") from exc
def remote_sha256(url: str) -> str | None:
try:
with urllib.request.urlopen(
request(url), timeout=HTTP_TIMEOUT_SECONDS,
) as response:
digest = hashlib.sha256()
for chunk in iter(lambda: response.read(1 << 20), b""):
digest.update(chunk)
return digest.hexdigest()
except urllib.error.HTTPError as exc:
if exc.code == 404:
return None
raise ReleaseManifestError(
f"checking release artifact failed (HTTP {exc.code}): {url}") from exc
except urllib.error.URLError as exc:
raise ReleaseManifestError(
f"release registry unreachable: {url} ({exc.reason})") from exc
def upload(url: str, source: bytes | Path) -> None:
handle = None
if isinstance(source, Path):
handle = source.open("rb")
data: object = handle
length = source.stat().st_size
else:
data = source
length = len(source)
try:
with urllib.request.urlopen(
request(url, method="PUT", data=data, length=length),
timeout=HTTP_TIMEOUT_SECONDS,
):
pass
except (urllib.error.HTTPError, urllib.error.URLError) as exc:
raise ReleaseManifestError(
f"publishing release artifact failed: {url} ({exc})") from exc
finally:
if handle is not None:
handle.close()
def publish_bundle(index: dict[str, object], wheel: Path) -> str:
"""Publish wheel first and index last, or verify an existing exact bundle."""
parse_bundle_index(index)
source_commit = str(index["source_commit"])
wheel_data = index["wheel"]
assert isinstance(wheel_data, dict)
filename = str(wheel_data["filename"])
expected_sha = str(wheel_data["sha256"])
if wheel.name != filename or sha256_file(wheel) != expected_sha:
raise ReleaseManifestError(
"release bundle wheel does not match its index")
wheel_url = bundle_url(source_commit, filename)
index_url = bundle_url(source_commit, INDEX_NAME)
if wheel_data["url"] != wheel_url:
raise ReleaseManifestError(
"release bundle wheel URL does not match its commit coordinate")
wanted_index = canonical_bytes(index)
existing_index = remote_bytes(index_url)
existing_wheel_sha = remote_sha256(wheel_url)
if existing_index is not None:
if existing_index != wanted_index or existing_wheel_sha != expected_sha:
raise ReleaseManifestError(
f"immutable release bundle {source_commit} already differs")
return index_url
if existing_wheel_sha is not None:
raise ReleaseManifestError(
f"partial release bundle {source_commit} exists without its index; "
"administrative cleanup is required")
upload(wheel_url, wheel)
upload(index_url, wanted_index)
return index_url
__all__ = [
"INDEX_NAME",
"PACKAGE_NAME",
"bundle_url",
"config",
"publish_bundle",
]
-197
View File
@@ -1,197 +0,0 @@
"""Validated release and channel pointers for qualified commit bundles."""
from __future__ import annotations
import json
import re
import urllib.error
import urllib.request
from typing import Any
from .release_bundle import parse_bundle_index
from .release_manifest import ReleaseManifestError
from .release_publish import (
HTTP_TIMEOUT_SECONDS,
bundle_url,
config,
remote_bytes,
request,
upload,
)
_COMMIT_RE = re.compile(r"^[0-9a-f]{40}$")
_STABLE_TAG_RE = re.compile(r"^v(\d+)\.(\d+)\.(\d+)$")
_STAGING_TAG_RE = re.compile(r"^v(\d+)\.(\d+)\.(\d+)-rc\.(\d+)$")
def release_channel(tag: str) -> str:
"""Return the only channel eligible for *tag*."""
if _STABLE_TAG_RE.fullmatch(tag):
return "production"
if _STAGING_TAG_RE.fullmatch(tag):
return "staging"
raise ReleaseManifestError(
"release tag must be vX.Y.Z or vX.Y.Z-rc.N")
def release_version(tag: str) -> tuple[int, int, int, int]:
"""Return a monotonically comparable version tuple."""
stable = _STABLE_TAG_RE.fullmatch(tag)
if stable:
return (
int(stable.group(1)),
int(stable.group(2)),
int(stable.group(3)),
1 << 30,
)
staging = _STAGING_TAG_RE.fullmatch(tag)
if staging:
return (
int(staging.group(1)),
int(staging.group(2)),
int(staging.group(3)),
int(staging.group(4)),
)
raise ReleaseManifestError(
"release tag must be vX.Y.Z or vX.Y.Z-rc.N")
def pointer_url(package: str, version: str, filename: str) -> str:
base, owner, _ = config()
return f"{base}/api/packages/{owner}/generic/{package}/{version}/{filename}"
def build_release_pointer(
*,
tag: str,
source_commit: str,
workflow_run: str,
qualified_at: str,
) -> dict[str, Any]:
"""Build a qualified pointer to one immutable commit bundle."""
channel = release_channel(tag)
if _COMMIT_RE.fullmatch(source_commit) is None:
raise ReleaseManifestError(
"release source_commit must be 40 lowercase hex")
if not workflow_run.strip() or not qualified_at.strip():
raise ReleaseManifestError(
"release qualification provenance is incomplete")
return {
"schema": 1,
"qualified": True,
"tag": tag,
"channel": channel,
"source_commit": source_commit,
"bundle_index_url": bundle_url(source_commit, "bundle-index.json"),
"qualification": {
"workflow_run": workflow_run.strip(),
"qualified_at": qualified_at.strip(),
},
}
def parse_release_pointer(data: Any) -> dict[str, Any]:
"""Validate a release or channel pointer."""
if not isinstance(data, dict) or data.get("schema") != 1:
raise ReleaseManifestError("release pointer must use schema 1")
if data.get("qualified") is not True:
raise ReleaseManifestError("release pointer must be qualified")
tag = data.get("tag")
channel = data.get("channel")
commit = data.get("source_commit")
if not isinstance(tag, str) or release_channel(tag) != channel:
raise ReleaseManifestError("release pointer tag and channel disagree")
if not isinstance(commit, str) or _COMMIT_RE.fullmatch(commit) is None:
raise ReleaseManifestError("release pointer source commit is invalid")
if data.get("bundle_index_url") != bundle_url(commit, "bundle-index.json"):
raise ReleaseManifestError("release pointer bundle URL is invalid")
qualification = data.get("qualification")
if not isinstance(qualification, dict) or not all(
isinstance(qualification.get(key), str) and qualification[key].strip()
for key in ("workflow_run", "qualified_at")
):
raise ReleaseManifestError("release qualification is incomplete")
return data
def canonical_pointer(data: Any) -> bytes:
parse_release_pointer(data)
return (json.dumps(data, indent=2, sort_keys=True) + "\n").encode()
def delete(url: str) -> None:
try:
with urllib.request.urlopen(
request(url, method="DELETE"), timeout=HTTP_TIMEOUT_SECONDS,
):
pass
except urllib.error.HTTPError as exc:
if exc.code != 404:
raise ReleaseManifestError(
f"replacing release channel failed (HTTP {exc.code}): {url}"
) from exc
except urllib.error.URLError as exc:
raise ReleaseManifestError(
f"release registry unreachable: {url} ({exc.reason})") from exc
def publish_qualification(
pointer: dict[str, Any], *, allow_rollback: bool = False,
) -> tuple[str, str]:
"""Publish an immutable tag pointer and advance its channel."""
parse_release_pointer(pointer)
tag = str(pointer["tag"])
channel = str(pointer["channel"])
wanted = canonical_pointer(pointer)
release_url = pointer_url(
"bot-bottle-releases", tag, "release.json")
channel_url = pointer_url(
"bot-bottle-channels", channel, "channel.json")
bundle = remote_bytes(str(pointer["bundle_index_url"]))
if bundle is None:
raise ReleaseManifestError(
f"commit bundle does not exist for {pointer['source_commit']}")
try:
bundle_data = parse_bundle_index(json.loads(bundle))
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
raise ReleaseManifestError("commit bundle index is malformed") from exc
if bundle_data["source_commit"] != pointer["source_commit"]:
raise ReleaseManifestError(
"commit bundle index does not match the qualified source commit")
existing_release = remote_bytes(release_url)
if existing_release is not None and existing_release != wanted:
raise ReleaseManifestError(
f"immutable release pointer {tag} already differs")
if existing_release is None:
upload(release_url, wanted)
existing_channel = remote_bytes(channel_url)
if existing_channel == wanted:
return release_url, channel_url
if existing_channel is not None:
try:
current = parse_release_pointer(json.loads(existing_channel))
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
raise ReleaseManifestError(
f"existing {channel} channel pointer is malformed") from exc
if (
not allow_rollback
and release_version(tag) <= release_version(str(current["tag"]))
):
raise ReleaseManifestError(
f"{channel} channel update is not monotonic; "
"use the explicit rollback operation")
delete(channel_url)
upload(channel_url, wanted)
return release_url, channel_url
__all__ = [
"build_release_pointer",
"canonical_pointer",
"parse_release_pointer",
"publish_qualification",
"release_channel",
"release_version",
]