948504bde2
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
138 lines
5.0 KiB
Python
138 lines
5.0 KiB
Python
"""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())
|