diff --git a/bot_bottle/backend/firecracker/infra_artifact.py b/bot_bottle/backend/firecracker/infra_artifact.py new file mode 100644 index 0000000..5c0d046 --- /dev/null +++ b/bot_bottle/backend/firecracker/infra_artifact.py @@ -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) diff --git a/bot_bottle/backend/firecracker/infra_vm.py b/bot_bottle/backend/firecracker/infra_vm.py index e236eef..8c7e52c 100644 --- a/bot_bottle/backend/firecracker/infra_vm.py +++ b/bot_bottle/backend/firecracker/infra_vm.py @@ -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})") diff --git a/bot_bottle/backend/firecracker/publish_infra.py b/bot_bottle/backend/firecracker/publish_infra.py new file mode 100644 index 0000000..3acdd65 --- /dev/null +++ b/bot_bottle/backend/firecracker/publish_infra.py @@ -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//generic/bot-bottle-infra//`. + +The `` 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()) diff --git a/docs/prds/0069-firecracker-native-docker-free.md b/docs/prds/0069-firecracker-native-docker-free.md index c771c6c..aa094e5 100644 --- a/docs/prds/0069-firecracker-native-docker-free.md +++ b/docs/prds/0069-firecracker-native-docker-free.md @@ -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/`) — 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//generic/bot-bottle-infra//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) diff --git a/tests/unit/test_firecracker_infra_vm.py b/tests/unit/test_firecracker_infra_vm.py index 8b3e876..497b7c8 100644 --- a/tests/unit/test_firecracker_infra_vm.py +++ b/tests/unit/test_firecracker_infra_vm.py @@ -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. diff --git a/tests/unit/test_infra_artifact.py b/tests/unit/test_infra_artifact.py new file mode 100644 index 0000000..22dd50c --- /dev/null +++ b/tests/unit/test_infra_artifact.py @@ -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()