feat(firecracker): pull the infra rootfs as a prebuilt artifact (PRD 0069 Stage 2)
Stage 2 of the docker-free Firecracker backend (#348): stop building the fixed infra image on the launch host. The infra VM's rootfs is host- and bottle-agnostic (authorized_keys + guest IP ride the kernel cmdline, not the rootfs), so it's built once off-host and published as a versioned, ready-to- boot ext4; the launch host downloads + verifies + boots it — no Docker, no image tooling, just HTTP + gunzip. - infra_artifact.py: version = content hash of the rootfs inputs (the shipped bot_bottle package + the three Dockerfiles + the init), so a launch host pulls the artifact matching its code and a content change can't silently boot a stale rootfs. Pull + sha256-verify (fail-closed) + gunzip from a Gitea generic package; base/owner/token configurable, default this Gitea. - infra_vm.ensure_built/boot default to the pull path; BOT_BOTTLE_INFRA_BUILD= local keeps the docker build-from-source path for iterating on Dockerfiles. - publish_infra.py: the off-host half — builds the images with Docker, mke2fs the rootfs (with buildah slack), gzips, and PUTs it to the generic package. Rollout note: default=pull means a launch 404s until an artifact is published; until the Gitea packages endpoint is enabled + an artifact published, use BOT_BOTTLE_INFRA_BUILD=local. Freeze/migrate's remaining docker use is a separate PR. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UoEZHDjv84ChoZbozQERhJ
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
"""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-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"
|
||||
|
||||
|
||||
def infra_artifact_version(init_script: str) -> str:
|
||||
"""Content hash (16 hex) of everything baked into the infra rootfs: the
|
||||
whole shipped `bot_bottle` package (the infra image `COPY`s it wholesale),
|
||||
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."""
|
||||
h = hashlib.sha256()
|
||||
h.update(f"format={_ARTIFACT_FORMAT}\n".encode())
|
||||
pkg = _REPO_ROOT / "bot_bottle"
|
||||
for path in sorted(pkg.rglob("*.py")):
|
||||
if "__pycache__" in path.parts:
|
||||
continue
|
||||
h.update(str(path.relative_to(_REPO_ROOT)).encode())
|
||||
h.update(path.read_bytes())
|
||||
for name in _DOCKERFILES:
|
||||
p = _REPO_ROOT / name
|
||||
h.update(name.encode())
|
||||
h.update(p.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 (optional — a
|
||||
public package needs none) reuses the shared Gitea token."""
|
||||
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") or os.environ.get(
|
||||
"BOT_BOTTLE_CLAUDE_GITEA_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)
|
||||
@@ -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,137 @@
|
||||
"""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-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` or `BOT_BOTTLE_CLAUDE_GITEA_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
|
||||
|
||||
|
||||
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, token: str) -> None:
|
||||
req = urllib.request.Request(url, data=body, method="PUT")
|
||||
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})")
|
||||
|
||||
|
||||
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}")
|
||||
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 (or "
|
||||
"BOT_BOTTLE_CLAUDE_GITEA_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)
|
||||
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)
|
||||
_put(gz_url, gz.read_bytes(), token)
|
||||
_put(sha_url, sha.read_bytes(), token)
|
||||
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-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-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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
"""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 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-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()
|
||||
Reference in New Issue
Block a user