fix(firecracker): hash all baked-in files + stream the artifact upload
lint / lint (push) Successful in 2m26s
test / unit (pull_request) Successful in 1m44s
test / integration (pull_request) Successful in 40s
test / coverage (pull_request) Successful in 1m32s

Address PR #395 review (two P1s):

- Version hash covered only `bot_bottle/**.py`, but the image `COPY`s the
  whole package — non-Python inputs baked in (egress_entrypoint.sh,
  netpool.defaults.env) didn't change the version, so a launch host could
  boot a stale rootfs whose code differs from its checkout. Hash every
  regular file under bot_bottle/ (excluding __pycache__/.pyc). Regression
  tests: a shell-script change bumps the version; .pyc/__pycache__ don't.

- publish_infra `_put` read the whole (hundreds-of-MB) gz into memory via
  read_bytes(). Stream it from disk with an explicit Content-Length; the
  tiny .sha256 stays in-memory. Test asserts the body is the file object,
  not bytes.

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:46:23 -04:00
parent 8d8a88aeeb
commit f2891a1634
4 changed files with 142 additions and 15 deletions
@@ -57,24 +57,34 @@ def local_build_requested() -> bool:
return os.environ.get("BOT_BOTTLE_INFRA_BUILD", "").strip().lower() == "local"
def infra_artifact_version(init_script: str) -> str:
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 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."""
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
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("*.py")):
if "__pycache__" in path.parts:
pkg = repo_root / "bot_bottle"
for path in sorted(pkg.rglob("*")):
if not path.is_file():
continue
h.update(str(path.relative_to(_REPO_ROOT)).encode())
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:
p = _REPO_ROOT / name
h.update(name.encode())
h.update(p.read_bytes())
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]
@@ -47,8 +47,22 @@ def _sha256(path: Path) -> str:
return h.hexdigest()
def _put(url: str, body: bytes, token: str) -> None:
req = urllib.request.Request(url, data=body, method="PUT")
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")
@@ -64,6 +78,9 @@ def _put(url: str, body: bytes, token: str) -> None:
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:
@@ -127,8 +144,8 @@ def main(argv: list[str] | None = None) -> int:
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)
_put(gz_url, gz, token) # streamed from disk (hundreds of MB)
_put(sha_url, sha.read_bytes(), token) # tiny, in-memory is fine
print(f"published infra rootfs {version}")
return 0