1a4b390e8a
Addresses the review on PR #481. Self-contained wheel (review point 1): the gateway/infra/orchestrator images build from a context that must hold bot_bottle/, pyproject.toml, and the root-level Dockerfiles. Modules previously located these by walking __file__ to the repo root, so an installed wheel (package in site-packages, no repo root) passed `doctor` but failed `start`. - Add bot_bottle/resources.py: build_root() returns the repo root in a checkout (unchanged) or a staged copy from the wheel's bundled _resources/ otherwise; dockerfile()/nix_netpool_module()/ netpool_script() derive from it. - setup.py bundles the root Dockerfiles, nix module, netpool script, and pyproject.toml into bot_bottle/_resources/ at build; MANIFEST.in ships them in the sdist. - Route every _REPO_ROOT/_REPO_DIR call site (docker/macos launch, macos infra, firecracker infra_vm/infra_artifact/setup, orchestrator lifecycle/gateway) through resources. Checkout behavior is unchanged. install.sh prerequisites (review point 2): check for git when installing a git+ spec, and — before the pip fallback — that pip is usable and the interpreter isn't externally managed (PEP 668), pointing at pipx. Tests: test_resources covers checkout + staged-wheel layouts; test_wheel_install builds the wheel, installs it into an isolated venv, and asserts `doctor` runs and build_root() yields a valid context. Running `start` end-to-end still needs a Docker/KVM host (CI). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
160 lines
6.7 KiB
Python
160 lines
6.7 KiB
Python
"""Locate build-time resources whether bot-bottle runs from a source
|
|
checkout or an installed wheel.
|
|
|
|
The gateway / infra / orchestrator images are built from a Docker (or Apple
|
|
`container`) build context that must contain the `bot_bottle` package,
|
|
`pyproject.toml`, and the root-level Dockerfiles as siblings. In a source
|
|
checkout that context is simply the repo root, one level above the package.
|
|
An installed wheel has no repo root: the same root-level files are shipped
|
|
inside the package under ``bot_bottle/_resources/`` (see ``setup.py``), and a
|
|
repo-root-shaped build context is staged on demand into the app-data dir.
|
|
|
|
``build_root()`` is the single source of truth — it returns a directory laid
|
|
out like a repo root (has ``bot_bottle/``, ``pyproject.toml``, the
|
|
Dockerfiles, ``nix/``, ``scripts/``). Every caller that needs a build
|
|
context, a Dockerfile path, the nix netpool module, or the netpool script
|
|
derives from it, so checkout and wheel installs share one downstream path.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import fcntl
|
|
import hashlib
|
|
import os
|
|
import shutil
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
from .paths import bot_bottle_root
|
|
|
|
_PKG = Path(__file__).resolve().parent # …/bot_bottle
|
|
_CHECKOUT_ROOT = _PKG.parent # repo root in a checkout
|
|
_BUNDLED = _PKG / "_resources" # wheel-shipped copies
|
|
|
|
# Root-level files bundled into the wheel under ``_resources/`` (paths are
|
|
# relative to the checkout root, and preserved verbatim under ``_resources/``
|
|
# and in the staged build root). ``setup.py`` copies exactly this set; keep
|
|
# the two lists in sync (``test_resources`` guards that every entry exists).
|
|
BUNDLED_RESOURCES: tuple[str, ...] = (
|
|
"pyproject.toml",
|
|
"Dockerfile.gateway",
|
|
"Dockerfile.orchestrator",
|
|
"Dockerfile.orchestrator.fc",
|
|
"nix/firecracker-netpool.nix",
|
|
"scripts/firecracker-netpool.sh",
|
|
)
|
|
|
|
# Present at a checkout root, never in a bare installed package — the cheap
|
|
# tell for which layout we're in.
|
|
_CHECKOUT_MARKER = "Dockerfile.gateway"
|
|
|
|
|
|
class ResourceError(RuntimeError):
|
|
"""Build resources are missing from the install (corrupt/partial wheel)."""
|
|
|
|
|
|
def is_source_checkout() -> bool:
|
|
"""True when running from a source tree (the root Dockerfiles sit beside
|
|
the package); False from an installed wheel."""
|
|
return (_CHECKOUT_ROOT / _CHECKOUT_MARKER).is_file()
|
|
|
|
|
|
def build_root() -> Path:
|
|
"""A directory shaped like a repo root: ``bot_bottle/``, ``pyproject.toml``,
|
|
the root Dockerfiles, ``nix/``, and ``scripts/``.
|
|
|
|
A checkout returns the repo root itself (no copying). An installed wheel
|
|
returns a staged copy under the app-data dir, materialized once and reused.
|
|
The stage is keyed by a digest of the installed package + bundled resources
|
|
(not the distribution version), so a force-reinstall of a newer commit that
|
|
keeps ``version = 0.1.0`` still rebuilds instead of reusing a stale tree."""
|
|
if is_source_checkout():
|
|
return _CHECKOUT_ROOT
|
|
return _stage_build_root()
|
|
|
|
|
|
def dockerfile(name: str) -> Path:
|
|
"""Absolute path to a root-level Dockerfile, e.g. ``Dockerfile.gateway``."""
|
|
return build_root() / name
|
|
|
|
|
|
def nix_netpool_module() -> Path:
|
|
"""Absolute path to the firecracker netpool NixOS module."""
|
|
return build_root() / "nix" / "firecracker-netpool.nix"
|
|
|
|
|
|
def netpool_script() -> Path:
|
|
"""Absolute path to the firecracker netpool bring-up script."""
|
|
return build_root() / "scripts" / "firecracker-netpool.sh"
|
|
|
|
|
|
def _content_digest() -> str:
|
|
"""A 16-hex digest of the installed package + bundled resources.
|
|
|
|
Keys the staged build root by *content*, so a force-reinstall over the same
|
|
version string (the installer defaults to a git branch + ``pipx install
|
|
--force``, and ``version`` stays ``0.1.0``) yields a different key and
|
|
re-stages, rather than reusing an old commit's tree. ``_PKG`` already
|
|
contains ``_resources``, so walking it covers both."""
|
|
h = hashlib.sha256()
|
|
for path in sorted(_PKG.rglob("*")):
|
|
if not path.is_file() or "__pycache__" in path.parts or path.suffix == ".pyc":
|
|
continue
|
|
h.update(str(path.relative_to(_PKG)).encode())
|
|
h.update(b"\0")
|
|
h.update(path.read_bytes())
|
|
return h.hexdigest()[:16]
|
|
|
|
|
|
def _stage_build_root() -> Path:
|
|
"""Materialize a repo-root-shaped build context from the installed wheel's
|
|
bundled resources, keyed by content digest. Idempotent and concurrency-safe:
|
|
a file lock serializes staging, a partial/stale tree is replaced, and the
|
|
finished tree is published with an atomic rename."""
|
|
if not _BUNDLED.is_dir():
|
|
raise ResourceError(
|
|
"bot-bottle build resources are missing from this install "
|
|
f"(expected {_BUNDLED}). Reinstall the package."
|
|
)
|
|
base = bot_bottle_root() / "build-root"
|
|
base.mkdir(parents=True, exist_ok=True)
|
|
dest = base / _content_digest()
|
|
if (dest / ".complete").is_file():
|
|
return dest
|
|
|
|
# Serialize staging across processes: a concurrent `start` after an install
|
|
# must not race on the shared tree. The lock is held only around stage +
|
|
# atomic publish; the fast path above never blocks.
|
|
with open(base / ".stage.lock", "w", encoding="utf-8") as lock:
|
|
fcntl.flock(lock, fcntl.LOCK_EX)
|
|
if (dest / ".complete").is_file(): # another process staged while we waited
|
|
return dest
|
|
# Stage into a private temp dir on the same filesystem, then publish by
|
|
# rename — never populate a shared path other processes might read.
|
|
staging = Path(tempfile.mkdtemp(prefix=".staging-", dir=base))
|
|
try:
|
|
# The package itself, minus caches and the bundled-resource copies,
|
|
# so the staged ``bot_bottle/`` matches a checkout's (keeps the
|
|
# firecracker infra-artifact hash stable across checkout and wheel).
|
|
shutil.copytree(
|
|
_PKG,
|
|
staging / "bot_bottle",
|
|
ignore=shutil.ignore_patterns("__pycache__", "*.pyc", "_resources"),
|
|
)
|
|
# The bundled root files, restored to their checkout-relative layout.
|
|
for rel in BUNDLED_RESOURCES:
|
|
dst = staging / rel
|
|
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(_BUNDLED / rel, dst)
|
|
(staging / ".complete").write_text("")
|
|
# Replace any partial leftover for this digest (safe: we hold the
|
|
# lock), then publish atomically.
|
|
if dest.exists():
|
|
shutil.rmtree(dest)
|
|
os.replace(staging, dest)
|
|
staging = None # published; nothing to clean up
|
|
finally:
|
|
if staging is not None:
|
|
shutil.rmtree(staging, ignore_errors=True)
|
|
return dest
|