fix: make installed wheel self-contained + harden install.sh prereqs
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>
This commit is contained in:
@@ -10,9 +10,10 @@ from ...paths import (
|
||||
ORCHESTRATOR_AUTH_JWT_ENV,
|
||||
host_gateway_ca_dir,
|
||||
)
|
||||
from ... import resources
|
||||
from ...gateway import (
|
||||
Gateway, GatewayTransport, GATEWAY_IMAGE, GATEWAY_NAME, GATEWAY_NETWORK,
|
||||
GATEWAY_DOCKERFILE, REPO_ROOT, GATEWAY_LABEL, MITMPROXY_HOME,
|
||||
GATEWAY_DOCKERFILE, GATEWAY_LABEL, MITMPROXY_HOME,
|
||||
DEFAULT_CA_TIMEOUT_SECONDS, CA_POLL_SECONDS, GATEWAY_CA_CERT, GatewayError
|
||||
)
|
||||
|
||||
@@ -50,7 +51,9 @@ class DockerGateway(Gateway):
|
||||
# `address` / `stop` work on an already-running gateway without it.
|
||||
self._orchestrator_url = ""
|
||||
self._gateway_token = ""
|
||||
self._build_context = build_context or REPO_ROOT
|
||||
# Resolved lazily in ensure_built() so merely constructing a gateway to
|
||||
# read its CA never stages a build root from an installed wheel.
|
||||
self._build_context = build_context
|
||||
self._dockerfile = dockerfile
|
||||
# Ports published on the host (0.0.0.0). Used by the Firecracker
|
||||
# backend's dev-harness gateway so VMs can reach it via their TAP link;
|
||||
@@ -72,9 +75,10 @@ class DockerGateway(Gateway):
|
||||
forces a full rebuild (parity with `start --no-cache`)."""
|
||||
if self._dockerfile is None:
|
||||
return
|
||||
context = self._build_context or resources.build_root()
|
||||
argv = ["docker", "build", "-t", self.image_ref,
|
||||
"-f", str(self._build_context / self._dockerfile),
|
||||
str(self._build_context)]
|
||||
"-f", str(context / self._dockerfile),
|
||||
str(context)]
|
||||
if os.environ.get("BOT_BOTTLE_NO_CACHE"):
|
||||
argv.insert(2, "--no-cache")
|
||||
proc = run_docker(argv)
|
||||
|
||||
@@ -34,6 +34,7 @@ from .orchestrator import (
|
||||
ORCHESTRATOR_NETWORK,
|
||||
)
|
||||
from ...paths import bot_bottle_root
|
||||
from ... import resources
|
||||
from ...gateway import (
|
||||
GATEWAY_IMAGE,
|
||||
GATEWAY_NAME,
|
||||
@@ -50,8 +51,6 @@ from ...orchestrator.lifecycle import (
|
||||
# the pair's public identity.
|
||||
INFRA_NAME = GATEWAY_NAME # the container agents attribute against is the gateway
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
|
||||
|
||||
class DockerInfraService(InfraService):
|
||||
"""Composes the per-host control plane + gateway as two containers.
|
||||
@@ -68,7 +67,7 @@ class DockerInfraService(InfraService):
|
||||
control_network: str = ORCHESTRATOR_NETWORK,
|
||||
orchestrator_image: str = ORCHESTRATOR_IMAGE,
|
||||
gateway_image: str = GATEWAY_IMAGE,
|
||||
repo_root: Path = _REPO_ROOT,
|
||||
repo_root: Path | None = None,
|
||||
host_root: Path | None = None,
|
||||
orchestrator_name: str = ORCHESTRATOR_NAME,
|
||||
orchestrator_label: str = ORCHESTRATOR_LABEL,
|
||||
@@ -79,7 +78,9 @@ class DockerInfraService(InfraService):
|
||||
self.control_network = control_network
|
||||
self.orchestrator_image = orchestrator_image
|
||||
self.gateway_image = gateway_image
|
||||
self._repo_root = repo_root
|
||||
# Build context / bind-mount source: the repo root in a checkout, a
|
||||
# staged copy from the installed wheel otherwise (bot_bottle.resources).
|
||||
self._repo_root = repo_root if repo_root is not None else resources.build_root()
|
||||
self._host_root = host_root or bot_bottle_root()
|
||||
self._orchestrator_name = orchestrator_name
|
||||
self._orchestrator_label = orchestrator_label
|
||||
|
||||
@@ -33,7 +33,6 @@ from __future__ import annotations
|
||||
import dataclasses
|
||||
import os
|
||||
from contextlib import ExitStack, contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Callable, Generator
|
||||
|
||||
from ...agent_provider import runtime_for
|
||||
@@ -65,10 +64,7 @@ from ...orchestrator.store.config_store import resolve_teardown_timeout
|
||||
from .consolidated_launch import launch_consolidated, deprovision_consolidated
|
||||
from .infra import INFRA_NAME
|
||||
from .gateway import DockerGateway
|
||||
|
||||
|
||||
# Where the repo root lives, for `docker build` context. Computed once.
|
||||
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
|
||||
from ... import resources
|
||||
|
||||
|
||||
def build_or_load_images(plan: DockerBottlePlan) -> BottleImages:
|
||||
@@ -88,7 +84,7 @@ def build_or_load_images(plan: DockerBottlePlan) -> BottleImages:
|
||||
)
|
||||
info(f"using cached agent image {plan.image!r}")
|
||||
return BottleImages(agent=plan.image)
|
||||
docker_mod.build_image(plan.image, _REPO_DIR, dockerfile=plan.dockerfile_path)
|
||||
docker_mod.build_image(plan.image, str(resources.build_root()), dockerfile=plan.dockerfile_path)
|
||||
docker_mod.verify_agent_image(
|
||||
plan.image, runtime_for(plan.agent_provider_template).smoke_test,
|
||||
)
|
||||
|
||||
@@ -15,6 +15,7 @@ import time
|
||||
from pathlib import Path
|
||||
|
||||
from ... import log
|
||||
from ... import resources
|
||||
from .util import run_docker
|
||||
from ...paths import (
|
||||
ORCHESTRATOR_TOKEN_ENV,
|
||||
@@ -55,8 +56,6 @@ _ROOT_IN_CONTAINER = "/bot-bottle-root"
|
||||
|
||||
_HEALTH_POLL_SECONDS = 0.25
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
|
||||
|
||||
class DockerOrchestrator(Orchestrator):
|
||||
"""The control plane as a single fixed-name container. `ensure_built` builds
|
||||
@@ -71,7 +70,7 @@ class DockerOrchestrator(Orchestrator):
|
||||
label: str = ORCHESTRATOR_LABEL,
|
||||
port: int = DEFAULT_PORT,
|
||||
control_network: str = ORCHESTRATOR_NETWORK,
|
||||
repo_root: Path = _REPO_ROOT,
|
||||
repo_root: Path | None = None,
|
||||
host_root: Path | None = None,
|
||||
dockerfile: str | None = ORCHESTRATOR_DOCKERFILE,
|
||||
) -> None:
|
||||
@@ -80,7 +79,9 @@ class DockerOrchestrator(Orchestrator):
|
||||
self.label = label
|
||||
self.port = port
|
||||
self.control_network = control_network
|
||||
self._repo_root = repo_root
|
||||
# Build context / bind-mount source: the repo root in a checkout, a
|
||||
# staged copy from the installed wheel otherwise (bot_bottle.resources).
|
||||
self._repo_root = repo_root if repo_root is not None else resources.build_root()
|
||||
self._host_root = host_root or bot_bottle_root()
|
||||
self._dockerfile = dockerfile
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
from ... import resources
|
||||
from ...log import die, info
|
||||
from . import util
|
||||
|
||||
@@ -44,8 +45,6 @@ from . import util
|
||||
# scheme can't collide with a cached/published artifact of the old one.
|
||||
_ARTIFACT_FORMAT = "1"
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
|
||||
# The two per-plane infra VM roles. Each publishes/pulls its own rootfs artifact
|
||||
# from its own generic package; the Dockerfiles baked into each differ (only the
|
||||
# orchestrator rootfs carries buildah), so the versions are hashed separately.
|
||||
@@ -74,7 +73,7 @@ def local_build_requested() -> bool:
|
||||
|
||||
|
||||
def infra_artifact_version(
|
||||
init_script: str, role: str, *, repo_root: Path = _REPO_ROOT,
|
||||
init_script: str, role: str, *, repo_root: Path | None = None,
|
||||
) -> str:
|
||||
"""Content hash (16 hex) of everything baked into `role`'s infra rootfs: the
|
||||
whole shipped `bot_bottle` package, that role's Dockerfiles, and its guest
|
||||
@@ -89,6 +88,8 @@ def infra_artifact_version(
|
||||
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."""
|
||||
if repo_root is None:
|
||||
repo_root = resources.build_root()
|
||||
h = hashlib.sha256()
|
||||
h.update(f"format={_ARTIFACT_FORMAT}\nrole={role}\n".encode())
|
||||
pkg = repo_root / "bot_bottle"
|
||||
|
||||
@@ -42,6 +42,7 @@ from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Generator
|
||||
|
||||
from ... import resources
|
||||
from ...log import die, info
|
||||
from ..docker import util as docker_mod
|
||||
from . import firecracker_vm, infra_artifact, netpool, util
|
||||
@@ -65,7 +66,6 @@ _GUEST_GATEWAY_JWT_PATH = "/var/lib/bot-bottle/gateway-jwt"
|
||||
_GATEWAY_IMAGE = "bot-bottle-gateway:latest"
|
||||
_ORCHESTRATOR_IMAGE = "bot-bottle-orchestrator:latest"
|
||||
_ORCHESTRATOR_FC_IMAGE = "bot-bottle-orchestrator-fc:latest"
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
|
||||
# Per-role rootfs source image + the extra free space `mke2fs` leaves for the
|
||||
# guest to grow into. The orchestrator keeps buildah's large build slack; the
|
||||
@@ -130,12 +130,13 @@ def build_infra_images_with_docker() -> None:
|
||||
orchestrator + buildah). The gateway VM boots the gateway image directly.
|
||||
The launch host uses this only in `BOT_BOTTLE_INFRA_BUILD=local` mode;
|
||||
`publish_infra` uses it off-host to produce the published artifacts."""
|
||||
root = str(resources.build_root())
|
||||
docker_mod.build_image(
|
||||
_ORCHESTRATOR_IMAGE, str(_REPO_ROOT), dockerfile="Dockerfile.orchestrator")
|
||||
_ORCHESTRATOR_IMAGE, root, dockerfile="Dockerfile.orchestrator")
|
||||
docker_mod.build_image(
|
||||
_GATEWAY_IMAGE, str(_REPO_ROOT), dockerfile="Dockerfile.gateway")
|
||||
_GATEWAY_IMAGE, root, dockerfile="Dockerfile.gateway")
|
||||
docker_mod.build_image(
|
||||
_ORCHESTRATOR_FC_IMAGE, str(_REPO_ROOT), dockerfile="Dockerfile.orchestrator.fc")
|
||||
_ORCHESTRATOR_FC_IMAGE, root, dockerfile="Dockerfile.orchestrator.fc")
|
||||
|
||||
|
||||
def build_rootfs_dir(role: str) -> Path:
|
||||
|
||||
@@ -20,6 +20,7 @@ import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from ... import resources
|
||||
from . import netpool
|
||||
from . import util
|
||||
|
||||
@@ -42,13 +43,13 @@ def _has_systemd() -> bool:
|
||||
|
||||
|
||||
def _module_path() -> str:
|
||||
"""Absolute path to the importable NixOS module in this checkout."""
|
||||
return str(Path(__file__).resolve().parents[3] / "nix" / "firecracker-netpool.nix")
|
||||
"""Absolute path to the importable NixOS module (checkout or wheel)."""
|
||||
return str(resources.nix_netpool_module())
|
||||
|
||||
|
||||
def _script_path() -> str:
|
||||
"""Absolute path to the bundled bring-up script in this checkout."""
|
||||
return str(Path(__file__).resolve().parents[3] / "scripts" / "firecracker-netpool.sh")
|
||||
"""Absolute path to the bundled bring-up script (checkout or wheel)."""
|
||||
return str(resources.netpool_script())
|
||||
|
||||
|
||||
def _print_prereqs() -> None:
|
||||
|
||||
@@ -28,6 +28,7 @@ from ...paths import (
|
||||
ORCHESTRATOR_AUTH_JWT_ENV,
|
||||
host_gateway_ca_dir,
|
||||
)
|
||||
from ... import resources
|
||||
from .. import util as backend_util
|
||||
from . import util as container_mod
|
||||
|
||||
@@ -52,8 +53,6 @@ GATEWAY_DAEMONS = "egress,git-http,supervise"
|
||||
|
||||
GATEWAY_IMAGE = os.environ.get("BOT_BOTTLE_GATEWAY_IMAGE", "bot-bottle-gateway:latest")
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
|
||||
|
||||
def ensure_networks(
|
||||
network: str = GATEWAY_NETWORK,
|
||||
@@ -84,14 +83,16 @@ class MacosGateway(Gateway):
|
||||
network: str = GATEWAY_NETWORK,
|
||||
egress_network: str = GATEWAY_EGRESS_NETWORK,
|
||||
control_network: str = CONTROL_NETWORK,
|
||||
repo_root: Path = _REPO_ROOT,
|
||||
repo_root: Path | None = None,
|
||||
) -> None:
|
||||
self.image_ref = image_ref
|
||||
self.name = name
|
||||
self.network = network
|
||||
self.egress_network = egress_network
|
||||
self.control_network = control_network
|
||||
self._repo_root = repo_root
|
||||
# Build context: the repo root in a checkout, a staged copy from the
|
||||
# installed wheel otherwise (bot_bottle.resources).
|
||||
self._repo_root = repo_root if repo_root is not None else resources.build_root()
|
||||
# Set by `connect_to_orchestrator`: the URL the daemons resolve policy
|
||||
# against + the pre-minted `gateway` token they present. The gateway
|
||||
# never mints, so it never holds the signing key (#469).
|
||||
|
||||
@@ -24,6 +24,7 @@ from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from ... import resources
|
||||
from ...orchestrator.lifecycle import (
|
||||
DEFAULT_PORT,
|
||||
DEFAULT_STARTUP_TIMEOUT_SECONDS,
|
||||
@@ -53,8 +54,6 @@ from .orchestrator import (
|
||||
# still import it (probe / reprovision attribute against the gateway).
|
||||
INFRA_NAME = GATEWAY_NAME
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
|
||||
|
||||
class MacosInfraService(InfraService):
|
||||
"""Composes the per-host orchestrator + gateway containers. Callers use
|
||||
@@ -70,7 +69,7 @@ class MacosInfraService(InfraService):
|
||||
control_network: str = CONTROL_NETWORK,
|
||||
gateway_image: str = GATEWAY_IMAGE,
|
||||
orchestrator_image: str = ORCHESTRATOR_IMAGE,
|
||||
repo_root: Path = _REPO_ROOT,
|
||||
repo_root: Path | None = None,
|
||||
orchestrator_name: str = ORCHESTRATOR_NAME,
|
||||
gateway_name: str = INFRA_NAME,
|
||||
db_volume: str = ORCHESTRATOR_DB_VOLUME,
|
||||
@@ -81,7 +80,9 @@ class MacosInfraService(InfraService):
|
||||
self.control_network = control_network
|
||||
self.gateway_image = gateway_image
|
||||
self.orchestrator_image = orchestrator_image
|
||||
self._repo_root = repo_root
|
||||
# Build context / bind-mount source: the repo root in a checkout, a
|
||||
# staged copy from the installed wheel otherwise (bot_bottle.resources).
|
||||
self._repo_root = repo_root if repo_root is not None else resources.build_root()
|
||||
self._orchestrator_name = orchestrator_name
|
||||
self._gateway_name = gateway_name
|
||||
self._db_volume = db_volume
|
||||
|
||||
@@ -36,7 +36,6 @@ import dataclasses
|
||||
import os
|
||||
import subprocess
|
||||
from contextlib import ExitStack, contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Callable, Generator
|
||||
|
||||
from ...bottle_state import (
|
||||
@@ -49,6 +48,7 @@ from ...git_gate import GitGate
|
||||
from ...gateway.git_gate.http_backend import DEFAULT_PORT as _GIT_HTTP_PORT
|
||||
from ...image_cache import check_stale
|
||||
from ...log import die, info, warn
|
||||
from ... import resources
|
||||
from .. import BottleImages
|
||||
from ...supervisor.types import SUPERVISE_PORT
|
||||
from ..docker.egress import EGRESS_PORT
|
||||
@@ -71,7 +71,6 @@ from .consolidated_launch import (
|
||||
deprovision_consolidated,
|
||||
)
|
||||
|
||||
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
|
||||
_AGENT_SLEEP_SECONDS = "2147483647"
|
||||
|
||||
|
||||
@@ -94,7 +93,7 @@ def _agent_image(plan: MacosContainerBottlePlan) -> str:
|
||||
)
|
||||
info(f"using cached agent image {plan.image!r}")
|
||||
return plan.image
|
||||
container_mod.build_image(plan.image, _REPO_DIR, dockerfile=plan.dockerfile_path)
|
||||
container_mod.build_image(plan.image, str(resources.build_root()), dockerfile=plan.dockerfile_path)
|
||||
return plan.image
|
||||
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
from ... import log
|
||||
from ... import resources
|
||||
from ...paths import ORCHESTRATOR_TOKEN_ENV
|
||||
from ...orchestrator.lifecycle import (
|
||||
DEFAULT_HEALTH_TIMEOUT_SECONDS,
|
||||
@@ -45,7 +46,6 @@ _DB_ROOT_IN_CONTAINER = "/var/lib/bot-bottle"
|
||||
_SRC_IN_CONTAINER = "/bot-bottle-src"
|
||||
|
||||
_HEALTH_POLL_SECONDS = 0.25
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
|
||||
|
||||
class MacosOrchestrator(Orchestrator):
|
||||
@@ -61,7 +61,7 @@ class MacosOrchestrator(Orchestrator):
|
||||
label: str = ORCHESTRATOR_LABEL,
|
||||
port: int = DEFAULT_PORT,
|
||||
control_network: str = CONTROL_NETWORK,
|
||||
repo_root: Path = _REPO_ROOT,
|
||||
repo_root: Path | None = None,
|
||||
db_volume: str = ORCHESTRATOR_DB_VOLUME,
|
||||
) -> None:
|
||||
self.image_ref = image_ref
|
||||
@@ -69,7 +69,9 @@ class MacosOrchestrator(Orchestrator):
|
||||
self.label = label
|
||||
self.port = port
|
||||
self.control_network = control_network
|
||||
self._repo_root = repo_root
|
||||
# Build context / bind-mount source: the repo root in a checkout, a
|
||||
# staged copy from the installed wheel otherwise (bot_bottle.resources).
|
||||
self._repo_root = repo_root if repo_root is not None else resources.build_root()
|
||||
self._db_volume = db_volume
|
||||
|
||||
def url(self) -> str:
|
||||
|
||||
@@ -62,7 +62,6 @@ GATEWAY_CA_GLOB = "mitmproxy-ca*"
|
||||
# that lands. Env override matches the backend's BOT_BOTTLE_GATEWAY_IMAGE.
|
||||
GATEWAY_IMAGE = os.environ.get("BOT_BOTTLE_GATEWAY_IMAGE", "bot-bottle-gateway:latest")
|
||||
GATEWAY_DOCKERFILE = "Dockerfile.gateway"
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def rotate_gateway_ca(ca_dir: Path | None = None) -> list[Path]:
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user