feat(firecracker): pull the infra rootfs as a prebuilt artifact (PRD 0069 Stage 2) #395

Merged
didericis merged 5 commits from nix-fixed-images-stage2 into main 2026-07-17 00:15:18 -04:00
7 changed files with 704 additions and 19 deletions
@@ -0,0 +1,199 @@
"""Prebuilt infra-VM rootfs, pulled as an artifact (PRD 0069 Stage 2).
The Firecracker infra VM boots a fixed rootfs (orchestrator control plane +
gateway + buildah, control-plane init as PID 1) that does not vary per launch —
the per-boot bits (authorized_keys, guest IP) ride the kernel cmdline, so one
rootfs boots on any host. Instead of building that rootfs on the launch host
with Docker, we build it **off-host** and publish it as a versioned, ready-to-
boot ext4 (gzip-compressed) to a Gitea **generic package**; the launch host
downloads + verifies + boots it. No Docker, no image tooling on the launch
host — just an HTTP fetch and gunzip.
publish (off-host, see publish_infra.py):
docker build -> rootfs dir -> mke2fs -> gzip -> PUT generic package
pull (this module, launch host):
GET .../rootfs.ext4.gz (+ .sha256) -> verify -> gunzip -> boot
The artifact **version** is a content hash of everything baked into the rootfs
(the shipped bot_bottle package, the three Dockerfiles, and the init), so a
launch host always pulls the artifact matching its code and a content change
can't silently boot a stale rootfs. A checksum mismatch fails closed.
Set `BOT_BOTTLE_INFRA_BUILD=local` to skip the pull and build the rootfs
locally with Docker (dev iteration on the Dockerfiles) — see `infra_vm`.
"""
from __future__ import annotations
import gzip
import hashlib
import os
import shutil
import urllib.error
import urllib.request
from pathlib import Path
from ...log import die, info
from . import util
# Bump if the on-disk artifact *format* changes (compression, layout) so a new
# scheme can't collide with a cached/published artifact of the old one.
_ARTIFACT_FORMAT = "1"
_REPO_ROOT = Path(__file__).resolve().parents[3]
_DOCKERFILES = ("Dockerfile.orchestrator", "Dockerfile.gateway", "Dockerfile.infra")
_DEFAULT_BASE = "https://gitea.dideric.is"
_DEFAULT_OWNER = "didericis"
_PACKAGE = "bot-bottle-firecracker-infra"
# Streaming copy chunk for the (hundreds-of-MB) download.
_CHUNK = 1 << 20
def local_build_requested() -> bool:
"""True when the operator opted into the dev Docker-build path instead of
pulling the published artifact (`BOT_BOTTLE_INFRA_BUILD=local`)."""
return os.environ.get("BOT_BOTTLE_INFRA_BUILD", "").strip().lower() == "local"
Outdated
Review

If this isn't set, should fallback to a config setting from the sqlite db... that will I think require having the host do a request to the orchestrator via HTTP, since the db now lives in the infra vm. Evaluate the merits of that decision/help me determine whether it's worth it.

If this isn't set, should fallback to a config setting from the sqlite db... that will I think require having the host do a request to the orchestrator via HTTP, since the db now lives in the infra vm. Evaluate the merits of that decision/help me determine whether it's worth it.
Outdated
Review

I don’t think an orchestrator/SQLite fallback is worth adding for BOT_BOTTLE_INFRA_BUILD. This setting is needed while deciding how to obtain the rootfs, before the infra VM can be booted; on a cold start there is no orchestrator to query until after that decision succeeds, so an HTTP fallback creates a bootstrap cycle. It would also move a host bootstrap choice into the runtime-state database, while the registry module explicitly says declarative config stays under ~/.bot-bottle/.

My recommendation: keep the environment variable as the immediate override in this PR. If we want a persistent default later, read it from a small host-side declarative config file (env > host config > pull), not from the infra VM database. That works for first boot, recovery when the VM is unhealthy, and offline/local-build operation without introducing a new unauthenticated pre-boot control-plane endpoint.

I don’t think an orchestrator/SQLite fallback is worth adding for `BOT_BOTTLE_INFRA_BUILD`. This setting is needed while deciding how to obtain the rootfs, before the infra VM can be booted; on a cold start there is no orchestrator to query until after that decision succeeds, so an HTTP fallback creates a bootstrap cycle. It would also move a host bootstrap choice into the runtime-state database, while the registry module explicitly says declarative config stays under `~/.bot-bottle/`. My recommendation: keep the environment variable as the immediate override in this PR. If we want a persistent default later, read it from a small host-side declarative config file (env > host config > `pull`), not from the infra VM database. That works for first boot, recovery when the VM is unhealthy, and offline/local-build operation without introducing a new unauthenticated pre-boot control-plane endpoint.
Outdated
Review

@didericis-codex duh, right, good call. My bad. That said, it's unfortunate how the db can't be mounted on the host. A host mounted db would be a great way to centralize both state and config, think it'd be ideal. Env vars work fine for now, but it'd be a bit more versatile to drive this from a db. Would also be cool if we could have a global edit panel and view into this from the console. That way the console can drive headless servers more easily/without needing to go onto the host.

@didericis-codex duh, right, good call. My bad. That said, it's unfortunate how the db can't be mounted on the host. A host mounted db would be a great way to centralize both state and config, think it'd be ideal. Env vars work fine for now, but it'd be a bit more versatile to drive this from a db. Would also be cool if we could have a global edit panel and view into this from the console. That way the console can drive headless servers more easily/without needing to go onto the host.
Outdated
Review

Agreed on the product goal: the console should have a global settings view and be able to manage a headless host without SSH. I’d separate that from making the SQLite file host-mounted, though. The registry ext4 is a block device attached read/write to the VM; mounting it concurrently on the host would give ext4 two independent writers and risks filesystem/DB corruption. Moving SQLite onto a shared filesystem also makes its locking/durability semantics the hard part.

A cleaner split seems to be:

  • Keep the tiny set of bootstrap settings needed to start/recover the infra VM (pull vs local, artifact registry location/auth) in host-owned config/env.
  • Put global runtime config in the central DB, owned exclusively by the orchestrator, and expose typed config CRUD over its HTTP API. The console can then view/edit it remotely without mounting the DB or logging into the host.
  • If the console must change bootstrap settings or recover a dead infra VM remotely, add a minimal host-side supervisor/API for those operations; the in-VM orchestrator cannot serve that role while it is down.

That preserves the centralized console experience for nearly everything while keeping the unavoidable pre-boot layer explicit and small. I’d treat the config API/console panel as a follow-up rather than expand this artifact PR.

Agreed on the product goal: the console should have a global settings view and be able to manage a headless host without SSH. I’d separate that from making the SQLite file host-mounted, though. The registry ext4 is a block device attached read/write to the VM; mounting it concurrently on the host would give ext4 two independent writers and risks filesystem/DB corruption. Moving SQLite onto a shared filesystem also makes its locking/durability semantics the hard part. A cleaner split seems to be: - Keep the tiny set of **bootstrap settings** needed to start/recover the infra VM (`pull` vs `local`, artifact registry location/auth) in host-owned config/env. - Put **global runtime config** in the central DB, owned exclusively by the orchestrator, and expose typed config CRUD over its HTTP API. The console can then view/edit it remotely without mounting the DB or logging into the host. - If the console must change bootstrap settings or recover a dead infra VM remotely, add a minimal host-side supervisor/API for those operations; the in-VM orchestrator cannot serve that role while it is down. That preserves the centralized console experience for nearly everything while keeping the unavoidable pre-boot layer explicit and small. I’d treat the config API/console panel as a follow-up rather than expand this artifact PR.
def infra_artifact_version(init_script: str, *, repo_root: Path = _REPO_ROOT) -> str:
"""Content hash (16 hex) of everything baked into the infra rootfs: the
whole shipped `bot_bottle` package, the three fixed Dockerfiles, and the
guest init. Deterministic across the publish host and the launch host when
both run the same checkout, so the tag the launch host pulls is exactly the
tag publish produced.
The package is `COPY bot_bottle /app/bot_bottle`'d wholesale into the image,
so hash *every* regular file under it — not just `*.py`. Non-Python inputs
(e.g. `egress_entrypoint.sh`, `netpool.defaults.env`) are baked in too, and
Outdated
Review

[P1] Include every copied runtime file in the artifact version. This loop hashes only *.py, but Dockerfile.gateway also copies bot_bottle/egress_entrypoint.sh into /app/egress-entrypoint.sh, and Dockerfile.orchestrator copies the package directory wholesale. A change to that shell entrypoint therefore leaves the version unchanged, so a launch host can fetch and boot an older rootfs even though its checkout contains different runtime code. Hash the actual Docker build inputs (respecting .dockerignore) or at minimum all files explicitly copied into these images, and add a regression test showing that changing the shell script changes the version.

**[P1] Include every copied runtime file in the artifact version.** This loop hashes only `*.py`, but `Dockerfile.gateway` also copies `bot_bottle/egress_entrypoint.sh` into `/app/egress-entrypoint.sh`, and `Dockerfile.orchestrator` copies the package directory wholesale. A change to that shell entrypoint therefore leaves the version unchanged, so a launch host can fetch and boot an older rootfs even though its checkout contains different runtime code. Hash the actual Docker build inputs (respecting `.dockerignore`) or at minimum all files explicitly copied into these images, and add a regression test showing that changing the shell script changes the version.
a change to one must bump the version or a launch host could boot a stale
rootfs whose code differs from its checkout. `__pycache__`/`.pyc` are the
only exclusions — build artifacts, never copied."""
h = hashlib.sha256()
h.update(f"format={_ARTIFACT_FORMAT}\n".encode())
pkg = repo_root / "bot_bottle"
for path in sorted(pkg.rglob("*")):
if not path.is_file():
continue
if "__pycache__" in path.parts or path.suffix == ".pyc":
continue
h.update(str(path.relative_to(repo_root)).encode())
h.update(b"\0")
h.update(path.read_bytes())
for name in _DOCKERFILES:
h.update(name.encode())
h.update(b"\0")
h.update((repo_root / name).read_bytes())
h.update(b"init\0")
h.update(init_script.encode())
return h.hexdigest()[:16]
def _config() -> tuple[str, str, str]:
"""(base_url, owner, token) for the generic-package endpoint. Base + owner
are overridable for other deployments / mirrors; the token comes solely from
`BOT_BOTTLE_INFRA_ARTIFACT_TOKEN` (a dedicated package-scoped token, kept
separate from the general-purpose Gitea token) and is optional — a public
package needs none to pull."""
base = os.environ.get("BOT_BOTTLE_INFRA_ARTIFACT_BASE", _DEFAULT_BASE).rstrip("/")
owner = os.environ.get("BOT_BOTTLE_INFRA_ARTIFACT_OWNER", _DEFAULT_OWNER)
token = os.environ.get("BOT_BOTTLE_INFRA_ARTIFACT_TOKEN", "")
return base, owner, token
def artifact_url(version: str, filename: str) -> str:
"""The generic-package download URL for one file of this version's
artifact (`rootfs.ext4.gz` / `rootfs.ext4.gz.sha256`)."""
base, owner, _ = _config()
return f"{base}/api/packages/{owner}/generic/{_PACKAGE}/{version}/{filename}"
_GZ_NAME = "rootfs.ext4.gz"
_SHA_NAME = "rootfs.ext4.gz.sha256"
def _cache_root(version: str) -> Path:
return util.cache_dir() / "infra-artifact" / version
def _open(url: str) -> urllib.request.Request:
_, _, token = _config()
req = urllib.request.Request(url)
if token:
req.add_header("Authorization", f"token {token}")
return req
def _download(url: str, dest: Path) -> None:
"""Stream `url` to `dest` (atomic via a `.part` sibling)."""
tmp = dest.with_suffix(dest.suffix + ".part")
try:
with urllib.request.urlopen(_open(url)) as resp, open(tmp, "wb") as out:
shutil.copyfileobj(resp, out, _CHUNK)
except urllib.error.HTTPError as e:
tmp.unlink(missing_ok=True)
if e.code == 404:
die(
f"infra artifact not published for this code version.\n"
f" missing: {url}\n"
f" publish it from a build host (Docker):\n"
f" python3 -m bot_bottle.backend.firecracker.publish_infra\n"
f" or build the rootfs locally: BOT_BOTTLE_INFRA_BUILD=local"
)
die(f"downloading infra artifact failed (HTTP {e.code}): {url}")
except urllib.error.URLError as e:
tmp.unlink(missing_ok=True)
die(f"infra artifact registry unreachable: {url} ({e.reason})")
tmp.replace(dest)
def _sha256_file(path: Path) -> str:
h = hashlib.sha256()
with open(path, "rb") as fh:
for chunk in iter(lambda: fh.read(_CHUNK), b""):
h.update(chunk)
return h.hexdigest()
def ensure_artifact_gz(version: str) -> Path:
"""The verified, cached `rootfs.ext4.gz` for `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 unverified rootfs."""
root = _cache_root(version)
root.mkdir(parents=True, exist_ok=True)
gz = root / _GZ_NAME
ok = root / ".verified"
if gz.is_file() and ok.is_file():
return gz
info(f"pulling infra rootfs artifact {_PACKAGE}/{version}")
_download(artifact_url(version, _GZ_NAME), gz)
sha = root / _SHA_NAME
_download(artifact_url(version, _SHA_NAME), sha)
expected = sha.read_text().split()[0].strip().lower()
actual = _sha256_file(gz)
if actual != expected:
gz.unlink(missing_ok=True)
sha.unlink(missing_ok=True)
die(
f"infra artifact checksum mismatch for {version}:\n"
f" expected {expected}\n"
f" actual {actual}\n"
f" refusing to boot an unverified rootfs."
)
ok.write_text("ok\n")
return gz
def materialize_ext4(version: str, dest: Path) -> None:
"""Ensure the verified 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)
tmp = dest.with_suffix(dest.suffix + ".part")
info(f"expanding infra rootfs -> {dest}")
with gzip.open(gz, "rb") as src, open(tmp, "wb") as out:
shutil.copyfileobj(src, out, _CHUNK)
tmp.replace(dest)
+28 -7
View File
@@ -35,7 +35,7 @@ from typing import Generator
from ...log import die, info
from ..docker import util as docker_mod
from ..docker.gateway_provision import GatewayProvisionError
from . import firecracker_vm, netpool, util
from . import firecracker_vm, infra_artifact, netpool, util
# The single infra-VM image: gateway data plane + baked control-plane source
# (Dockerfile.infra FROM the gateway image). Built from source by default;
@@ -109,10 +109,26 @@ class InfraVm:
def ensure_built() -> None:
"""Build the infra image from source (bootstrap via host docker). The
infra image `COPY --from`s the orchestrator image and is `FROM` the
gateway image, so both must exist first. A pull-from-registry mode
replaces this later."""
"""Ensure the infra rootfs is available before boot.
Default (docker-free, PRD 0069 Stage 2): download + verify the prebuilt
rootfs artifact matching this code version (see `infra_artifact`); the
launch host needs no Docker. `BOT_BOTTLE_INFRA_BUILD=local` instead builds
the three fixed images from source with host Docker — the infra image
`COPY --from`s the orchestrator image and is `FROM` the gateway image, so
both must exist first — for iterating on the Dockerfiles."""
if infra_artifact.local_build_requested():
build_infra_images_with_docker()
return
infra_artifact.ensure_artifact_gz(
infra_artifact.infra_artifact_version(_infra_init()))
def build_infra_images_with_docker() -> None:
"""Build the three fixed images from source with host Docker: orchestrator,
gateway, then the combined infra image (`COPY --from` orchestrator, `FROM`
gateway). The launch host uses this only in `BOT_BOTTLE_INFRA_BUILD=local`
mode; `publish_infra` uses it off-host to produce the published artifact."""
docker_mod.build_image(
_ORCHESTRATOR_IMAGE, str(_REPO_ROOT), dockerfile="Dockerfile.orchestrator")
docker_mod.build_image(
@@ -190,10 +206,15 @@ def boot() -> InfraVm:
die(f"orchestrator link {slot.iface} not present.\n"
f" ./cli.py backend setup --backend=firecracker")
base = build_infra_rootfs_dir()
run_dir = _infra_dir()
rootfs = run_dir / "rootfs.ext4"
util.build_rootfs_ext4(base, rootfs, slack_mib=8192)
if infra_artifact.local_build_requested():
util.build_rootfs_ext4(build_infra_rootfs_dir(), rootfs, slack_mib=8192)
else:
# Prebuilt artifact already carries the buildah build slack; expand it
# to a fresh writable rootfs for this boot.
infra_artifact.materialize_ext4(
infra_artifact.infra_artifact_version(_infra_init()), rootfs)
private_key, pubkey = _stable_keypair()
info(f"booting infra VM on {slot.iface} (guest {slot.guest_ip})")
@@ -0,0 +1,169 @@
"""Build the infra rootfs and publish it as a Gitea generic package.
The off-host (build / CI) half of PRD 0069 Stage 2: this DOES use Docker, but
never on the launch host. It runs the same pipeline the launch host used to run
locally — `docker build` the three fixed images, export to a rootfs dir, inject
the guest boot, `mke2fs` to an ext4 with the buildah build slack — then gzips
the ext4 and PUTs it (plus a `.sha256`) to
`…/api/packages/<owner>/generic/bot-bottle-firecracker-infra/<version>/`.
The `<version>` is `infra_artifact.infra_artifact_version(...)`, the content
hash of the rootfs inputs, so a launch host at the same code checkout resolves
the exact artifact this produced.
python3 -m bot_bottle.backend.firecracker.publish_infra [--dry-run] [--force]
Auth: a token with `write:package` on the target owner, from
`BOT_BOTTLE_INFRA_ARTIFACT_TOKEN`.
"""
from __future__ import annotations
import argparse
import gzip
import hashlib
import shutil
import sys
import tempfile
import urllib.error
import urllib.request
from pathlib import Path
from . import infra_artifact, infra_vm, util
_CHUNK = 1 << 20
# A human-readable description shipped alongside the artifact — generic packages
# have no description field, so this file *is* the description on the package
# page. Uploaded on every publish so it never goes stale.
_ABOUT_NAME = "about.txt"
_ABOUT_TEXT = (
"bot-bottle infra rootfs for the Firecracker backend (PRD 0069 Stage 2, "
"#348): the per-host infra VM (orchestrator control plane + gateway + "
"buildah). Prebuilt off-host, gzip ext4; the launch host downloads + "
"sha256-verifies + boots it, no host Docker. The version tag is a content "
"hash of the rootfs inputs. Files: rootfs.ext4.gz + rootfs.ext4.gz.sha256.\n"
)
def _gzip(src: Path, dest: Path) -> None:
with open(src, "rb") as fh, gzip.open(dest, "wb") as out:
shutil.copyfileobj(fh, out, _CHUNK)
def _sha256(path: Path) -> str:
h = hashlib.sha256()
with open(path, "rb") as fh:
for chunk in iter(lambda: fh.read(_CHUNK), b""):
h.update(chunk)
return h.hexdigest()
def _put(url: str, body: "bytes | Path", token: str) -> None:
"""PUT `body` (raw bytes, or a Path streamed from disk) to `url`. The rootfs
is hundreds of MB, so it is passed as a Path and streamed — `urlopen` reads
the open file in blocks rather than materializing it in memory (with an
explicit Content-Length, which Gitea requires and which also stops urllib
from `len()`-ing a non-bytes body)."""
handle = None
if isinstance(body, Path):
length = body.stat().st_size
handle = open(body, "rb")
data: object = handle
else:
length = len(body)
data = body
req = urllib.request.Request(url, data=data, method="PUT") # type: ignore[arg-type]
req.add_header("Content-Length", str(length))
if token:
req.add_header("Authorization", f"token {token}")
req.add_header("Content-Type", "application/octet-stream")
try:
with urllib.request.urlopen(req) as resp:
print(f" uploaded {url} (HTTP {resp.status})")
except urllib.error.HTTPError as e:
if e.code == 409:
raise SystemExit(
f"artifact already published at {url} (HTTP 409); "
f"bump the code version or pass --force to overwrite"
)
raise SystemExit(f"upload failed (HTTP {e.code}): {url}\n{e.read().decode(errors='replace')}")
except urllib.error.URLError as e:
raise SystemExit(f"registry unreachable: {url} ({e.reason})")
finally:
if handle is not None:
handle.close()
def _delete(url: str, token: str) -> None:
req = urllib.request.Request(url, method="DELETE")
if token:
req.add_header("Authorization", f"token {token}")
try:
with urllib.request.urlopen(req):
pass
except urllib.error.HTTPError as e:
if e.code != 404:
raise SystemExit(f"could not overwrite existing artifact (HTTP {e.code}): {url}")
except urllib.error.URLError as e:
raise SystemExit(f"registry unreachable: {url} ({e.reason})")
def build_artifact(out_dir: Path) -> tuple[str, Path, Path]:
"""Build the infra rootfs ext4, gzip it, and write the checksum. Returns
`(version, gz_path, sha_path)`. Uses host Docker (off-host / CI)."""
version = infra_artifact.infra_artifact_version(infra_vm._infra_init())
print(f"building infra rootfs artifact {version} (docker)")
infra_vm.build_infra_images_with_docker()
base = infra_vm.build_infra_rootfs_dir()
ext4 = out_dir / "rootfs.ext4"
util.build_rootfs_ext4(base, ext4, slack_mib=8192)
gz = out_dir / "rootfs.ext4.gz"
print("compressing rootfs")
_gzip(ext4, gz)
ext4.unlink(missing_ok=True)
sha = out_dir / "rootfs.ext4.gz.sha256"
digest = _sha256(gz)
sha.write_text(f"{digest} rootfs.ext4.gz\n")
print(f" {gz.name}: {gz.stat().st_size / 1e6:.0f} MB sha256={digest}")
Outdated
Review

[P1] Stream the rootfs upload instead of reading it into memory. gz.read_bytes() materializes the entire compressed ext4 in Python before urlopen sends it. This image is built with 8 GiB of writable slack and is explicitly expected to be hundreds of MB, so publishing can require another artifact-sized allocation and OOM a modest CI/build host. _put should accept a path/file and stream it (with an explicit content length if Gitea requires one); the checksum file can remain a small in-memory upload.

**[P1] Stream the rootfs upload instead of reading it into memory.** `gz.read_bytes()` materializes the entire compressed ext4 in Python before `urlopen` sends it. This image is built with 8 GiB of writable slack and is explicitly expected to be hundreds of MB, so publishing can require another artifact-sized allocation and OOM a modest CI/build host. `_put` should accept a path/file and stream it (with an explicit content length if Gitea requires one); the checksum file can remain a small in-memory upload.
return version, gz, sha
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
prog="publish_infra", description="Build + publish the infra rootfs artifact.")
parser.add_argument("--dry-run", action="store_true",
help="build the artifact but do not upload")
parser.add_argument("--force", action="store_true",
help="overwrite an already-published artifact of this version")
args = parser.parse_args(argv)
_, _, token = infra_artifact._config()
if not args.dry_run and not token:
raise SystemExit(
"no publish token: set BOT_BOTTLE_INFRA_ARTIFACT_TOKEN to a token "
"with write:package")
with tempfile.TemporaryDirectory(prefix="bb-publish-infra.") as tmp:
version, gz, sha = build_artifact(Path(tmp))
gz_url = infra_artifact.artifact_url(version, gz.name)
sha_url = infra_artifact.artifact_url(version, sha.name)
about_url = infra_artifact.artifact_url(version, _ABOUT_NAME)
if args.dry_run:
print(f"dry-run: would upload -> {gz_url}")
return 0
if args.force:
_delete(gz_url, token)
_delete(sha_url, token)
_delete(about_url, token)
_put(gz_url, gz, token) # streamed from disk (hundreds of MB)
_put(sha_url, sha.read_bytes(), token) # tiny, in-memory is fine
_put(about_url, _ABOUT_TEXT.encode(), token) # package description
print(f"published infra rootfs {version}")
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -8,16 +8,20 @@
> **Superseded in part by [PRD 0070](0070-per-host-orchestrator.md) (#351):**
> the sidecar-consolidation framing here (Stage 1, per-host sidecar; Stage 4,
> sidecar-as-VM) is taken over by 0070's per-host orchestrator. This PRD still
> owns the docker-free **image-building** work — Stage 2 (nix-built fixed
> images, a dependency of 0070) and Stage 3 (in-VM Dockerfile builder).
> owns the docker-free **image-provisioning** work — Stage 2 (pull the fixed
> images from an OCI registry instead of building them with host Docker, a
> dependency of 0070) and Stage 3 (in-VM Dockerfile builder).
## Summary
Make the Firecracker backend depend on **firecracker + KVM only**, removing
Docker from the host. Two moves get us there: run the **sidecar bundle as a
persistent, per-host service** (eventually a Firecracker VM) instead of a
per-bottle container, and **build agent rootfs images without a host Docker
daemon** (nix for the fixed images; an in-VM builder for user Dockerfiles).
per-bottle container, and **provision rootfs images without a host Docker
daemon** — pull the fixed images (orchestrator/gateway/infra) from an OCI
registry and unpack them daemonlessly, and build user Dockerfiles in an in-VM
builder. The images are still *built* with Docker, but off the launch host
(CI / a publish step) and pushed to the registry; the launch host only pulls.
## Motivation
@@ -93,13 +97,51 @@ torn down at exit."
Can ship as a container first (quick resource/ops win) and become a VM in
Stage 4.
### Stage 2 — Fixed images built with nix (no Docker)
### Stage 2 — Fixed rootfs prebuilt + pulled as an artifact (no host Docker)
The images bot-bottle *ships* — the sidecar, the agent base, and the builder
(Stage 3) — are built declaratively with nix (`nixos-generators` /
`make-ext4-fs` / `pkgs.dockerTools` for the rootfs), producing an ext4 or
tar with correct ownership. Removes Docker for everything we own and gives
the rootless-rootfs correctness (#347) for free on these images.
The one fixed image the Firecracker backend needs at launch — the combined
**infra** rootfs the infra VM boots (orchestrator control plane + gateway +
buildah, with the control-plane init as PID 1) — is **prebuilt end-to-end off
the launch host and published as a versioned, ready-to-boot ext4 artifact**.
The launch host **downloads the `.ext4` and boots it directly** — no
`docker build`, no `docker export`, no `mke2fs`, no image tooling at all.
This is possible because the infra rootfs is already **host- and
bottle-agnostic**: the per-boot bits (authorized_keys, guest IP) arrive on the
**kernel cmdline**, not in the rootfs (see `build_base_rootfs_dir`). So one
published ext4 boots on any launch host.
- **Artifact.** `rootfs.ext4` + a `rootfs.ext4.sha256`, published as a Gitea
**generic package** (`bot-bottle-firecracker-infra/<tag>`) — generic packages take
arbitrary large binaries (no attachment size cap / file-type allowlist that
release attachments impose). The matching `vmlinux` kernel can ship the same
way, so the whole VM is fetchable.
- **Pull.** The launch host `GET`s
`…/api/packages/<owner>/generic/bot-bottle-firecracker-infra/<tag>/rootfs.ext4` (+
`.sha256`) for its pinned tag, verifies the checksum, caches it under the
tag, and attaches it as the infra VM's root disk. Host prerequisite is an
HTTP client — nothing else. Public packages need no auth to pull; a token
with `read:package` covers a private instance.
- **Registry.** The artifact base URL + owner are configurable, defaulting to
this deployment's Gitea (`https://gitea.dideric.is` / `didericis`);
overridable via env for other deployments / air-gapped mirrors.
- **Versioning.** A pinned tag bumped when the infra rootfs contents change
(bot_bottle's shipped files, the base deps, or the init), so a launch host
pulls the artifact matching its code and a content change can't silently
boot a stale rootfs. A checksum mismatch fails closed.
- **Publish.** A `publish` step (CLI subcommand / CI job) runs the full
pipeline **on a build/CI host**`docker build` the three Dockerfiles →
export → inject guest boot → `mke2fs` → upload the `.ext4` + `.sha256`.
Building still uses Docker, but never on the launch/runner host, which is
the one #348 needs unprivileged.
- **Dev escape hatch.** An explicit opt-in still builds the rootfs locally
with Docker (for iterating on the Dockerfiles without a publish
round-trip); it is never the default path.
Removes Docker from the launch host entirely for the fixed image, and the
launch host needs no OCI/rootfs tooling — just fetch + boot. The build-time
cache / build-time-egress open problems a from-scratch build would face don't
arise: the launch host never builds, it downloads a finished disk.
### Stage 3 — User Dockerfiles built in a builder VM (the unlock)
+12 -2
View File
@@ -7,6 +7,7 @@ decisions that must hold without a VM.
from __future__ import annotations
import os
import unittest
from pathlib import Path
from unittest.mock import MagicMock, patch
@@ -95,8 +96,17 @@ class TestRegistryVolume(unittest.TestCase):
class TestEnsureBuilt(unittest.TestCase):
def test_builds_deps_before_infra(self):
with patch.object(infra_vm.docker_mod, "build_image") as build:
def test_default_pulls_artifact_without_docker(self):
# PRD 0069 Stage 2: the launch host pulls the prebuilt rootfs; no Docker.
with patch.object(infra_vm.docker_mod, "build_image") as build, \
patch.object(infra_vm.infra_artifact, "ensure_artifact_gz") as pull:
infra_vm.ensure_built()
build.assert_not_called()
pull.assert_called_once()
def test_local_mode_builds_deps_before_infra(self):
with patch.dict(os.environ, {"BOT_BOTTLE_INFRA_BUILD": "local"}), \
patch.object(infra_vm.docker_mod, "build_image") as build:
infra_vm.ensure_built()
tags = [c.args[0] for c in build.call_args_list]
# infra is FROM gateway and COPY --from orchestrator, so both first.
+182
View File
@@ -0,0 +1,182 @@
"""Unit: the prebuilt infra-rootfs artifact pull (PRD 0069 Stage 2).
The launch-host half — version hashing and download/verify/decompress — is
what keeps a docker-free host from booting a stale or corrupted rootfs, so the
checksum + fail-closed paths are locked here. Network is mocked; no Docker.
"""
from __future__ import annotations
import gzip
import hashlib
import io
import os
import tempfile
import unittest
import urllib.error
import urllib.request
from pathlib import Path
from unittest import mock
from bot_bottle.backend.firecracker import infra_artifact as ia
from bot_bottle.log import Die
def _gz(data: bytes) -> bytes:
return gzip.compress(data)
class _FakeNet:
"""Map artifact URLs to bytes (or an HTTPError) for urlopen."""
def __init__(self, responses: "dict[str, bytes | Exception]") -> None:
self._responses = responses
self.calls: list[str] = []
def urlopen(self, req: urllib.request.Request, *a: object, **k: object) -> io.BytesIO:
url = req.full_url
self.calls.append(url)
val = self._responses.get(url)
if isinstance(val, Exception):
raise val
if val is None:
raise urllib.error.HTTPError(url, 404, "not found", {}, None) # type: ignore[arg-type]
return io.BytesIO(val)
class _CacheMixin(unittest.TestCase):
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory()
self._env = mock.patch.dict(
os.environ,
{"BOT_BOTTLE_FC_CACHE": self._tmp.name,
"BOT_BOTTLE_INFRA_ARTIFACT_TOKEN": ""},
clear=False,
)
self._env.start()
self.addCleanup(self._env.stop)
self.addCleanup(self._tmp.cleanup)
def _serve(self, version: str, gz_bytes: bytes, sha_text: str | None = None):
if sha_text is None:
sha_text = f"{hashlib.sha256(gz_bytes).hexdigest()} rootfs.ext4.gz\n"
net = _FakeNet({
ia.artifact_url(version, "rootfs.ext4.gz"): gz_bytes,
ia.artifact_url(version, "rootfs.ext4.gz.sha256"): sha_text.encode(),
})
return mock.patch.object(ia.urllib.request, "urlopen", net.urlopen), net
class TestVersion(unittest.TestCase):
def test_deterministic_16_hex(self) -> None:
v = ia.infra_artifact_version("#!/bin/sh\ntrue\n")
self.assertEqual(v, ia.infra_artifact_version("#!/bin/sh\ntrue\n"))
self.assertEqual(16, len(v))
int(v, 16) # hex
def test_init_change_bumps_version(self) -> None:
self.assertNotEqual(
ia.infra_artifact_version("a"), ia.infra_artifact_version("b"))
class TestVersionInputs(unittest.TestCase):
"""The hash must cover *every* file baked into the rootfs, not just `*.py`
(`COPY bot_bottle` is wholesale) — else a non-Python change (e.g. the egress
entrypoint shell script) leaves the version unchanged and a launch host
boots a rootfs whose code differs from its checkout."""
def _fake_repo(self, root: Path) -> None:
pkg = root / "bot_bottle"
pkg.mkdir()
(pkg / "app.py").write_text("print('hi')\n")
(pkg / "egress_entrypoint.sh").write_text("#!/bin/sh\nexec mitmdump\n")
(pkg / "netpool.defaults.env").write_text("FOO=1\n")
for name in ("Dockerfile.orchestrator", "Dockerfile.gateway", "Dockerfile.infra"):
(root / name).write_text(f"FROM scratch # {name}\n")
def test_non_python_file_change_bumps_version(self) -> None:
with tempfile.TemporaryDirectory() as d:
root = Path(d)
self._fake_repo(root)
before = ia.infra_artifact_version("init", repo_root=root)
(root / "bot_bottle" / "egress_entrypoint.sh").write_text(
"#!/bin/sh\nexec mitmdump --different\n")
after = ia.infra_artifact_version("init", repo_root=root)
self.assertNotEqual(before, after)
def test_pyc_and_pycache_ignored(self) -> None:
with tempfile.TemporaryDirectory() as d:
root = Path(d)
self._fake_repo(root)
before = ia.infra_artifact_version("init", repo_root=root)
cache = root / "bot_bottle" / "__pycache__"
cache.mkdir()
(cache / "app.cpython-312.pyc").write_bytes(b"\x00bytecode")
(root / "bot_bottle" / "app.pyc").write_bytes(b"\x00bytecode")
after = ia.infra_artifact_version("init", repo_root=root)
self.assertEqual(before, after)
class TestEnsureArtifact(_CacheMixin):
def test_downloads_verifies_and_caches(self) -> None:
version = "deadbeef00000000"
gz = _gz(b"fake ext4 bytes")
patcher, net = self._serve(version, gz)
with patcher:
path = ia.ensure_artifact_gz(version)
self.assertTrue(path.is_file())
self.assertEqual(gz, path.read_bytes())
first_calls = len(net.calls)
# Second call is a cache hit — no further network.
ia.ensure_artifact_gz(version)
self.assertEqual(first_calls, len(net.calls))
def test_checksum_mismatch_fails_closed(self) -> None:
version = "beefbeefbeefbeef"
gz = _gz(b"payload")
patcher, _ = self._serve(version, gz, sha_text="0" * 64 + " rootfs.ext4.gz\n")
with patcher:
with self.assertRaises(Die) as ctx:
ia.ensure_artifact_gz(version)
self.assertIn("checksum mismatch", str(ctx.exception.message))
# nothing left cached to accidentally boot
self.assertFalse((ia._cache_root(version) / "rootfs.ext4.gz").exists())
def test_missing_artifact_points_at_publish(self) -> None:
version = "0000000000000000"
net = _FakeNet({}) # everything 404s
with mock.patch.object(ia.urllib.request, "urlopen", net.urlopen):
with self.assertRaises(Die) as ctx:
ia.ensure_artifact_gz(version)
self.assertIn("publish_infra", str(ctx.exception.message))
def test_materialize_gunzips_to_dest(self) -> None:
version = "1234123412341234"
raw = b"the real rootfs contents" * 100
patcher, _ = self._serve(version, _gz(raw))
with patcher, tempfile.TemporaryDirectory() as d:
dest = Path(d) / "rootfs.ext4"
ia.materialize_ext4(version, dest)
self.assertEqual(raw, dest.read_bytes())
class TestConfig(unittest.TestCase):
def test_base_and_owner_overridable(self) -> None:
with mock.patch.dict(os.environ, {
"BOT_BOTTLE_INFRA_ARTIFACT_BASE": "https://mirror.example/",
"BOT_BOTTLE_INFRA_ARTIFACT_OWNER": "acme",
}):
url = ia.artifact_url("v1", "rootfs.ext4.gz")
self.assertEqual(
"https://mirror.example/api/packages/acme/generic/"
"bot-bottle-firecracker-infra/v1/rootfs.ext4.gz", url)
def test_local_build_flag(self) -> None:
with mock.patch.dict(os.environ, {"BOT_BOTTLE_INFRA_BUILD": "local"}):
self.assertTrue(ia.local_build_requested())
with mock.patch.dict(os.environ, {"BOT_BOTTLE_INFRA_BUILD": ""}):
self.assertFalse(ia.local_build_requested())
if __name__ == "__main__":
unittest.main()
+62
View File
@@ -0,0 +1,62 @@
"""Unit: the infra-artifact publisher's upload path (PRD 0069 Stage 2).
The rootfs is hundreds of MB, so `_put` must stream it from disk rather than
read it into memory. Network is mocked; no Docker, no real build.
"""
from __future__ import annotations
import tempfile
import unittest
import urllib.request
from pathlib import Path
from unittest import mock
from bot_bottle.backend.firecracker import publish_infra as pub
class _Resp:
status = 201
def __enter__(self) -> "_Resp":
return self
def __exit__(self, *a: object) -> bool:
return False
class TestPut(unittest.TestCase):
def test_streams_file_body_with_content_length(self) -> None:
captured: list[urllib.request.Request] = []
def fake_urlopen(req: urllib.request.Request, *a: object, **k: object) -> _Resp:
captured.append(req)
return _Resp()
with tempfile.TemporaryDirectory() as d:
f = Path(d) / "rootfs.ext4.gz"
payload = b"x" * 4096
f.write_bytes(payload)
with mock.patch.object(pub.urllib.request, "urlopen", fake_urlopen):
pub._put("https://reg/pkg", f, token="t")
req = captured[0]
# Body is the open file object (streamed), never the bytes in memory.
self.assertTrue(hasattr(req.data, "read"))
self.assertNotIsInstance(req.data, (bytes, bytearray))
self.assertEqual(str(len(payload)), req.get_header("Content-length"))
def test_small_bytes_body_still_works(self) -> None:
captured: list[urllib.request.Request] = []
def fake_urlopen(req: urllib.request.Request, *a: object, **k: object) -> _Resp:
captured.append(req)
return _Resp()
with mock.patch.object(pub.urllib.request, "urlopen", fake_urlopen):
pub._put("https://reg/sha", b"abc123 rootfs\n", token="")
self.assertEqual(b"abc123 rootfs\n", captured[0].data)
if __name__ == "__main__":
unittest.main()