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:
2026-07-16 23:21:17 -04:00
parent bbb8913382
commit 18f190b7e3
6 changed files with 562 additions and 19 deletions
@@ -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)
+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,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)
+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.
+144
View File
@@ -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()