refactor(firecracker): split the combined rootfs into per-plane artifacts
Each infra VM now boots its own rootfs instead of a shared combined image, so
the exposed gateway VM no longer carries buildah + the control-plane code it
never runs (a fatter, less-isolated exposed surface — the opposite of the plane
split's intent). The combined image bought only "one artifact"; each VM already
kept a full copy of the shared rootfs, so nothing was saved at boot.
* orchestrator rootfs — control plane + buildah (Dockerfile.orchestrator.fc,
FROM orchestrator); the slim gateway rootfs boots bot-bottle-gateway:latest
directly. Dockerfile.infra.fc (the combined image) is deleted.
* `_infra_init` splits into `_orchestrator_init` / `_gateway_init` (shared
preamble via `_init_head`); each per-plane rootfs bakes only its role init,
so the `bb_role` cmdline branch is gone.
* infra_artifact is role-parametrized: a per-role package
(bot-bottle-firecracker-<role>), version hash, URL, cache dir, and candidate
subdir. `ensure_built` pulls both; `_expected_version` combines both markers.
* publish_infra builds the images once, then builds + publishes an
orchestrator and a gateway artifact under DIR/<role>/.
coverage.sh's candidate-dir plumbing is layout-agnostic (publish_infra --output
now fills DIR/<role>/, which ensure_artifact_gz reads). Full suite green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,20 +1,22 @@
|
||||
"""Build the infra rootfs and publish it as a Gitea generic package.
|
||||
"""Build the infra rootfs artifacts and publish them as Gitea generic packages.
|
||||
|
||||
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-firecracker-infra/<version>/`.
|
||||
locally — `docker build` the fixed images, export each per-plane rootfs, inject
|
||||
the guest boot, `mke2fs` to an ext4 — then gzips each and PUTs it (plus a
|
||||
`.sha256`) to `…/api/packages/<owner>/generic/bot-bottle-firecracker-<role>/<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.
|
||||
There are two artifacts, one per plane (`orchestrator`, `gateway`); the
|
||||
orchestrator rootfs carries buildah, the gateway rootfs is slim. Each
|
||||
`<version>` is `infra_artifact.infra_artifact_version(...)`, the content hash of
|
||||
that rootfs's inputs, so a launch host at the same code checkout resolves the
|
||||
exact artifacts this produced.
|
||||
|
||||
python3 -m bot_bottle.backend.firecracker.publish_infra --output DIR
|
||||
python3 -m bot_bottle.backend.firecracker.publish_infra --publish-dir DIR
|
||||
|
||||
Auth: a token with `write:package` on the target owner, from
|
||||
A candidate bundle holds each role under its own `DIR/<role>/` subdir. Auth: a
|
||||
token with `write:package` on the target owner, from
|
||||
`BOT_BOTTLE_INFRA_ARTIFACT_TOKEN`.
|
||||
"""
|
||||
|
||||
@@ -33,17 +35,27 @@ from . import infra_artifact, infra_vm, util
|
||||
|
||||
_CHUNK = 1 << 20
|
||||
|
||||
# A human-readable description shipped alongside the artifact — generic packages
|
||||
_GZ_NAME = "rootfs.ext4.gz"
|
||||
_SHA_NAME = "rootfs.ext4.gz.sha256"
|
||||
|
||||
# A human-readable description shipped alongside each artifact — generic packages
|
||||
# have no description field, so this file *is* the description on the package
|
||||
# page. Uploaded on every publish so it never goes stale.
|
||||
_ABOUT_NAME = "about.txt"
|
||||
_ABOUT_TEXT = (
|
||||
"bot-bottle infra rootfs for the Firecracker backend (PRD 0069 Stage 2, "
|
||||
"#348): the per-host infra VM (orchestrator control plane + gateway + "
|
||||
"buildah). Prebuilt off-host, gzip ext4; the launch host downloads + "
|
||||
"sha256-verifies + boots it, no host Docker. The version tag is a content "
|
||||
"hash of the rootfs inputs. Files: rootfs.ext4.gz + rootfs.ext4.gz.sha256.\n"
|
||||
)
|
||||
|
||||
|
||||
def _about_text(role: str) -> str:
|
||||
return (
|
||||
f"bot-bottle firecracker {role} rootfs (PRD 0069 Stage 2 / PRD 0070): "
|
||||
f"the per-host {role} infra VM. Prebuilt off-host, gzip ext4; the launch "
|
||||
f"host downloads + sha256-verifies + boots it, no host Docker. The "
|
||||
f"version tag is a content hash of the rootfs inputs. Files: "
|
||||
f"{_GZ_NAME} + {_SHA_NAME}.\n"
|
||||
)
|
||||
|
||||
|
||||
def _role_version(role: str) -> str:
|
||||
return infra_artifact.infra_artifact_version(infra_vm.role_init(role), role)
|
||||
|
||||
|
||||
def _gzip(src: Path, dest: Path) -> None:
|
||||
@@ -109,34 +121,35 @@ def _delete(url: str, token: str) -> None:
|
||||
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()
|
||||
def build_role_artifact(role: str, role_dir: Path) -> str:
|
||||
"""Build `role`'s rootfs ext4, gzip it, and write the checksum + version into
|
||||
`role_dir`. Returns the version. Assumes the docker images are already
|
||||
built (`infra_vm.build_infra_images_with_docker`). Uses host Docker."""
|
||||
version = _role_version(role)
|
||||
print(f"building {role} rootfs artifact {version} (docker)")
|
||||
base = infra_vm.build_rootfs_dir(role)
|
||||
|
||||
ext4 = out_dir / "rootfs.ext4"
|
||||
util.build_rootfs_ext4(base, ext4, slack_mib=8192)
|
||||
gz = out_dir / "rootfs.ext4.gz"
|
||||
print("compressing rootfs")
|
||||
ext4 = role_dir / "rootfs.ext4"
|
||||
util.build_rootfs_ext4(base, ext4, slack_mib=infra_vm._ROOTFS_SLACK_MIB[role])
|
||||
gz = role_dir / _GZ_NAME
|
||||
print(f"compressing {role} rootfs")
|
||||
_gzip(ext4, gz)
|
||||
ext4.unlink(missing_ok=True)
|
||||
|
||||
sha = out_dir / "rootfs.ext4.gz.sha256"
|
||||
sha = role_dir / _SHA_NAME
|
||||
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
|
||||
sha.write_text(f"{digest} {_GZ_NAME}\n")
|
||||
(role_dir / "version.txt").write_text(version + "\n", encoding="utf-8")
|
||||
print(f" {role}/{gz.name}: {gz.stat().st_size / 1e6:.0f} MB sha256={digest}")
|
||||
return version
|
||||
|
||||
|
||||
def _try_download_published(out_dir: Path) -> tuple[str, Path, Path] | None:
|
||||
"""If this version's artifact is already in the registry, download the gz
|
||||
and sha to out_dir and return (version, gz_path, sha_path). Returns None
|
||||
when not yet published."""
|
||||
version = infra_artifact.infra_artifact_version(infra_vm._infra_init())
|
||||
sha_url = infra_artifact.artifact_url(version, "rootfs.ext4.gz.sha256")
|
||||
def _try_download_published(role: str, role_dir: Path) -> str | None:
|
||||
"""If `role`'s artifact for this version is already in the registry, download
|
||||
the gz + sha into `role_dir` and return the version. None when not yet
|
||||
published."""
|
||||
version = _role_version(role)
|
||||
sha_url = infra_artifact.artifact_url(version, _SHA_NAME, role=role)
|
||||
try:
|
||||
with urllib.request.urlopen(infra_artifact._open(sha_url)):
|
||||
pass
|
||||
@@ -146,70 +159,70 @@ def _try_download_published(out_dir: Path) -> tuple[str, Path, Path] | None:
|
||||
raise SystemExit(f"registry check failed (HTTP {e.code}): {sha_url}")
|
||||
except urllib.error.URLError as e:
|
||||
raise SystemExit(f"registry unreachable: {sha_url} ({e.reason})")
|
||||
print(f"infra rootfs {version} already published — downloading instead of building")
|
||||
gz = out_dir / "rootfs.ext4.gz"
|
||||
sha = out_dir / "rootfs.ext4.gz.sha256"
|
||||
infra_artifact._download(infra_artifact.artifact_url(version, "rootfs.ext4.gz"), gz)
|
||||
infra_artifact._download(infra_artifact.artifact_url(version, "rootfs.ext4.gz.sha256"), sha)
|
||||
return version, gz, sha
|
||||
print(f"{role} rootfs {version} already published — downloading instead of building")
|
||||
infra_artifact._download(
|
||||
infra_artifact.artifact_url(version, _GZ_NAME, role=role), role_dir / _GZ_NAME)
|
||||
infra_artifact._download(sha_url, role_dir / _SHA_NAME)
|
||||
(role_dir / "version.txt").write_text(version + "\n", encoding="utf-8")
|
||||
return version
|
||||
|
||||
|
||||
def _publish_bundle(root: Path, token: str) -> str:
|
||||
version_file = root / "version.txt"
|
||||
def _publish_bundle(role: str, role_dir: Path, token: str) -> str:
|
||||
version_file = role_dir / "version.txt"
|
||||
# Guard the read so a missing version.txt is a clean error, not a raw
|
||||
# FileNotFoundError.
|
||||
if not version_file.is_file():
|
||||
raise SystemExit(f"incomplete artifact bundle: {root}")
|
||||
raise SystemExit(f"incomplete {role} artifact bundle: {role_dir}")
|
||||
version = version_file.read_text(encoding="utf-8").strip()
|
||||
expected = infra_artifact.infra_artifact_version(infra_vm._infra_init())
|
||||
expected = _role_version(role)
|
||||
if version != expected:
|
||||
raise SystemExit(
|
||||
f"artifact bundle version {version!r} does not match checkout {expected!r}"
|
||||
f"{role} artifact bundle version {version!r} does not match checkout {expected!r}"
|
||||
)
|
||||
gz = root / "rootfs.ext4.gz"
|
||||
sha = root / "rootfs.ext4.gz.sha256"
|
||||
gz = role_dir / _GZ_NAME
|
||||
sha = role_dir / _SHA_NAME
|
||||
if not gz.is_file() or not sha.is_file():
|
||||
raise SystemExit(f"incomplete artifact bundle: {root}")
|
||||
raise SystemExit(f"incomplete {role} artifact bundle: {role_dir}")
|
||||
expected_sha = sha.read_text().split()[0].strip().lower()
|
||||
if _sha256(gz) != expected_sha:
|
||||
raise SystemExit("artifact bundle checksum mismatch")
|
||||
raise SystemExit(f"{role} artifact bundle checksum mismatch")
|
||||
|
||||
gz_url = infra_artifact.artifact_url(version, gz.name)
|
||||
sha_url = infra_artifact.artifact_url(version, sha.name)
|
||||
about_url = infra_artifact.artifact_url(version, _ABOUT_NAME)
|
||||
gz_url = infra_artifact.artifact_url(version, _GZ_NAME, role=role)
|
||||
sha_url = infra_artifact.artifact_url(version, _SHA_NAME, role=role)
|
||||
about_url = infra_artifact.artifact_url(version, _ABOUT_NAME, role=role)
|
||||
|
||||
# Publishing is idempotent. If this exact complete artifact is already
|
||||
# present, a test-only main commit is a no-op. Otherwise clear any partial
|
||||
# upload left by an interrupted prior attempt and upload the complete set.
|
||||
# present, a re-publish is a no-op. Otherwise clear any partial upload left
|
||||
# by an interrupted prior attempt and upload the complete set.
|
||||
try:
|
||||
with urllib.request.urlopen(infra_artifact._open(sha_url)) as resp:
|
||||
remote_sha = resp.read().decode("utf-8").split()[0].strip().lower()
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code != 404:
|
||||
raise SystemExit(f"checking existing artifact failed (HTTP {e.code})")
|
||||
raise SystemExit(f"checking existing {role} artifact failed (HTTP {e.code})")
|
||||
remote_sha = ""
|
||||
except urllib.error.URLError as e:
|
||||
raise SystemExit(f"registry unreachable: {sha_url} ({e.reason})")
|
||||
if remote_sha == expected_sha:
|
||||
print(f"infra rootfs {version} already published")
|
||||
print(f"{role} rootfs {version} already published")
|
||||
return version
|
||||
|
||||
for url in (gz_url, sha_url, about_url):
|
||||
_delete(url, token)
|
||||
_put(gz_url, gz, token)
|
||||
_put(sha_url, sha.read_bytes(), token)
|
||||
_put(about_url, _ABOUT_TEXT.encode(), token)
|
||||
_put(about_url, _about_text(role).encode(), token)
|
||||
return version
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="publish_infra", description="Build + publish the infra rootfs artifact.")
|
||||
prog="publish_infra", description="Build + publish the infra rootfs artifacts.")
|
||||
mode = parser.add_mutually_exclusive_group(required=True)
|
||||
mode.add_argument("--output", type=Path,
|
||||
help="build a candidate bundle in DIR without publishing")
|
||||
help="build candidate bundles in DIR/<role>/ without publishing")
|
||||
mode.add_argument("--publish-dir", type=Path,
|
||||
help="publish an already-built and tested candidate bundle")
|
||||
help="publish already-built + tested candidate bundles under DIR")
|
||||
parser.add_argument("--reuse-published", action="store_true",
|
||||
help="with --output: download from registry if already published instead of building")
|
||||
args = parser.parse_args(argv)
|
||||
@@ -221,23 +234,28 @@ def main(argv: list[str] | None = None) -> int:
|
||||
"with write:package")
|
||||
|
||||
if args.output is not None:
|
||||
args.output.mkdir(parents=True, exist_ok=True)
|
||||
reused = None
|
||||
if args.reuse_published:
|
||||
reused = _try_download_published(args.output)
|
||||
if reused is not None:
|
||||
version, _, _ = reused
|
||||
(args.output / "version.txt").write_text(version + "\n", encoding="utf-8")
|
||||
print(f"reused published infra rootfs candidate {version}")
|
||||
return 0
|
||||
version, _gz, _sha = build_artifact(args.output)
|
||||
(args.output / "version.txt").write_text(version + "\n", encoding="utf-8")
|
||||
print(f"built infra rootfs candidate {version}")
|
||||
# Build (or reuse) all roles. Images are built once, up front, only when
|
||||
# something actually needs building.
|
||||
pending = []
|
||||
for role in infra_artifact.ROLES:
|
||||
role_dir = args.output / role
|
||||
role_dir.mkdir(parents=True, exist_ok=True)
|
||||
if args.reuse_published and _try_download_published(role, role_dir):
|
||||
print(f"reused published {role} rootfs candidate")
|
||||
continue
|
||||
pending.append(role)
|
||||
if pending:
|
||||
print("building infra images (docker)")
|
||||
infra_vm.build_infra_images_with_docker()
|
||||
for role in pending:
|
||||
build_role_artifact(role, args.output / role)
|
||||
print(f"built {role} rootfs candidate")
|
||||
return 0
|
||||
|
||||
assert args.publish_dir is not None
|
||||
version = _publish_bundle(args.publish_dir, token)
|
||||
print(f"published infra rootfs {version}")
|
||||
for role in infra_artifact.ROLES:
|
||||
version = _publish_bundle(role, args.publish_dir / role, token)
|
||||
print(f"published {role} rootfs {version}")
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user