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
+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,