Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 652f14dcb1 | |||
| 38c13708c7 | |||
| 82669b22d5 | |||
| 1a4b390e8a | |||
| 955cb3bcbd | |||
| 605146d287 | |||
| 27dea58ae1 | |||
| 5401f036a9 | |||
| 238f5f7614 | |||
| 2644759b0d | |||
| e53104d5c1 | |||
| 8f6148d571 | |||
| 45f3cefbc5 | |||
| 0e70d26af4 | |||
| f2d8158742 |
@@ -20,3 +20,9 @@ omit =
|
|||||||
bot_bottle/cli/tui.py
|
bot_bottle/cli/tui.py
|
||||||
bot_bottle/cli/init.py
|
bot_bottle/cli/init.py
|
||||||
tests/*
|
tests/*
|
||||||
|
# Build-time only: setuptools invokes it out-of-process to build the
|
||||||
|
# wheel/sdist (it's never imported by the running app), so in-process
|
||||||
|
# coverage can't reach it. Its one job — bundling the root resources into
|
||||||
|
# bot_bottle/_resources/ — is exercised end-to-end by test_wheel_install,
|
||||||
|
# which builds and installs a real wheel and checks the result.
|
||||||
|
setup.py
|
||||||
|
|||||||
@@ -212,6 +212,89 @@ jobs:
|
|||||||
name: firecracker-inputs
|
name: firecracker-inputs
|
||||||
path: /var/cache/bot-bottle-fc/dropbear
|
path: /var/cache/bot-bottle-fc/dropbear
|
||||||
|
|
||||||
|
# Integration tests against the macOS Apple Container backend. Runs on a
|
||||||
|
# self-hosted macOS runner (label `macos`) registered in HOST mode — Apple
|
||||||
|
# Container needs the host `container` CLI + virtualization framework and
|
||||||
|
# cannot run inside a Linux container, so this cannot reuse the KVM runner.
|
||||||
|
#
|
||||||
|
# Advisory only: workflow_dispatch (manual) exclusively — never push or
|
||||||
|
# pull_request. A single non-redundant laptop that sleeps/roams must not run
|
||||||
|
# unattended on every push to main, let alone block a PR merge, so this job is
|
||||||
|
# deliberately NOT in the `coverage` job's `needs` and its coverage never
|
||||||
|
# feeds the diff-coverage gate. Dispatch-only also means no fork PR (or any
|
||||||
|
# push) ever executes on the host-mode runner.
|
||||||
|
#
|
||||||
|
# The infra container is a singleton (`bot-bottle-mac-infra`); the
|
||||||
|
# `concurrency` group serializes runs so two never collide on it (#425), and
|
||||||
|
# the always-run teardown removes it so a crashed run can't wedge the next.
|
||||||
|
#
|
||||||
|
# Runner prerequisites (provision once; see README "macOS Apple Container"):
|
||||||
|
# the `container` CLI on PATH with `container system status` running, and a
|
||||||
|
# Python >=3.11 with `coverage` importable on the launchd service PATH.
|
||||||
|
integration-macos:
|
||||||
|
runs-on: [self-hosted, macos]
|
||||||
|
if: github.event_name == 'workflow_dispatch'
|
||||||
|
concurrency:
|
||||||
|
group: integration-macos-infra
|
||||||
|
cancel-in-progress: false
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# Fail loudly if the backend this job promises isn't actually usable,
|
||||||
|
# rather than letting every test silently `unittest.skip` and the job go
|
||||||
|
# green on zero coverage. `backend status` exits non-zero (and prints the
|
||||||
|
# per-check summary) when the `container` CLI or its system service is
|
||||||
|
# missing — the same readiness check the skip guards gate on.
|
||||||
|
- name: Preflight — Apple Container backend is ready
|
||||||
|
run: |
|
||||||
|
command -v container >/dev/null || {
|
||||||
|
echo "container CLI not on PATH — provision the runner (README: macOS Apple Container)"; exit 1; }
|
||||||
|
container system status || {
|
||||||
|
echo "container system service not running — run 'container system start'"; exit 1; }
|
||||||
|
python3 cli.py backend status --backend=macos-container
|
||||||
|
|
||||||
|
# `coverage` comes from the runner's provisioned Python (no pip install
|
||||||
|
# into the host interpreter). Advisory job: report coverage in-line for
|
||||||
|
# visibility but don't upload — it never feeds the combined gate.
|
||||||
|
- name: Run integration tests (macos-container) with coverage
|
||||||
|
env:
|
||||||
|
BOT_BOTTLE_BACKEND: macos-container
|
||||||
|
COVERAGE_FILE: ${{ github.workspace }}/.coverage.macos
|
||||||
|
run: python3 -m coverage run -m unittest discover -t . -s tests/integration -v
|
||||||
|
|
||||||
|
- name: Report macos coverage
|
||||||
|
env:
|
||||||
|
COVERAGE_FILE: ${{ github.workspace }}/.coverage.macos
|
||||||
|
run: python3 -m coverage report -m
|
||||||
|
|
||||||
|
# On failure, capture the infra containers' state and logs BEFORE the
|
||||||
|
# teardown below removes them — otherwise a control-plane crash is
|
||||||
|
# undiagnosable from CI, since `stop()` deletes the orchestrator (and its
|
||||||
|
# logs) on every run. Best-effort: never let the diagnostics themselves
|
||||||
|
# fail the job, and keep going if a container is already gone.
|
||||||
|
- name: Dump infra diagnostics (on failure)
|
||||||
|
if: failure()
|
||||||
|
run: |
|
||||||
|
set +e
|
||||||
|
echo "=== containers ==="
|
||||||
|
container ls -a | grep bot-bottle-mac || echo "(no bot-bottle-mac containers)"
|
||||||
|
echo "=== networks ==="
|
||||||
|
container network ls | grep bot-bottle-mac || echo "(no bot-bottle-mac networks)"
|
||||||
|
for c in bot-bottle-mac-orchestrator bot-bottle-mac-infra; do
|
||||||
|
echo "=== inspect $c ==="
|
||||||
|
container inspect "$c" || echo "($c not found)"
|
||||||
|
echo "=== logs $c ==="
|
||||||
|
container logs "$c" || echo "($c logs unavailable)"
|
||||||
|
done
|
||||||
|
exit 0
|
||||||
|
|
||||||
|
# Remove the singleton infra container so a crashed or cancelled run
|
||||||
|
# cannot leave `bot-bottle-mac-infra` wedged for the next job.
|
||||||
|
- name: Teardown infra singleton
|
||||||
|
if: always()
|
||||||
|
run: python3 -c 'from bot_bottle.backend.macos_container.infra import MacosInfraService; MacosInfraService().stop()'
|
||||||
|
|
||||||
# Combined coverage gate: aggregates .coverage.* artifacts uploaded by each
|
# Combined coverage gate: aggregates .coverage.* artifacts uploaded by each
|
||||||
# test job, then runs the diff-coverage gate (new/changed lines >= 90%).
|
# test job, then runs the diff-coverage gate (new/changed lines >= 90%).
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ __pycache__/
|
|||||||
*.py[cod]
|
*.py[cod]
|
||||||
*$py.class
|
*$py.class
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
|
# setuptools/build_meta output (wheels, sdists, build tree)
|
||||||
|
/build/
|
||||||
|
/dist/
|
||||||
.venv/
|
.venv/
|
||||||
venv/
|
venv/
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# Root-level build resources copied into bot_bottle/_resources/ at build time
|
||||||
|
# (see setup.py). Included in the sdist so `pip install` from an sdist can
|
||||||
|
# still bundle them into the wheel.
|
||||||
|
include Dockerfile.gateway
|
||||||
|
include Dockerfile.orchestrator
|
||||||
|
include Dockerfile.orchestrator.fc
|
||||||
|
include nix/firecracker-netpool.nix
|
||||||
|
include scripts/firecracker-netpool.sh
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
# bot-bottle
|
# bot-bottle
|
||||||
|
|
||||||
[](https://gitea.dideric.is/didericis/bot-bottle/actions?workflow=test.yml)
|
[](https://gitea.dideric.is/didericis/bot-bottle/actions?workflow=test.yml)
|
||||||
[](https://coverage.readthedocs.io/)
|
[](https://coverage.readthedocs.io/)
|
||||||
[](https://gitea.dideric.is/didericis/bot-bottle/src/branch/main/docs/decisions/0004-coverage-policy.md)
|
[](https://gitea.dideric.is/didericis/bot-bottle/src/branch/main/docs/decisions/0004-coverage-policy.md)
|
||||||
|
|
||||||
**Problem:** Developer wants to run a coding agent without supervision, but they don't want a prompt injected or misbehaving agent wrecking their environment or exfiltrating sensitive data.
|
**Problem:** Developer wants to run a coding agent without supervision, but they don't want a prompt injected or misbehaving agent wrecking their environment or exfiltrating sensitive data.
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
On the default macOS Apple Container backend, a bottle is an agent container on a host-only internal network plus a gateway attached to both that internal network and a NAT egress network. The agent gets HTTP(S)_PROXY and CA bundle env vars pointing at the gateway's internal-network IP, so HTTP/HTTPS traffic flows through the gateway instead of direct egress. `bottle.git` / git-gate is intentionally deferred on this backend until a safe Apple Container key-delivery path exists.
|
On the default macOS Apple Container backend, a bottle is an agent container on a host-only internal network plus a gateway attached to both that internal network and a NAT egress network. The agent gets HTTP(S)_PROXY and CA bundle env vars pointing at the gateway's internal-network IP, so HTTP/HTTPS traffic flows through the gateway instead of direct egress. git-gate runs over the gateway's consolidated `git-http` daemon (the legacy per-bottle `git://` daemon is not used on this backend); keys are provisioned dynamically at launch and revoked on teardown.
|
||||||
|
|
||||||
On the Firecracker backend, a bottle is an agent microVM plus a Docker gateway for egress, git-gate, and supervise. The VM reaches the gateway over a per-bottle point-to-point TAP link; a dedicated fail-closed `nftables` table (`inet bot_bottle_fc`) confines the guest to that link, so nothing leaves the box except through the gateway. The TAP pool and nft table are provisioned once (root); per-launch needs no privilege.
|
On the Firecracker backend, a bottle is an agent microVM plus a Docker gateway for egress, git-gate, and supervise. The VM reaches the gateway over a per-bottle point-to-point TAP link; a dedicated fail-closed `nftables` table (`inet bot_bottle_fc`) confines the guest to that link, so nothing leaves the box except through the gateway. The TAP pool and nft table are provisioned once (root); per-launch needs no privilege.
|
||||||
|
|
||||||
@@ -75,6 +75,8 @@ On compatible macOS hosts, the default backend requires Apple's `container` CLI
|
|||||||
|
|
||||||
Use `BOT_BOTTLE_BACKEND=docker ./cli.py start <agent>` on hosts where neither Apple Container nor KVM is available and Docker is the desired backend.
|
Use `BOT_BOTTLE_BACKEND=docker ./cli.py start <agent>` on hosts where neither Apple Container nor KVM is available and Docker is the desired backend.
|
||||||
|
|
||||||
|
> **CI (macOS Apple Container):** the `integration-macos` job (`.gitea/workflows/test.yml`) runs the integration suite against `BOT_BOTTLE_BACKEND=macos-container` on a self-hosted macOS runner labelled `macos`, because Apple Container needs the host virtualization framework and cannot run in a Linux container (so it can't reuse the `kvm` runner). Provision an Apple Silicon host with the `container` CLI on `PATH` and `container system status` running, then register the runner in **host mode** (not docker mode) with the `macos` label — `brew install gitea-runner` (the `act_runner` rename). Give it a Python ≥ 3.11 with `coverage` importable on the launchd service's `PATH` (a launchd service doesn't inherit your shell profile, so pin `node` and the Python env explicitly). The job is **advisory** — `workflow_dispatch` (manual) only, never triggered by push or PR — since a single laptop that sleeps/roams must not block merges or churn on every push to main; its coverage doesn't feed the gate. The infra container is a singleton (`bot-bottle-mac-infra`), so keep runner concurrency at 1.
|
||||||
|
|
||||||
### Containers inside a bottle
|
### Containers inside a bottle
|
||||||
|
|
||||||
A bottle may set `nested_containers: true`. On the macOS backend this starts a
|
A bottle may set `nested_containers: true`. On the macOS backend this starts a
|
||||||
|
|||||||
@@ -10,9 +10,10 @@ from ...paths import (
|
|||||||
ORCHESTRATOR_AUTH_JWT_ENV,
|
ORCHESTRATOR_AUTH_JWT_ENV,
|
||||||
host_gateway_ca_dir,
|
host_gateway_ca_dir,
|
||||||
)
|
)
|
||||||
|
from ... import resources
|
||||||
from ...gateway import (
|
from ...gateway import (
|
||||||
Gateway, GatewayTransport, GATEWAY_IMAGE, GATEWAY_NAME, GATEWAY_NETWORK,
|
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
|
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.
|
# `address` / `stop` work on an already-running gateway without it.
|
||||||
self._orchestrator_url = ""
|
self._orchestrator_url = ""
|
||||||
self._gateway_token = ""
|
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
|
self._dockerfile = dockerfile
|
||||||
# Ports published on the host (0.0.0.0). Used by the Firecracker
|
# 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;
|
# 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`)."""
|
forces a full rebuild (parity with `start --no-cache`)."""
|
||||||
if self._dockerfile is None:
|
if self._dockerfile is None:
|
||||||
return
|
return
|
||||||
|
context = self._build_context or resources.build_root()
|
||||||
argv = ["docker", "build", "-t", self.image_ref,
|
argv = ["docker", "build", "-t", self.image_ref,
|
||||||
"-f", str(self._build_context / self._dockerfile),
|
"-f", str(context / self._dockerfile),
|
||||||
str(self._build_context)]
|
str(context)]
|
||||||
if os.environ.get("BOT_BOTTLE_NO_CACHE"):
|
if os.environ.get("BOT_BOTTLE_NO_CACHE"):
|
||||||
argv.insert(2, "--no-cache")
|
argv.insert(2, "--no-cache")
|
||||||
proc = run_docker(argv)
|
proc = run_docker(argv)
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ from .orchestrator import (
|
|||||||
ORCHESTRATOR_NETWORK,
|
ORCHESTRATOR_NETWORK,
|
||||||
)
|
)
|
||||||
from ...paths import bot_bottle_root
|
from ...paths import bot_bottle_root
|
||||||
|
from ... import resources
|
||||||
from ...gateway import (
|
from ...gateway import (
|
||||||
GATEWAY_IMAGE,
|
GATEWAY_IMAGE,
|
||||||
GATEWAY_NAME,
|
GATEWAY_NAME,
|
||||||
@@ -50,8 +51,6 @@ from ...orchestrator.lifecycle import (
|
|||||||
# the pair's public identity.
|
# the pair's public identity.
|
||||||
INFRA_NAME = GATEWAY_NAME # the container agents attribute against is the gateway
|
INFRA_NAME = GATEWAY_NAME # the container agents attribute against is the gateway
|
||||||
|
|
||||||
_REPO_ROOT = Path(__file__).resolve().parents[3]
|
|
||||||
|
|
||||||
|
|
||||||
class DockerInfraService(InfraService):
|
class DockerInfraService(InfraService):
|
||||||
"""Composes the per-host control plane + gateway as two containers.
|
"""Composes the per-host control plane + gateway as two containers.
|
||||||
@@ -68,7 +67,7 @@ class DockerInfraService(InfraService):
|
|||||||
control_network: str = ORCHESTRATOR_NETWORK,
|
control_network: str = ORCHESTRATOR_NETWORK,
|
||||||
orchestrator_image: str = ORCHESTRATOR_IMAGE,
|
orchestrator_image: str = ORCHESTRATOR_IMAGE,
|
||||||
gateway_image: str = GATEWAY_IMAGE,
|
gateway_image: str = GATEWAY_IMAGE,
|
||||||
repo_root: Path = _REPO_ROOT,
|
repo_root: Path | None = None,
|
||||||
host_root: Path | None = None,
|
host_root: Path | None = None,
|
||||||
orchestrator_name: str = ORCHESTRATOR_NAME,
|
orchestrator_name: str = ORCHESTRATOR_NAME,
|
||||||
orchestrator_label: str = ORCHESTRATOR_LABEL,
|
orchestrator_label: str = ORCHESTRATOR_LABEL,
|
||||||
@@ -79,7 +78,9 @@ class DockerInfraService(InfraService):
|
|||||||
self.control_network = control_network
|
self.control_network = control_network
|
||||||
self.orchestrator_image = orchestrator_image
|
self.orchestrator_image = orchestrator_image
|
||||||
self.gateway_image = gateway_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._host_root = host_root or bot_bottle_root()
|
||||||
self._orchestrator_name = orchestrator_name
|
self._orchestrator_name = orchestrator_name
|
||||||
self._orchestrator_label = orchestrator_label
|
self._orchestrator_label = orchestrator_label
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ from __future__ import annotations
|
|||||||
import dataclasses
|
import dataclasses
|
||||||
import os
|
import os
|
||||||
from contextlib import ExitStack, contextmanager
|
from contextlib import ExitStack, contextmanager
|
||||||
from pathlib import Path
|
|
||||||
from typing import Callable, Generator
|
from typing import Callable, Generator
|
||||||
|
|
||||||
from ...agent_provider import runtime_for
|
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 .consolidated_launch import launch_consolidated, deprovision_consolidated
|
||||||
from .infra import INFRA_NAME
|
from .infra import INFRA_NAME
|
||||||
from .gateway import DockerGateway
|
from .gateway import DockerGateway
|
||||||
|
from ... import resources
|
||||||
|
|
||||||
# Where the repo root lives, for `docker build` context. Computed once.
|
|
||||||
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
|
|
||||||
|
|
||||||
|
|
||||||
def build_or_load_images(plan: DockerBottlePlan) -> BottleImages:
|
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}")
|
info(f"using cached agent image {plan.image!r}")
|
||||||
return BottleImages(agent=plan.image)
|
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(
|
docker_mod.verify_agent_image(
|
||||||
plan.image, runtime_for(plan.agent_provider_template).smoke_test,
|
plan.image, runtime_for(plan.agent_provider_template).smoke_test,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -15,11 +15,11 @@ import time
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from ... import log
|
from ... import log
|
||||||
|
from ... import resources
|
||||||
from .util import run_docker
|
from .util import run_docker
|
||||||
from ...paths import (
|
from ...paths import (
|
||||||
ORCHESTRATOR_TOKEN_ENV,
|
ORCHESTRATOR_TOKEN_ENV,
|
||||||
bot_bottle_root,
|
bot_bottle_root,
|
||||||
host_orchestrator_token,
|
|
||||||
)
|
)
|
||||||
from ...gateway import GatewayError
|
from ...gateway import GatewayError
|
||||||
from ...orchestrator.lifecycle import (
|
from ...orchestrator.lifecycle import (
|
||||||
@@ -56,8 +56,6 @@ _ROOT_IN_CONTAINER = "/bot-bottle-root"
|
|||||||
|
|
||||||
_HEALTH_POLL_SECONDS = 0.25
|
_HEALTH_POLL_SECONDS = 0.25
|
||||||
|
|
||||||
_REPO_ROOT = Path(__file__).resolve().parents[3]
|
|
||||||
|
|
||||||
|
|
||||||
class DockerOrchestrator(Orchestrator):
|
class DockerOrchestrator(Orchestrator):
|
||||||
"""The control plane as a single fixed-name container. `ensure_built` builds
|
"""The control plane as a single fixed-name container. `ensure_built` builds
|
||||||
@@ -72,7 +70,7 @@ class DockerOrchestrator(Orchestrator):
|
|||||||
label: str = ORCHESTRATOR_LABEL,
|
label: str = ORCHESTRATOR_LABEL,
|
||||||
port: int = DEFAULT_PORT,
|
port: int = DEFAULT_PORT,
|
||||||
control_network: str = ORCHESTRATOR_NETWORK,
|
control_network: str = ORCHESTRATOR_NETWORK,
|
||||||
repo_root: Path = _REPO_ROOT,
|
repo_root: Path | None = None,
|
||||||
host_root: Path | None = None,
|
host_root: Path | None = None,
|
||||||
dockerfile: str | None = ORCHESTRATOR_DOCKERFILE,
|
dockerfile: str | None = ORCHESTRATOR_DOCKERFILE,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -81,7 +79,9 @@ class DockerOrchestrator(Orchestrator):
|
|||||||
self.label = label
|
self.label = label
|
||||||
self.port = port
|
self.port = port
|
||||||
self.control_network = control_network
|
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._host_root = host_root or bot_bottle_root()
|
||||||
self._dockerfile = dockerfile
|
self._dockerfile = dockerfile
|
||||||
|
|
||||||
@@ -171,7 +171,9 @@ class DockerOrchestrator(Orchestrator):
|
|||||||
fixed-name container first)."""
|
fixed-name container first)."""
|
||||||
self._ensure_control_network()
|
self._ensure_control_network()
|
||||||
run_docker(["docker", "rm", "--force", self.name])
|
run_docker(["docker", "rm", "--force", self.name])
|
||||||
_signing_key = host_orchestrator_token()
|
# The signing key comes through the shared provisioning contract (#476),
|
||||||
|
# which fail-closes rather than yield an empty key that would run OPEN.
|
||||||
|
_signing_key = self.control_plane_key()
|
||||||
proc = run_docker([
|
proc = run_docker([
|
||||||
"docker", "run", "--detach",
|
"docker", "run", "--detach",
|
||||||
"--name", self.name,
|
"--name", self.name,
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import urllib.error
|
|||||||
import urllib.request
|
import urllib.request
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from ... import resources
|
||||||
from ...log import die, info
|
from ...log import die, info
|
||||||
from . import util
|
from . import util
|
||||||
|
|
||||||
@@ -44,8 +45,6 @@ from . import util
|
|||||||
# scheme can't collide with a cached/published artifact of the old one.
|
# scheme can't collide with a cached/published artifact of the old one.
|
||||||
_ARTIFACT_FORMAT = "1"
|
_ARTIFACT_FORMAT = "1"
|
||||||
|
|
||||||
_REPO_ROOT = Path(__file__).resolve().parents[3]
|
|
||||||
|
|
||||||
# The two per-plane infra VM roles. Each publishes/pulls its own rootfs artifact
|
# 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
|
# from its own generic package; the Dockerfiles baked into each differ (only the
|
||||||
# orchestrator rootfs carries buildah), so the versions are hashed separately.
|
# orchestrator rootfs carries buildah), so the versions are hashed separately.
|
||||||
@@ -74,7 +73,7 @@ def local_build_requested() -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def infra_artifact_version(
|
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:
|
) -> str:
|
||||||
"""Content hash (16 hex) of everything baked into `role`'s infra rootfs: the
|
"""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
|
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
|
version or a launch host could boot a stale rootfs whose code differs from
|
||||||
its checkout. `__pycache__`/`.pyc` are the only exclusions — build artifacts,
|
its checkout. `__pycache__`/`.pyc` are the only exclusions — build artifacts,
|
||||||
never copied."""
|
never copied."""
|
||||||
|
if repo_root is None:
|
||||||
|
repo_root = resources.build_root()
|
||||||
h = hashlib.sha256()
|
h = hashlib.sha256()
|
||||||
h.update(f"format={_ARTIFACT_FORMAT}\nrole={role}\n".encode())
|
h.update(f"format={_ARTIFACT_FORMAT}\nrole={role}\n".encode())
|
||||||
pkg = repo_root / "bot_bottle"
|
pkg = repo_root / "bot_bottle"
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ from dataclasses import dataclass
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Generator
|
from typing import Generator
|
||||||
|
|
||||||
|
from ... import resources
|
||||||
from ...log import die, info
|
from ...log import die, info
|
||||||
from ..docker import util as docker_mod
|
from ..docker import util as docker_mod
|
||||||
from . import firecracker_vm, infra_artifact, netpool, util
|
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"
|
_GATEWAY_IMAGE = "bot-bottle-gateway:latest"
|
||||||
_ORCHESTRATOR_IMAGE = "bot-bottle-orchestrator:latest"
|
_ORCHESTRATOR_IMAGE = "bot-bottle-orchestrator:latest"
|
||||||
_ORCHESTRATOR_FC_IMAGE = "bot-bottle-orchestrator-fc: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
|
# 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
|
# 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.
|
orchestrator + buildah). The gateway VM boots the gateway image directly.
|
||||||
The launch host uses this only in `BOT_BOTTLE_INFRA_BUILD=local` mode;
|
The launch host uses this only in `BOT_BOTTLE_INFRA_BUILD=local` mode;
|
||||||
`publish_infra` uses it off-host to produce the published artifacts."""
|
`publish_infra` uses it off-host to produce the published artifacts."""
|
||||||
|
root = str(resources.build_root())
|
||||||
docker_mod.build_image(
|
docker_mod.build_image(
|
||||||
_ORCHESTRATOR_IMAGE, str(_REPO_ROOT), dockerfile="Dockerfile.orchestrator")
|
_ORCHESTRATOR_IMAGE, root, dockerfile="Dockerfile.orchestrator")
|
||||||
docker_mod.build_image(
|
docker_mod.build_image(
|
||||||
_GATEWAY_IMAGE, str(_REPO_ROOT), dockerfile="Dockerfile.gateway")
|
_GATEWAY_IMAGE, root, dockerfile="Dockerfile.gateway")
|
||||||
docker_mod.build_image(
|
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:
|
def build_rootfs_dir(role: str) -> Path:
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import time
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from ...log import die, info
|
from ...log import die, info
|
||||||
from ...paths import host_orchestrator_token
|
|
||||||
from ...orchestrator.lifecycle import (
|
from ...orchestrator.lifecycle import (
|
||||||
DEFAULT_STARTUP_TIMEOUT_SECONDS,
|
DEFAULT_STARTUP_TIMEOUT_SECONDS,
|
||||||
Orchestrator,
|
Orchestrator,
|
||||||
@@ -108,11 +107,13 @@ class FirecrackerOrchestrator(Orchestrator):
|
|||||||
data_drive=self._ensure_registry_volume(),
|
data_drive=self._ensure_registry_volume(),
|
||||||
)
|
)
|
||||||
# Push the host-canonical signing key (the init waits for it before
|
# Push the host-canonical signing key (the init waits for it before
|
||||||
# starting the control plane). The host token file stays the single
|
# starting the control plane). It comes through the shared provisioning
|
||||||
# source of truth, so a co-running docker/macOS control plane keeps
|
# contract (#476) — the same host token file every backend uses, so a
|
||||||
# working; the guest verifies tokens with the same key the CLI signs from.
|
# co-running docker/macOS control plane keeps working and the guest
|
||||||
|
# verifies tokens with the same key the CLI signs from; fail-closed, so
|
||||||
|
# the guest is never handed an empty key that would run it OPEN.
|
||||||
infra_vm.push_secret(
|
infra_vm.push_secret(
|
||||||
vm, host_orchestrator_token(), infra_vm._GUEST_SIGNING_KEY_PATH,
|
vm, self.control_plane_key(), infra_vm._GUEST_SIGNING_KEY_PATH,
|
||||||
"the control-plane signing key to the orchestrator VM "
|
"the control-plane signing key to the orchestrator VM "
|
||||||
"(its control plane will not start)",
|
"(its control plane will not start)",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from ... import resources
|
||||||
from . import netpool
|
from . import netpool
|
||||||
from . import util
|
from . import util
|
||||||
|
|
||||||
@@ -42,13 +43,13 @@ def _has_systemd() -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def _module_path() -> str:
|
def _module_path() -> str:
|
||||||
"""Absolute path to the importable NixOS module in this checkout."""
|
"""Absolute path to the importable NixOS module (checkout or wheel)."""
|
||||||
return str(Path(__file__).resolve().parents[3] / "nix" / "firecracker-netpool.nix")
|
return str(resources.nix_netpool_module())
|
||||||
|
|
||||||
|
|
||||||
def _script_path() -> str:
|
def _script_path() -> str:
|
||||||
"""Absolute path to the bundled bring-up script in this checkout."""
|
"""Absolute path to the bundled bring-up script (checkout or wheel)."""
|
||||||
return str(Path(__file__).resolve().parents[3] / "scripts" / "firecracker-netpool.sh")
|
return str(resources.netpool_script())
|
||||||
|
|
||||||
|
|
||||||
def _print_prereqs() -> None:
|
def _print_prereqs() -> None:
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ from ...paths import (
|
|||||||
ORCHESTRATOR_AUTH_JWT_ENV,
|
ORCHESTRATOR_AUTH_JWT_ENV,
|
||||||
host_gateway_ca_dir,
|
host_gateway_ca_dir,
|
||||||
)
|
)
|
||||||
|
from ... import resources
|
||||||
from .. import util as backend_util
|
from .. import util as backend_util
|
||||||
from . import util as container_mod
|
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")
|
GATEWAY_IMAGE = os.environ.get("BOT_BOTTLE_GATEWAY_IMAGE", "bot-bottle-gateway:latest")
|
||||||
|
|
||||||
_REPO_ROOT = Path(__file__).resolve().parents[3]
|
|
||||||
|
|
||||||
|
|
||||||
def ensure_networks(
|
def ensure_networks(
|
||||||
network: str = GATEWAY_NETWORK,
|
network: str = GATEWAY_NETWORK,
|
||||||
@@ -84,14 +83,16 @@ class MacosGateway(Gateway):
|
|||||||
network: str = GATEWAY_NETWORK,
|
network: str = GATEWAY_NETWORK,
|
||||||
egress_network: str = GATEWAY_EGRESS_NETWORK,
|
egress_network: str = GATEWAY_EGRESS_NETWORK,
|
||||||
control_network: str = CONTROL_NETWORK,
|
control_network: str = CONTROL_NETWORK,
|
||||||
repo_root: Path = _REPO_ROOT,
|
repo_root: Path | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.image_ref = image_ref
|
self.image_ref = image_ref
|
||||||
self.name = name
|
self.name = name
|
||||||
self.network = network
|
self.network = network
|
||||||
self.egress_network = egress_network
|
self.egress_network = egress_network
|
||||||
self.control_network = control_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
|
# Set by `connect_to_orchestrator`: the URL the daemons resolve policy
|
||||||
# against + the pre-minted `gateway` token they present. The gateway
|
# against + the pre-minted `gateway` token they present. The gateway
|
||||||
# never mints, so it never holds the signing key (#469).
|
# never mints, so it never holds the signing key (#469).
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from ... import resources
|
||||||
from ...orchestrator.lifecycle import (
|
from ...orchestrator.lifecycle import (
|
||||||
DEFAULT_PORT,
|
DEFAULT_PORT,
|
||||||
DEFAULT_STARTUP_TIMEOUT_SECONDS,
|
DEFAULT_STARTUP_TIMEOUT_SECONDS,
|
||||||
@@ -53,8 +54,6 @@ from .orchestrator import (
|
|||||||
# still import it (probe / reprovision attribute against the gateway).
|
# still import it (probe / reprovision attribute against the gateway).
|
||||||
INFRA_NAME = GATEWAY_NAME
|
INFRA_NAME = GATEWAY_NAME
|
||||||
|
|
||||||
_REPO_ROOT = Path(__file__).resolve().parents[3]
|
|
||||||
|
|
||||||
|
|
||||||
class MacosInfraService(InfraService):
|
class MacosInfraService(InfraService):
|
||||||
"""Composes the per-host orchestrator + gateway containers. Callers use
|
"""Composes the per-host orchestrator + gateway containers. Callers use
|
||||||
@@ -70,7 +69,7 @@ class MacosInfraService(InfraService):
|
|||||||
control_network: str = CONTROL_NETWORK,
|
control_network: str = CONTROL_NETWORK,
|
||||||
gateway_image: str = GATEWAY_IMAGE,
|
gateway_image: str = GATEWAY_IMAGE,
|
||||||
orchestrator_image: str = ORCHESTRATOR_IMAGE,
|
orchestrator_image: str = ORCHESTRATOR_IMAGE,
|
||||||
repo_root: Path = _REPO_ROOT,
|
repo_root: Path | None = None,
|
||||||
orchestrator_name: str = ORCHESTRATOR_NAME,
|
orchestrator_name: str = ORCHESTRATOR_NAME,
|
||||||
gateway_name: str = INFRA_NAME,
|
gateway_name: str = INFRA_NAME,
|
||||||
db_volume: str = ORCHESTRATOR_DB_VOLUME,
|
db_volume: str = ORCHESTRATOR_DB_VOLUME,
|
||||||
@@ -81,7 +80,9 @@ class MacosInfraService(InfraService):
|
|||||||
self.control_network = control_network
|
self.control_network = control_network
|
||||||
self.gateway_image = gateway_image
|
self.gateway_image = gateway_image
|
||||||
self.orchestrator_image = orchestrator_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._orchestrator_name = orchestrator_name
|
||||||
self._gateway_name = gateway_name
|
self._gateway_name = gateway_name
|
||||||
self._db_volume = db_volume
|
self._db_volume = db_volume
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ import dataclasses
|
|||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
from contextlib import ExitStack, contextmanager
|
from contextlib import ExitStack, contextmanager
|
||||||
from pathlib import Path
|
|
||||||
from typing import Callable, Generator
|
from typing import Callable, Generator
|
||||||
|
|
||||||
from ...bottle_state import (
|
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 ...gateway.git_gate.http_backend import DEFAULT_PORT as _GIT_HTTP_PORT
|
||||||
from ...image_cache import check_stale
|
from ...image_cache import check_stale
|
||||||
from ...log import die, info, warn
|
from ...log import die, info, warn
|
||||||
|
from ... import resources
|
||||||
from .. import BottleImages
|
from .. import BottleImages
|
||||||
from ...supervisor.types import SUPERVISE_PORT
|
from ...supervisor.types import SUPERVISE_PORT
|
||||||
from ..docker.egress import EGRESS_PORT
|
from ..docker.egress import EGRESS_PORT
|
||||||
@@ -71,7 +71,6 @@ from .consolidated_launch import (
|
|||||||
deprovision_consolidated,
|
deprovision_consolidated,
|
||||||
)
|
)
|
||||||
|
|
||||||
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
|
|
||||||
_AGENT_SLEEP_SECONDS = "2147483647"
|
_AGENT_SLEEP_SECONDS = "2147483647"
|
||||||
|
|
||||||
|
|
||||||
@@ -94,7 +93,7 @@ def _agent_image(plan: MacosContainerBottlePlan) -> str:
|
|||||||
)
|
)
|
||||||
info(f"using cached agent image {plan.image!r}")
|
info(f"using cached agent image {plan.image!r}")
|
||||||
return plan.image
|
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
|
return plan.image
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -18,10 +18,8 @@ import urllib.request
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from ... import log
|
from ... import log
|
||||||
from ...paths import (
|
from ... import resources
|
||||||
ORCHESTRATOR_TOKEN_ENV,
|
from ...paths import ORCHESTRATOR_TOKEN_ENV
|
||||||
host_orchestrator_token,
|
|
||||||
)
|
|
||||||
from ...orchestrator.lifecycle import (
|
from ...orchestrator.lifecycle import (
|
||||||
DEFAULT_HEALTH_TIMEOUT_SECONDS,
|
DEFAULT_HEALTH_TIMEOUT_SECONDS,
|
||||||
DEFAULT_PORT,
|
DEFAULT_PORT,
|
||||||
@@ -48,7 +46,6 @@ _DB_ROOT_IN_CONTAINER = "/var/lib/bot-bottle"
|
|||||||
_SRC_IN_CONTAINER = "/bot-bottle-src"
|
_SRC_IN_CONTAINER = "/bot-bottle-src"
|
||||||
|
|
||||||
_HEALTH_POLL_SECONDS = 0.25
|
_HEALTH_POLL_SECONDS = 0.25
|
||||||
_REPO_ROOT = Path(__file__).resolve().parents[3]
|
|
||||||
|
|
||||||
|
|
||||||
class MacosOrchestrator(Orchestrator):
|
class MacosOrchestrator(Orchestrator):
|
||||||
@@ -64,7 +61,7 @@ class MacosOrchestrator(Orchestrator):
|
|||||||
label: str = ORCHESTRATOR_LABEL,
|
label: str = ORCHESTRATOR_LABEL,
|
||||||
port: int = DEFAULT_PORT,
|
port: int = DEFAULT_PORT,
|
||||||
control_network: str = CONTROL_NETWORK,
|
control_network: str = CONTROL_NETWORK,
|
||||||
repo_root: Path = _REPO_ROOT,
|
repo_root: Path | None = None,
|
||||||
db_volume: str = ORCHESTRATOR_DB_VOLUME,
|
db_volume: str = ORCHESTRATOR_DB_VOLUME,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.image_ref = image_ref
|
self.image_ref = image_ref
|
||||||
@@ -72,7 +69,9 @@ class MacosOrchestrator(Orchestrator):
|
|||||||
self.label = label
|
self.label = label
|
||||||
self.port = port
|
self.port = port
|
||||||
self.control_network = control_network
|
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
|
self._db_volume = db_volume
|
||||||
|
|
||||||
def url(self) -> str:
|
def url(self) -> str:
|
||||||
@@ -134,7 +133,9 @@ class MacosOrchestrator(Orchestrator):
|
|||||||
|
|
||||||
def _run_container(self, current_hash: str) -> None:
|
def _run_container(self, current_hash: str) -> None:
|
||||||
container_mod.force_remove_container(self.name)
|
container_mod.force_remove_container(self.name)
|
||||||
_signing_key = host_orchestrator_token()
|
# The signing key comes through the shared provisioning contract (#476),
|
||||||
|
# which fail-closes rather than yield an empty key that would run OPEN.
|
||||||
|
_signing_key = self.control_plane_key()
|
||||||
argv = [
|
argv = [
|
||||||
"container", "run", "--detach",
|
"container", "run", "--detach",
|
||||||
"--name", self.name,
|
"--name", self.name,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ _HANDLERS: dict[str, str] = {
|
|||||||
"backend": "backend:cmd_backend",
|
"backend": "backend:cmd_backend",
|
||||||
"cleanup": "cleanup:cmd_cleanup",
|
"cleanup": "cleanup:cmd_cleanup",
|
||||||
"commit": "commit:cmd_commit",
|
"commit": "commit:cmd_commit",
|
||||||
|
"doctor": "doctor:cmd_doctor",
|
||||||
"edit": "edit:cmd_edit",
|
"edit": "edit:cmd_edit",
|
||||||
"help": "help:cmd_help",
|
"help": "help:cmd_help",
|
||||||
"init": "init:cmd_init",
|
"init": "init:cmd_init",
|
||||||
@@ -53,6 +54,6 @@ COMMANDS = {name: _lazy(spec) for name, spec in _HANDLERS.items()}
|
|||||||
# gating it on the schema breaks preflight on a fresh CI runner where stdin
|
# gating it on the schema breaks preflight on a fresh CI runner where stdin
|
||||||
# isn't a TTY and the migration prompt can't be answered. `help` and `login`
|
# isn't a TTY and the migration prompt can't be answered. `help` and `login`
|
||||||
# likewise never touch the store.
|
# likewise never touch the store.
|
||||||
NO_MIGRATION_COMMANDS = frozenset({"backend", "help", "login"})
|
NO_MIGRATION_COMMANDS = frozenset({"backend", "doctor", "help", "login"})
|
||||||
|
|
||||||
__all__ = ["COMMANDS", "NO_MIGRATION_COMMANDS"]
|
__all__ = ["COMMANDS", "NO_MIGRATION_COMMANDS"]
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
"""`doctor` CLI command — validate host prerequisites for running
|
||||||
|
bot-bottle and report what's ready.
|
||||||
|
|
||||||
|
Fails (non-zero exit) only on the two hard requirements: a new-enough
|
||||||
|
Python and at least one backend that is *ready* (passes its full status
|
||||||
|
checks, so `start` can actually work). The config directory is a soft
|
||||||
|
check — `install.sh` creates it, but a missing one only warrants a note,
|
||||||
|
not a failure, since `start` provisions what it needs on first run.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from ...backend import is_backend_ready, known_backend_names
|
||||||
|
from ..constants import PROG
|
||||||
|
|
||||||
|
MIN_PYTHON = (3, 11)
|
||||||
|
CONFIG_DIR = ".bot-bottle"
|
||||||
|
|
||||||
|
|
||||||
|
def _ok(label: str, detail: str) -> None:
|
||||||
|
print(f"ok: {label}: {detail}")
|
||||||
|
|
||||||
|
|
||||||
|
def _warn(label: str, detail: str) -> None:
|
||||||
|
print(f"warn: {label}: {detail}")
|
||||||
|
|
||||||
|
|
||||||
|
def _fail(label: str, detail: str) -> None:
|
||||||
|
print(f"fail: {label}: {detail}")
|
||||||
|
|
||||||
|
|
||||||
|
def _check_python() -> bool:
|
||||||
|
v = sys.version_info
|
||||||
|
detail = f"{v.major}.{v.minor}.{v.micro}"
|
||||||
|
if (v.major, v.minor) >= MIN_PYTHON:
|
||||||
|
_ok("python", detail)
|
||||||
|
return True
|
||||||
|
_fail("python", f"{detail}; need {MIN_PYTHON[0]}.{MIN_PYTHON[1]} or newer")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _check_backends() -> bool:
|
||||||
|
"""At least one backend must be *ready* to run a bottle — i.e. pass its
|
||||||
|
full status() checks (daemon reachable, network pool present, KVM usable),
|
||||||
|
not merely have a binary on PATH. A binary-only check would report `ok`
|
||||||
|
on a host with a stopped Docker daemon or a half-configured Firecracker,
|
||||||
|
where `start` still can't work. Each not-ready backend prints its own
|
||||||
|
diagnostics (quiet=False) so the operator sees exactly what's missing."""
|
||||||
|
ready = []
|
||||||
|
for name in known_backend_names():
|
||||||
|
if is_backend_ready(name, quiet=False):
|
||||||
|
_ok("backend", f"{name}: ready")
|
||||||
|
ready.append(name)
|
||||||
|
else:
|
||||||
|
_warn("backend", f"{name}: not ready (see diagnostics above)")
|
||||||
|
if ready:
|
||||||
|
return True
|
||||||
|
_fail(
|
||||||
|
"backend",
|
||||||
|
"no backend is ready to run a bottle; start Docker, or finish "
|
||||||
|
"Apple Container (macOS) / Firecracker (Linux) setup",
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _check_config_dir() -> None:
|
||||||
|
config = Path.home() / CONFIG_DIR
|
||||||
|
if config.is_dir():
|
||||||
|
_ok("config", str(config))
|
||||||
|
else:
|
||||||
|
_warn("config", f"{config} does not exist yet (created on first use)")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_doctor(argv: list[str]) -> int:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog=f"{PROG} doctor",
|
||||||
|
description="Check host prerequisites for running bot-bottle.",
|
||||||
|
)
|
||||||
|
parser.parse_args(argv)
|
||||||
|
|
||||||
|
# Hard requirements gate the exit code; the config note is advisory.
|
||||||
|
required = [_check_python(), _check_backends()]
|
||||||
|
_check_config_dir()
|
||||||
|
return 0 if all(required) else 1
|
||||||
@@ -25,6 +25,7 @@ def cmd_help(argv: list[str] | None = None) -> int:
|
|||||||
w(" backend set up / check / undo a backend's host prerequisites (setup|status|teardown)\n")
|
w(" backend set up / check / undo a backend's host prerequisites (setup|status|teardown)\n")
|
||||||
w(" cleanup stop and remove all active bot-bottle containers\n")
|
w(" cleanup stop and remove all active bot-bottle containers\n")
|
||||||
w(" commit snapshot a running bottle's container state to a Docker image\n")
|
w(" commit snapshot a running bottle's container state to a Docker image\n")
|
||||||
|
w(" doctor check host prerequisites (Python, backend, config dir)\n")
|
||||||
w(" edit open an agent in vim for editing\n")
|
w(" edit open an agent in vim for editing\n")
|
||||||
w(" help show this command list\n")
|
w(" help show this command list\n")
|
||||||
w(" init interactively create a new agent and add it to bot-bottle.json\n")
|
w(" init interactively create a new agent and add it to bot-bottle.json\n")
|
||||||
|
|||||||
@@ -62,7 +62,6 @@ GATEWAY_CA_GLOB = "mitmproxy-ca*"
|
|||||||
# that lands. Env override matches the backend's BOT_BOTTLE_GATEWAY_IMAGE.
|
# 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_IMAGE = os.environ.get("BOT_BOTTLE_GATEWAY_IMAGE", "bot-bottle-gateway:latest")
|
||||||
GATEWAY_DOCKERFILE = "Dockerfile.gateway"
|
GATEWAY_DOCKERFILE = "Dockerfile.gateway"
|
||||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
||||||
|
|
||||||
|
|
||||||
def rotate_gateway_ca(ca_dir: Path | None = None) -> list[Path]:
|
def rotate_gateway_ca(ca_dir: Path | None = None) -> list[Path]:
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ import urllib.request
|
|||||||
from collections.abc import Iterable
|
from collections.abc import Iterable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from ..orchestrator_auth import ROLE_CLI, mint
|
from ..orchestrator_auth import ROLE_CLI
|
||||||
from ..paths import host_orchestrator_token
|
from ..trust_domain import CONTROL_PLANE
|
||||||
from .server import ORCHESTRATOR_AUTH_HEADER
|
from .server import ORCHESTRATOR_AUTH_HEADER
|
||||||
|
|
||||||
DEFAULT_TIMEOUT_SECONDS = 5.0
|
DEFAULT_TIMEOUT_SECONDS = 5.0
|
||||||
@@ -32,7 +32,7 @@ def _host_auth_token() -> str:
|
|||||||
"" means 'send no auth header' — correct against an open (unconfigured)
|
"" means 'send no auth header' — correct against an open (unconfigured)
|
||||||
control plane, and harmlessly rejected by a secured one."""
|
control plane, and harmlessly rejected by a secured one."""
|
||||||
try:
|
try:
|
||||||
return mint(ROLE_CLI, host_orchestrator_token())
|
return CONTROL_PLANE.mint(ROLE_CLI)
|
||||||
except (OSError, ValueError):
|
except (OSError, ValueError):
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|||||||
@@ -21,8 +21,7 @@ import urllib.error
|
|||||||
import urllib.request
|
import urllib.request
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from ..orchestrator_auth import ROLE_GATEWAY, mint
|
from ..trust_domain import ControlPlaneProvisioning
|
||||||
from ..paths import host_orchestrator_token
|
|
||||||
|
|
||||||
DEFAULT_PORT = 8099
|
DEFAULT_PORT = 8099
|
||||||
DEFAULT_STARTUP_TIMEOUT_SECONDS = 45.0
|
DEFAULT_STARTUP_TIMEOUT_SECONDS = 45.0
|
||||||
@@ -58,6 +57,12 @@ class Orchestrator(abc.ABC):
|
|||||||
so it — not the gateway — mints the gateway's role-scoped token.
|
so it — not the gateway — mints the gateway's role-scoped token.
|
||||||
Backend-neutral."""
|
Backend-neutral."""
|
||||||
|
|
||||||
|
# The shared control-plane auth provisioning contract (#476). Every backend
|
||||||
|
# gets its signing key + gateway token through this one seam rather than
|
||||||
|
# re-deriving the wiring; it is fail-closed for every backend — the
|
||||||
|
# orchestrator never starts without its signing key.
|
||||||
|
provisioning: ControlPlaneProvisioning = ControlPlaneProvisioning()
|
||||||
|
|
||||||
def ensure_built(self) -> None:
|
def ensure_built(self) -> None:
|
||||||
"""Ensure the orchestrator's image / rootfs exists, building it if
|
"""Ensure the orchestrator's image / rootfs exists, building it if
|
||||||
needed. Default: nothing to build (e.g. a pre-pulled image). Call before
|
needed. Default: nothing to build (e.g. a pre-pulled image). Call before
|
||||||
@@ -105,9 +110,17 @@ class Orchestrator(abc.ABC):
|
|||||||
def mint_gateway_token(self) -> str:
|
def mint_gateway_token(self) -> str:
|
||||||
"""Mint a role-scoped `gateway` JWT from the host signing key for the
|
"""Mint a role-scoped `gateway` JWT from the host signing key for the
|
||||||
gateway to present. The orchestrator holds the key; the gateway never
|
gateway to present. The orchestrator holds the key; the gateway never
|
||||||
does (#469). Backend-neutral — the same host token file is the single
|
does (#469). Routed through the shared provisioning contract (#476), so
|
||||||
source of truth across backends."""
|
the same host token file is the single source of truth across backends."""
|
||||||
return mint(ROLE_GATEWAY, host_orchestrator_token())
|
return self.provisioning.gateway_token()
|
||||||
|
|
||||||
|
def control_plane_key(self) -> str:
|
||||||
|
"""The raw signing key the control-plane *process* must receive — the ONE
|
||||||
|
place a backend obtains it (docker/macOS inject it as `key_env`;
|
||||||
|
firecracker pushes it to the guest). Fail-closed via the provisioning
|
||||||
|
contract: it raises rather than yield an empty key that would run the
|
||||||
|
server OPEN (#476)."""
|
||||||
|
return self.provisioning.orchestrator_key()
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -117,4 +130,5 @@ __all__ = [
|
|||||||
"OrchestratorStartError",
|
"OrchestratorStartError",
|
||||||
"source_hash",
|
"source_hash",
|
||||||
"Orchestrator",
|
"Orchestrator",
|
||||||
|
"ControlPlaneProvisioning",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -63,8 +63,8 @@ import sys
|
|||||||
import typing
|
import typing
|
||||||
from urllib.parse import urlsplit
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
from ..orchestrator_auth import ROLE_CLI, ROLES, verify
|
from ..orchestrator_auth import ROLE_CLI, ROLES
|
||||||
from ..paths import ORCHESTRATOR_TOKEN_ENV
|
from ..trust_domain import CONTROL_PLANE
|
||||||
from ..supervisor.types import TOOLS
|
from ..supervisor.types import TOOLS
|
||||||
from .service import OrchestratorCore
|
from .service import OrchestratorCore
|
||||||
|
|
||||||
@@ -413,11 +413,13 @@ class OrchestratorServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
|
|||||||
|
|
||||||
def __init__(self, address: tuple[str, int], orchestrator: OrchestratorCore) -> None:
|
def __init__(self, address: tuple[str, int], orchestrator: OrchestratorCore) -> None:
|
||||||
self.orchestrator = orchestrator
|
self.orchestrator = orchestrator
|
||||||
self._signing_key = os.environ.get(ORCHESTRATOR_TOKEN_ENV, "").strip()
|
# The control-plane trust domain's signing key, as injected into THIS
|
||||||
|
# (the owning) process by the launcher (#476). Unset → open mode below.
|
||||||
|
self._signing_key = CONTROL_PLANE.key_from_env()
|
||||||
if not self._signing_key:
|
if not self._signing_key:
|
||||||
sys.stderr.write(
|
sys.stderr.write(
|
||||||
"orchestrator: WARNING — no control-plane signing key "
|
"orchestrator: WARNING — no control-plane signing key "
|
||||||
f"(${ORCHESTRATOR_TOKEN_ENV}); running WITHOUT caller "
|
f"(${CONTROL_PLANE.key_env}); running WITHOUT caller "
|
||||||
"authentication. Any client that can reach this port can drive "
|
"authentication. Any client that can reach this port can drive "
|
||||||
"it. Backends that put the control plane on an agent-reachable "
|
"it. Backends that put the control plane on an agent-reachable "
|
||||||
"network MUST set this.\n"
|
"network MUST set this.\n"
|
||||||
@@ -433,7 +435,7 @@ class OrchestratorServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
|
|||||||
role (→ per-route 401/403 in `dispatch`)."""
|
role (→ per-route 401/403 in `dispatch`)."""
|
||||||
if not self._signing_key:
|
if not self._signing_key:
|
||||||
return ROLE_CLI
|
return ROLE_CLI
|
||||||
return verify(presented, self._signing_key)
|
return CONTROL_PLANE.verify(presented, self._signing_key)
|
||||||
|
|
||||||
|
|
||||||
def make_server(
|
def make_server(
|
||||||
|
|||||||
@@ -59,12 +59,17 @@ _HEADER_SEGMENT = _b64url_encode(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def mint(role: str, secret: str) -> str:
|
def mint(role: str, secret: str, *, roles: frozenset[str] = ROLES) -> str:
|
||||||
"""A compact HS256 token asserting `role`, signed with `secret`.
|
"""A compact HS256 token asserting `role`, signed with `secret`.
|
||||||
|
|
||||||
Raises ValueError for an unknown role (mint only what the control plane will
|
`roles` is the set the signing key is allowed to sign (default: the
|
||||||
accept) or an empty signing key (an unsigned credential is never valid)."""
|
orchestrator's `{gateway, cli}`). A separate service (e.g. the host
|
||||||
if role not in ROLES:
|
controller) passes its own key + role set so its tokens can't be forged with
|
||||||
|
the orchestrator's key — see `trust_domain.py`, issues #476/#468.
|
||||||
|
|
||||||
|
Raises ValueError for a role outside `roles`, or an empty signing key (an
|
||||||
|
unsigned credential is never valid)."""
|
||||||
|
if role not in roles:
|
||||||
raise ValueError(f"unknown control-plane role {role!r}")
|
raise ValueError(f"unknown control-plane role {role!r}")
|
||||||
if not secret:
|
if not secret:
|
||||||
raise ValueError("cannot mint a control-plane token without a signing key")
|
raise ValueError("cannot mint a control-plane token without a signing key")
|
||||||
@@ -73,10 +78,11 @@ def mint(role: str, secret: str) -> str:
|
|||||||
return f"{signing_input}.{_sign(secret, signing_input)}"
|
return f"{signing_input}.{_sign(secret, signing_input)}"
|
||||||
|
|
||||||
|
|
||||||
def verify(token: str, secret: str) -> str | None:
|
def verify(token: str, secret: str, *, roles: frozenset[str] = ROLES) -> str | None:
|
||||||
"""The role a valid `token` carries, or None if it is malformed, wrongly
|
"""The role a valid `token` carries, or None if it is malformed, wrongly
|
||||||
signed, or names an unknown role. Constant-time signature check; rejects any
|
signed, or names a role outside `roles` (the verifying trust domain's set —
|
||||||
header whose alg isn't HS256 (no alg-confusion / `none`)."""
|
default `{gateway, cli}`). Constant-time signature check; rejects any header
|
||||||
|
whose alg isn't HS256 (no alg-confusion / `none`)."""
|
||||||
if not token or not secret:
|
if not token or not secret:
|
||||||
return None
|
return None
|
||||||
parts = token.split(".")
|
parts = token.split(".")
|
||||||
@@ -94,7 +100,7 @@ def verify(token: str, secret: str) -> str | None:
|
|||||||
if not isinstance(header, dict) or header.get("alg") != _ALG:
|
if not isinstance(header, dict) or header.get("alg") != _ALG:
|
||||||
return None
|
return None
|
||||||
role = payload.get("role") if isinstance(payload, dict) else None
|
role = payload.get("role") if isinstance(payload, dict) else None
|
||||||
return role if isinstance(role, str) and role in ROLES else None
|
return role if isinstance(role, str) and role in roles else None
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["ROLE_GATEWAY", "ROLE_CLI", "ROLES", "mint", "verify"]
|
__all__ = ["ROLE_GATEWAY", "ROLE_CLI", "ROLES", "mint", "verify"]
|
||||||
|
|||||||
+19
-9
@@ -97,16 +97,17 @@ def host_gateway_ca_dir() -> Path:
|
|||||||
return ca_dir
|
return ca_dir
|
||||||
|
|
||||||
|
|
||||||
def host_orchestrator_token() -> str:
|
def host_signing_key(filename: str) -> str:
|
||||||
"""The per-host control-plane secret, minted (256-bit, url-safe) and
|
"""A per-host signing key at `<root>/<filename>`, minted (256-bit, url-safe)
|
||||||
persisted 0600 on first use, then reused.
|
and persisted 0600 on first use, then reused.
|
||||||
|
|
||||||
This is the shared secret the launchers inject into the control-plane and
|
The generic form of `host_orchestrator_token()`: each service names its own
|
||||||
gateway containers and that the host CLI presents on every call. It is a
|
key file (`trust_domain.py`), so the orchestrator and a separate service like
|
||||||
*host* artifact — the file lives under the root the agent never mounts, and
|
the host controller (#468) get distinct keys neither can read. It is a *host*
|
||||||
the env var is set only on the trusted containers — so reading it here is
|
artifact — the file lives under the root the agent never mounts, and its value
|
||||||
safe on the host launch path but the value never reaches a bottle."""
|
is injected only into the trusted control-plane process — so reading it here
|
||||||
path = bot_bottle_root() / ORCHESTRATOR_TOKEN_FILENAME
|
is safe on the launch path but the value never reaches a bottle."""
|
||||||
|
path = bot_bottle_root() / filename
|
||||||
try:
|
try:
|
||||||
existing = path.read_text().strip()
|
existing = path.read_text().strip()
|
||||||
if existing:
|
if existing:
|
||||||
@@ -128,6 +129,14 @@ def host_orchestrator_token() -> str:
|
|||||||
return token
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def host_orchestrator_token() -> str:
|
||||||
|
"""The per-host control-plane signing key — the host-canonical key the
|
||||||
|
launchers inject into the control-plane process and the host CLI mints its
|
||||||
|
own `cli` token from. The `control-plane` trust domain's specialization of
|
||||||
|
`host_signing_key()`."""
|
||||||
|
return host_signing_key(ORCHESTRATOR_TOKEN_FILENAME)
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"HOST_DB_FILENAME",
|
"HOST_DB_FILENAME",
|
||||||
"ORCHESTRATOR_TOKEN_FILENAME",
|
"ORCHESTRATOR_TOKEN_FILENAME",
|
||||||
@@ -138,5 +147,6 @@ __all__ = [
|
|||||||
"host_db_path",
|
"host_db_path",
|
||||||
"host_db_dir",
|
"host_db_dir",
|
||||||
"host_gateway_ca_dir",
|
"host_gateway_ca_dir",
|
||||||
|
"host_signing_key",
|
||||||
"host_orchestrator_token",
|
"host_orchestrator_token",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
"""Per-service control-plane signing keys (issue #476).
|
||||||
|
|
||||||
|
A `TrustDomain` is one service's signing material: its host-canonical key file,
|
||||||
|
the roles that key may sign, and the env vars its key and a pre-minted token ride
|
||||||
|
in. Scoping `mint`/`verify` to a domain's roles keeps one service's key from
|
||||||
|
signing (or accepting) another service's tokens.
|
||||||
|
|
||||||
|
Today there is one domain, `CONTROL_PLANE` — the orchestrator's key (roles
|
||||||
|
`{gateway, cli}`): the orchestrator holds it and mints the gateway's and CLI's
|
||||||
|
tokens. The host controller (#468) will add a **second** domain with its own key
|
||||||
|
the orchestrator never holds. That is the point: the host controller starts and
|
||||||
|
stops the orchestrator, so the orchestrator must not be able to mint the
|
||||||
|
credentials it uses to talk to it. Adding a `host` role to `CONTROL_PLANE`
|
||||||
|
instead would defeat that — the orchestrator holds that key, so it could forge
|
||||||
|
`host` tokens.
|
||||||
|
|
||||||
|
`ControlPlaneProvisioning` is the one seam every backend launcher uses to get the
|
||||||
|
orchestrator its key and the gateway its token, instead of re-deriving that
|
||||||
|
wiring per backend (the bug class behind PR #471 — see
|
||||||
|
`docs/prds/prd-new-control-plane-auth-provisioning.md`).
|
||||||
|
|
||||||
|
Stdlib-only: the HMAC lives in `orchestrator_auth`, the key file in `paths`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from . import orchestrator_auth
|
||||||
|
from .orchestrator_auth import ROLE_GATEWAY
|
||||||
|
from .paths import (
|
||||||
|
ORCHESTRATOR_AUTH_JWT_ENV,
|
||||||
|
ORCHESTRATOR_TOKEN_ENV,
|
||||||
|
ORCHESTRATOR_TOKEN_FILENAME,
|
||||||
|
host_signing_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ProvisioningError(RuntimeError):
|
||||||
|
"""A control-plane auth invariant would be violated (e.g. starting the
|
||||||
|
orchestrator without its signing key — which would run OPEN)."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TrustDomain:
|
||||||
|
"""One service's signing material: a host-canonical key file, the roles that
|
||||||
|
key may sign, and the env vars its key and a minted token ride in.
|
||||||
|
|
||||||
|
The service that *owns* the domain (e.g. the orchestrator) receives the raw
|
||||||
|
key via `key_env`; a delegate (e.g. the gateway) receives only a pre-minted,
|
||||||
|
role-scoped token via `token_env` it cannot rewrite. `mint`/`verify` are
|
||||||
|
scoped to `roles`, so this service's key can neither sign nor accept another
|
||||||
|
service's role."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
key_filename: str
|
||||||
|
roles: frozenset[str]
|
||||||
|
key_env: str
|
||||||
|
token_env: str
|
||||||
|
|
||||||
|
def signing_key(self) -> str:
|
||||||
|
"""This service's host-canonical signing key (minted 0600 on first use).
|
||||||
|
Host-side only — the value is injected into the owning process."""
|
||||||
|
return host_signing_key(self.key_filename)
|
||||||
|
|
||||||
|
def key_from_env(self, environ: Mapping[str, str] | None = None) -> str:
|
||||||
|
"""The signing key as the owning process sees it — read from `key_env`
|
||||||
|
(default `os.environ`). "" when unset; the caller decides whether that is
|
||||||
|
fatal (`ControlPlaneProvisioning`) or the open-mode fallback
|
||||||
|
(`OrchestratorServer`)."""
|
||||||
|
env = os.environ if environ is None else environ
|
||||||
|
return env.get(self.key_env, "").strip()
|
||||||
|
|
||||||
|
def mint(self, role: str) -> str:
|
||||||
|
"""A role-scoped token for a delegate, signed with this service's key.
|
||||||
|
Raises ValueError for a role this service doesn't sign."""
|
||||||
|
if role not in self.roles:
|
||||||
|
raise ValueError(f"role {role!r} is not in trust domain {self.name!r}")
|
||||||
|
return orchestrator_auth.mint(role, self.signing_key(), roles=self.roles)
|
||||||
|
|
||||||
|
def verify(self, token: str, key: str) -> str | None:
|
||||||
|
"""The role `token` carries under `key`, or None. `key` is passed in
|
||||||
|
(not read from disk) because the verifier — the control-plane process —
|
||||||
|
holds it in `key_env`, not on disk in its guest."""
|
||||||
|
return orchestrator_auth.verify(token, key, roles=self.roles)
|
||||||
|
|
||||||
|
|
||||||
|
# The orchestrator's domain: the key the orchestrator (and host CLI) holds, the
|
||||||
|
# `gateway` token it mints for the data plane, and the `cli` token the CLI mints
|
||||||
|
# for itself. #468's host controller will add a second, separate domain.
|
||||||
|
CONTROL_PLANE = TrustDomain(
|
||||||
|
name="control-plane",
|
||||||
|
key_filename=ORCHESTRATOR_TOKEN_FILENAME,
|
||||||
|
roles=orchestrator_auth.ROLES,
|
||||||
|
key_env=ORCHESTRATOR_TOKEN_ENV,
|
||||||
|
token_env=ORCHESTRATOR_AUTH_JWT_ENV,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ControlPlaneProvisioning:
|
||||||
|
"""The one seam every backend launcher uses to provision control-plane auth,
|
||||||
|
instead of re-deriving the four invariants that each cost a PR #471 review
|
||||||
|
round: the orchestrator gets the raw key (`orchestrator_key`), the gateway
|
||||||
|
gets a minted `gateway` token (`gateway_token`), the host CLI mints its own
|
||||||
|
`cli` token from the same host-canonical key, and the orchestrator never
|
||||||
|
starts open."""
|
||||||
|
|
||||||
|
domain: TrustDomain = CONTROL_PLANE
|
||||||
|
|
||||||
|
def orchestrator_key(self) -> str:
|
||||||
|
"""The raw signing key the orchestrator process must receive (carry it in
|
||||||
|
`domain.key_env`). Fail-closed: raises rather than return "", since an
|
||||||
|
empty key runs the server open — and being on a separate host does not
|
||||||
|
stop the gateway from reaching the control plane (it must, for
|
||||||
|
`/resolve`), so an open orchestrator would treat that gateway as `cli`."""
|
||||||
|
key = self.domain.signing_key()
|
||||||
|
if not key:
|
||||||
|
raise ProvisioningError(
|
||||||
|
f"refusing to start the {self.domain.name} orchestrator without "
|
||||||
|
"a signing key: an open orchestrator authenticates no one and "
|
||||||
|
"grants every caller that reaches it full `cli` (#476)"
|
||||||
|
)
|
||||||
|
return key
|
||||||
|
|
||||||
|
def gateway_token(self) -> str:
|
||||||
|
"""The `gateway`-role token the gateway receives (carry it in
|
||||||
|
`domain.token_env`) — minted from the key, never the key itself, so a
|
||||||
|
compromised gateway cannot forge a `cli` token."""
|
||||||
|
return self.domain.mint(ROLE_GATEWAY)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ProvisioningError",
|
||||||
|
"TrustDomain",
|
||||||
|
"CONTROL_PLANE",
|
||||||
|
"ControlPlaneProvisioning",
|
||||||
|
]
|
||||||
+12
-1
@@ -2,11 +2,22 @@
|
|||||||
|
|
||||||
The test workflow lives at [`.gitea/workflows/test.yml`](../.gitea/workflows/test.yml).
|
The test workflow lives at [`.gitea/workflows/test.yml`](../.gitea/workflows/test.yml).
|
||||||
It runs the unit suite plus one integration job per backend
|
It runs the unit suite plus one integration job per backend
|
||||||
(`integration-docker`, `integration-firecracker`) on:
|
(`integration-docker`, `integration-firecracker`, `integration-macos`) on:
|
||||||
|
|
||||||
- every push to a branch with an open pull request, and
|
- every push to a branch with an open pull request, and
|
||||||
- every push to `main`.
|
- every push to `main`.
|
||||||
|
|
||||||
|
`integration-macos` is the exception: it is **advisory**, running only on
|
||||||
|
`workflow_dispatch` (manual dispatch), never on push or pull requests. It targets the
|
||||||
|
Apple Container backend on a self-hosted macOS runner (label `macos`,
|
||||||
|
registered in host mode — Apple Container can't run in a Linux container, so it
|
||||||
|
can't reuse the `kvm` runner). A single non-redundant laptop must not be able
|
||||||
|
to block a PR merge, so the job stays out of the `coverage` job's `needs` and
|
||||||
|
its coverage never feeds the diff-coverage gate. Because the infra container is
|
||||||
|
a singleton (`bot-bottle-mac-infra`), the job declares a `concurrency` group
|
||||||
|
and tears the container down on exit; keep runner concurrency at 1. See the
|
||||||
|
README "macOS Apple Container" CI note for runner provisioning.
|
||||||
|
|
||||||
Each integration job selects its backend via `BOT_BOTTLE_BACKEND` and
|
Each integration job selects its backend via `BOT_BOTTLE_BACKEND` and
|
||||||
runs a **preflight** (`./cli.py backend status --backend=<name>`) that
|
runs a **preflight** (`./cli.py backend status --backend=<name>`) that
|
||||||
prints a clear per-check readiness summary and fails the job when the
|
prints a clear per-check readiness summary and fails the job when the
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# PRD prd-new: Per-service signing keys for control-plane auth
|
||||||
|
|
||||||
|
- **Status:** Draft
|
||||||
|
- **Author:** claude
|
||||||
|
- **Created:** 2026-07-26
|
||||||
|
- **Issue:** #476
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Provision control-plane signing keys **per service**, through one shared seam, so
|
||||||
|
no service can mint another's credentials. Concretely: the orchestrator holds the
|
||||||
|
control-plane key and mints the gateway's and CLI's tokens; the host controller
|
||||||
|
(#468, next) gets a **separate** key the orchestrator never holds — so the
|
||||||
|
orchestrator cannot forge the credentials it uses to talk to the host controller
|
||||||
|
that starts and stops it. Landing this seam also retires the per-backend
|
||||||
|
provisioning duplication that made PR #471 take three review rounds.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
**1. The orchestrator could forge host-controller credentials.** The
|
||||||
|
orchestrator's key signs roles `{gateway, cli}`. The tempting way to add the host
|
||||||
|
controller (#468) is a third role, `host`, on that same key. But then the
|
||||||
|
orchestrator — which holds the key — can mint `host` tokens, and the host
|
||||||
|
controller, which owns the orchestrator's lifecycle, must not trust anything the
|
||||||
|
orchestrator can mint. The two services need separate keys.
|
||||||
|
|
||||||
|
**2. Every backend provisioned auth by hand.** Each launcher (docker
|
||||||
|
gateway/infra, macOS infra, firecracker infra) re-derived how to generate the
|
||||||
|
signing key, scope it to the orchestrator, mint the gateway JWT, and keep the
|
||||||
|
host key file canonical. All three PR #471 High-severity findings were this one
|
||||||
|
integration bug in different launchers: the data plane got the full `cli` token;
|
||||||
|
the firecracker control plane ran open; the firecracker guest clobbered the host
|
||||||
|
key.
|
||||||
|
|
||||||
|
## Goals / Success Criteria
|
||||||
|
|
||||||
|
- The orchestrator and the host controller sign with **different** keys; neither
|
||||||
|
can mint the other's tokens. (This PR provisions the orchestrator's key and
|
||||||
|
leaves a drop-in seam for the host controller's.)
|
||||||
|
- One shared provisioning seam every backend uses — a new backend or daemon
|
||||||
|
implements it instead of rediscovering these four invariants:
|
||||||
|
1. the signing key is host-canonical: a guest is handed it, never generates or
|
||||||
|
overwrites it;
|
||||||
|
2. only the orchestrator process gets the raw key; the gateway gets a
|
||||||
|
pre-minted `gateway` token it can't rewrite into `cli`;
|
||||||
|
3. the host CLI's `cli` token is minted from the same key, so it stays valid
|
||||||
|
across co-running backends;
|
||||||
|
4. the orchestrator never runs open — the signing key is mandatory, with no
|
||||||
|
topology opt-out (a separate host does not stop a caller from reaching the
|
||||||
|
control-plane listener, so it cannot make open mode safe).
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- The host controller itself (#468) — this only provisions the orchestrator's
|
||||||
|
key and the seam #468 plugs into.
|
||||||
|
- Rewriting the HMAC primitive: `orchestrator_auth.mint/verify` gain an optional
|
||||||
|
`roles=` arg (default unchanged) so a key can carry a different role set;
|
||||||
|
nothing else changes.
|
||||||
|
- Network topology, the plane split (#469), or the server's open-mode fallback
|
||||||
|
for tests.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
A **`TrustDomain`** is one service's signing material: its host-canonical key
|
||||||
|
file, the roles that key may sign, and the env vars its key and a pre-minted
|
||||||
|
token ride in. `mint`/`verify` are scoped to that domain's roles, so a token
|
||||||
|
signed by one service's key neither carries nor verifies another service's role.
|
||||||
|
|
||||||
|
- `CONTROL_PLANE` — the orchestrator's domain: key `orchestrator-token`, roles
|
||||||
|
`{gateway, cli}`. The orchestrator process holds the key; the gateway holds
|
||||||
|
only a minted `gateway` token; the host CLI mints its own `cli` token.
|
||||||
|
- The host controller (#468) will add a second `TrustDomain` — its own key file
|
||||||
|
and role(s) — that the orchestrator never holds.
|
||||||
|
|
||||||
|
**`ControlPlaneProvisioning`** is the seam the backends call.
|
||||||
|
`orchestrator_key()` returns the raw key for the control-plane process
|
||||||
|
(fail-closed for every backend: it raises rather than hand back an empty key that
|
||||||
|
would run the server open). `gateway_token()` mints the gateway's token. Each backend applies these through its own transport —
|
||||||
|
docker/macOS inject env vars, firecracker pushes over SSH — but none re-derives
|
||||||
|
*which* key or role.
|
||||||
|
|
||||||
|
`paths.host_signing_key(filename)` generalizes `host_orchestrator_token()` so each
|
||||||
|
domain names its own key file.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
None blocking. #468 adds its `TrustDomain` and a second
|
||||||
|
`ControlPlaneProvisioning`-shaped consumer; renaming that class to something
|
||||||
|
service-neutral is a cosmetic call to make then.
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
# PRD prd-new: Quick install script
|
||||||
|
|
||||||
|
- **Status:** Draft
|
||||||
|
- **Author:** claude
|
||||||
|
- **Created:** 2026-07-25
|
||||||
|
- **Issue:** #197
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Add a proper Python package distribution (`pyproject.toml` with a
|
||||||
|
`bot-bottle` entry point) plus a thin `install.sh` bootstrapper, so users
|
||||||
|
can install bot-bottle with a single command instead of cloning the repo
|
||||||
|
and invoking `cli.py` directly. A new `bot-bottle doctor` subcommand
|
||||||
|
verifies host prerequisites after install.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
There is currently no install path for new users. The only way to run
|
||||||
|
bot-bottle is to clone the repo and invoke `./cli.py`. This blocks any
|
||||||
|
public demo: readers want `curl | sh` or `pipx install`, not a manual
|
||||||
|
clone-and-configure flow. There is also no single command that tells a
|
||||||
|
user whether their host is actually ready to run a bottle.
|
||||||
|
|
||||||
|
## Goals / Success Criteria
|
||||||
|
|
||||||
|
- `curl -fsSL <raw-url>/install.sh | sh` leaves a working `bot-bottle`
|
||||||
|
command on PATH.
|
||||||
|
- Python-native users can install with `pipx install bot-bottle` or
|
||||||
|
`uv tool install bot-bottle` (once published) — or from a local
|
||||||
|
checkout today.
|
||||||
|
- `install.sh` validates prerequisites (Python ≥ 3.11), creates the
|
||||||
|
`~/.bot-bottle/` config tree, installs the package, and runs
|
||||||
|
`bot-bottle doctor`. It never installs Docker or a VM backend silently
|
||||||
|
and never uses `sudo`.
|
||||||
|
- `install.sh` is idempotent — safe to re-run.
|
||||||
|
- `bot-bottle doctor` reports Python version, backend *readiness*, and
|
||||||
|
config-dir presence, exiting non-zero when a hard prerequisite is unmet.
|
||||||
|
- The package keeps **zero runtime pip dependencies** (stdlib-only,
|
||||||
|
matching the existing constraint in `AGENTS.md`).
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Bundling a Python runtime or producing a standalone binary.
|
||||||
|
- Automatic Docker / VM-backend installation.
|
||||||
|
- Plugin-architecture changes (issue #197 floats a containerized-plugin
|
||||||
|
direction; that's a separate feature).
|
||||||
|
- Publishing to a package index in this PR — the package *structure* is
|
||||||
|
the deliverable; publishing is a follow-up step.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Package structure (`pyproject.toml`)
|
||||||
|
|
||||||
|
Fill out the previously-stub `pyproject.toml` with project metadata, a
|
||||||
|
console-script entry point, and package-data for the non-Python assets the
|
||||||
|
runtime reads from inside the package:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[project]
|
||||||
|
name = "bot-bottle"
|
||||||
|
version = "0.1.0"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = []
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
bot-bottle = "bot_bottle.cli:main"
|
||||||
|
```
|
||||||
|
|
||||||
|
`bot_bottle.cli:main` already exists (the `cli.py` shim calls it), so no
|
||||||
|
refactor of the entry point is needed. `package-data` ships the non-Python
|
||||||
|
assets that live *inside* the package (`egress_entrypoint.sh`, the contrib
|
||||||
|
Dockerfiles, the firecracker netpool defaults, the macos-container init
|
||||||
|
script).
|
||||||
|
|
||||||
|
### Self-contained wheel (build resources)
|
||||||
|
|
||||||
|
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. Several
|
||||||
|
modules used to locate that context by walking `__file__`'s parents to the
|
||||||
|
repo root (`_REPO_ROOT = Path(__file__)…parents[N]`) and reading
|
||||||
|
`Dockerfile.gateway`, `nix/firecracker-netpool.nix`, and
|
||||||
|
`scripts/firecracker-netpool.sh` from it. In an installed wheel the package
|
||||||
|
lives in `site-packages` with no repo root above it, so those reads fail —
|
||||||
|
`doctor` passes but `start` / backend setup breaks.
|
||||||
|
|
||||||
|
Fix: a single resolver, `bot_bottle/resources.py`.
|
||||||
|
|
||||||
|
- `build_root()` returns a directory shaped like a repo root (has
|
||||||
|
`bot_bottle/`, `pyproject.toml`, the Dockerfiles, `nix/`, `scripts/`).
|
||||||
|
In a **checkout** it's the repo root itself — unchanged behavior. From an
|
||||||
|
**installed wheel** it stages a copy under the app-data dir, keyed by a
|
||||||
|
**content digest** of the installed package + bundled resources (not the
|
||||||
|
distribution version): the installer defaults to a git branch and
|
||||||
|
`pipx install --force` while `version` stays `0.1.0`, so a version key
|
||||||
|
would reuse a previous commit's tree — the digest key re-stages instead.
|
||||||
|
Staging is concurrency-safe: a file lock serializes it, each writer builds
|
||||||
|
into a private temp dir, and the finished tree is published with an atomic
|
||||||
|
rename (never populating a shared path another process might read).
|
||||||
|
- The root-level resources are shipped inside the wheel under
|
||||||
|
`bot_bottle/_resources/` by a `setup.py` `build_py` step (kept in sync
|
||||||
|
with `resources.BUNDLED_RESOURCES`); `MANIFEST.in` includes them in the
|
||||||
|
sdist.
|
||||||
|
- Every former `_REPO_ROOT` / `_REPO_DIR` call site now derives from
|
||||||
|
`resources`: the docker/macos agent-image launch, each backend's
|
||||||
|
`orchestrator` / `gateway` / `infra` service, firecracker `infra_vm` /
|
||||||
|
`infra_artifact` / `setup`, and the shared `gateway` build context. So
|
||||||
|
checkout and wheel installs share one downstream path.
|
||||||
|
|
||||||
|
Verification: `test_resources` exercises both layouts — including the staged
|
||||||
|
wheel context, a re-stage when package content changes at the same version,
|
||||||
|
and a rebuild of a partial (crashed) stage. `test_wheel_install` builds the
|
||||||
|
wheel, installs it into an isolated venv, and asserts `bot-bottle doctor`
|
||||||
|
runs and `build_root()` produces a valid context; `build` is in
|
||||||
|
`requirements-dev.txt` so it runs in CI, and a build/install failure fails
|
||||||
|
the test (it does not skip). Running `start` end-to-end still needs a
|
||||||
|
Docker/KVM host (CI), not a source checkout.
|
||||||
|
|
||||||
|
### `install.sh`
|
||||||
|
|
||||||
|
A POSIX `sh` bootstrapper that:
|
||||||
|
|
||||||
|
1. Checks `python3` is present and ≥ 3.11; exits with a clear message
|
||||||
|
otherwise.
|
||||||
|
2. Checks `git` when installing a `git+` spec, and — when falling back to
|
||||||
|
pip — that pip is usable and the interpreter isn't externally managed
|
||||||
|
(PEP 668), pointing at pipx otherwise.
|
||||||
|
3. Creates `~/.bot-bottle/{agents,bottles,contrib}`.
|
||||||
|
4. Installs via `pipx` if available, else `python3 -m pip install --user`.
|
||||||
|
The spec defaults to the git URL and is overridable via
|
||||||
|
`BOT_BOTTLE_INSTALL_SPEC` (used by tests / local installs).
|
||||||
|
5. Locates the `bot-bottle` entry point: PATH first, else the
|
||||||
|
interpreter's own user-scheme scripts dir resolved via `sysconfig`
|
||||||
|
(`~/.local/bin` on Linux, `~/Library/Python/<X.Y>/bin` on a python.org
|
||||||
|
macOS interpreter — not hardcoded).
|
||||||
|
6. Runs `bot-bottle doctor` and reports the result.
|
||||||
|
|
||||||
|
It is idempotent and never calls `sudo`.
|
||||||
|
|
||||||
|
### `bot-bottle doctor`
|
||||||
|
|
||||||
|
A new store-free subcommand (no DB migration required) that checks and
|
||||||
|
reports:
|
||||||
|
|
||||||
|
- **python** — interpreter version (hard requirement: ≥ 3.11).
|
||||||
|
- **backend** — at least one backend *ready* on this host
|
||||||
|
(macos-container / firecracker / docker), via `is_backend_ready()` — a
|
||||||
|
full backend `status()` probe (daemon reachable, network pool present,
|
||||||
|
KVM usable), not a PATH-only check: a stopped daemon or half-configured
|
||||||
|
backend must not report `ok` when `start` can't work. Each not-ready
|
||||||
|
backend prints its own diagnostics. Hard requirement.
|
||||||
|
- **config** — whether `~/.bot-bottle/` exists (advisory only; `start`
|
||||||
|
provisions on first run).
|
||||||
|
|
||||||
|
Exits 0 when both hard requirements pass, non-zero otherwise.
|
||||||
|
|
||||||
|
## Testing strategy
|
||||||
|
|
||||||
|
- Unit test `bot-bottle doctor` success/failure paths with backend
|
||||||
|
readiness (`is_backend_ready`) and Python version mocked, including the
|
||||||
|
available-but-not-ready → fail case.
|
||||||
|
- Unit test that `pyproject.toml` parses, declares the entry point and an
|
||||||
|
empty `dependencies` list, and that every `package-data` glob resolves
|
||||||
|
to a file that exists on disk (guards against drift).
|
||||||
|
- Unit test that `install.sh` is executable, POSIX-ish (`set -eu`), never
|
||||||
|
calls `sudo`, and runs `doctor` after install.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
- Should `version` be derived from a git tag at build time (e.g.
|
||||||
|
`hatch-vcs`) or kept static? Static (`0.1.0`) is simpler for now.
|
||||||
|
- Publishing target (PyPI vs. a self-hosted index) is deferred.
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
# PRD prd-new: macOS (Apple Container) CI runner
|
||||||
|
|
||||||
|
- **Status:** Draft
|
||||||
|
- **Author:** Claude
|
||||||
|
- **Created:** 2026-07-25
|
||||||
|
- **Issue:** #426
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
CI has no runner for the `macos-container` (Apple Container) backend.
|
||||||
|
`.gitea/workflows/test.yml` exercises Docker (`ubuntu-latest`) and
|
||||||
|
Firecracker (self-hosted `kvm`) but never the macOS backend. This PRD adds a
|
||||||
|
self-hosted macOS runner (label `macos`) and an advisory `integration-macos`
|
||||||
|
job that runs the integration suite against `BOT_BOTTLE_BACKEND=macos-container`,
|
||||||
|
so the backend that is the default on macOS stops shipping unexercised.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The gap is not theoretical. `5ad3449` moved `bot_bottle` from flat files under
|
||||||
|
`/app` into a pip-installed package but left init scripts spawning the
|
||||||
|
supervisor as `python3 /app/gateway_init.py`, which no longer exists. Both the
|
||||||
|
Firecracker and macOS backends carried the identical bug:
|
||||||
|
|
||||||
|
- **firecracker** — caught and fixed in `127ba49` because the KVM runner
|
||||||
|
(added in `c193b04`, PR #349) runs that backend's integration suite.
|
||||||
|
- **macos-container** — survived on `main` and only surfaced when a human ran
|
||||||
|
`bot-bottle start` by hand.
|
||||||
|
|
||||||
|
The failure mode is expensive to debug: the supervisor never starts, so
|
||||||
|
mitmdump never generates its CA, and launch dies downstream with
|
||||||
|
`GatewayError: gateway CA not available`, which points at TLS rather than at
|
||||||
|
the supervisor. Unit tests did not help — `test_macos_infra` asserted the
|
||||||
|
substring `"gateway_init.py"`, which the *broken* path satisfies. (That
|
||||||
|
specific assertion has since been tightened to the module form
|
||||||
|
`bot_bottle.gateway_init`, matching its Firecracker twin, so the exact
|
||||||
|
regression is now covered on `ubuntu-latest`. What remains missing is the
|
||||||
|
end-to-end runner that would catch the *next* macOS-only launch regression.)
|
||||||
|
|
||||||
|
PR #470 (#414) already made the integration suite backend-agnostic:
|
||||||
|
`skip_unless_selected_backend_available()` gates on the *selected* backend's
|
||||||
|
own `is_backend_ready()` rather than `docker_available()`, and each
|
||||||
|
integration job runs `./cli.py backend status --backend=<name>` as a preflight
|
||||||
|
that fails loudly when the backend is missing. That is the machinery this job
|
||||||
|
plugs into; this PRD supplies the runner and the job.
|
||||||
|
|
||||||
|
## Goals / Success criteria
|
||||||
|
|
||||||
|
- A macOS runner is registered and picks up jobs by the `macos` label.
|
||||||
|
- An `integration-macos` job runs the integration suite against
|
||||||
|
`BOT_BOTTLE_BACKEND=macos-container`.
|
||||||
|
- The job **fails, not skips**, when the backend is unavailable on the runner
|
||||||
|
(via the `backend status` preflight).
|
||||||
|
- Reverting the `macos_container/infra.py` supervisor fix makes the job fail:
|
||||||
|
the broken supervisor path throws `GatewayError` at bottle launch, which is
|
||||||
|
`TestSandboxEscape.setUpClass`, failing the whole class before any individual
|
||||||
|
attack runs.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Making `integration-macos` a **required** PR check. It runs on
|
||||||
|
`workflow_dispatch` (manual dispatch) only — never on push or PRs. A single
|
||||||
|
non-redundant laptop that sleeps and roams must never be able to block a PR
|
||||||
|
merge or churn unattended on every push to main, and it is deliberately kept
|
||||||
|
out of the `coverage` job's `needs` so the diff-coverage gate never depends on
|
||||||
|
it.
|
||||||
|
- Multi-machine or hosted macOS runners. Apple Container needs the host
|
||||||
|
virtualization framework, so the runner must be a physical/VM macOS host on
|
||||||
|
Apple Silicon — it cannot reuse the KVM runner or run in a Linux container.
|
||||||
|
- Coverage aggregation from the macOS job into the combined gate (would couple
|
||||||
|
the gate to the laptop).
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Runner (operational, provisioned once)
|
||||||
|
|
||||||
|
- Apple Silicon macOS host with Apple's `container` CLI installed and
|
||||||
|
`container system status` reporting `running`.
|
||||||
|
- Install the runner: `brew install gitea-runner` (the `act_runner` rename),
|
||||||
|
registered in **host mode** with label `macos` — not docker mode, because
|
||||||
|
Apple Container needs the host `container` CLI and virtualization framework,
|
||||||
|
not a nested container.
|
||||||
|
- A Python ≥ 3.11 with `coverage` importable on the runner's `PATH`. Because a
|
||||||
|
launchd service does not inherit an interactive shell's `PATH`, pin `node`
|
||||||
|
(for the JS `actions/*`) and the Python env explicitly in the service
|
||||||
|
environment rather than relying on `nvm`/shell profile.
|
||||||
|
- Concurrency 1. The infra container is a singleton (`bot-bottle-mac-infra`),
|
||||||
|
so two simultaneous runs on one host collide (#425). The job also declares a
|
||||||
|
`concurrency` group as belt-and-suspenders and tears the singleton down after
|
||||||
|
each run.
|
||||||
|
|
||||||
|
### `integration-macos` job
|
||||||
|
|
||||||
|
Modeled on `integration-firecracker`:
|
||||||
|
|
||||||
|
- `runs-on: [self-hosted, macos]`.
|
||||||
|
- `if:` `workflow_dispatch` only (advisory, manual dispatch; never push or PRs,
|
||||||
|
so no fork-PR exposure, no merge-blocking, and no unattended runs on push).
|
||||||
|
- `concurrency: { group: integration-macos-infra, cancel-in-progress: false }`
|
||||||
|
to serialize runs against the singleton.
|
||||||
|
- **Preflight** — `command -v container`, `container system status`, then
|
||||||
|
`./cli.py backend status --backend=macos-container`; any failure exits
|
||||||
|
non-zero so a misprovisioned runner fails loudly instead of silently
|
||||||
|
skipping.
|
||||||
|
- Run the integration suite under coverage with
|
||||||
|
`BOT_BOTTLE_BACKEND=macos-container` and print a `coverage report -m` for
|
||||||
|
visibility (no upload, not in the gate).
|
||||||
|
- **Teardown** (`if: always()`) — `MacosInfraService().stop()` removes the
|
||||||
|
singleton so a crashed run cannot wedge the next one.
|
||||||
|
|
||||||
|
### The `test_sandbox_escape` CI guard (the trap #470 left)
|
||||||
|
|
||||||
|
`TestSandboxEscape` is the only backend-agnostic integration test that boots a
|
||||||
|
real bottle, so it is the one that would catch a macOS launch regression. It
|
||||||
|
still carries a second guard that skips under `GITEA_ACTIONS` for every backend
|
||||||
|
except `firecracker`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@unittest.skipIf(
|
||||||
|
os.environ.get("GITEA_ACTIONS") == "true"
|
||||||
|
and os.environ.get("BOT_BOTTLE_BACKEND") != "firecracker",
|
||||||
|
...,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
The skip exists because the *containerized* `act_runner` (docker on
|
||||||
|
`ubuntu-latest`) can't see a host bind mount and hides sibling-gateway network
|
||||||
|
topology. Those constraints do not apply to a **host-mode** runner — neither
|
||||||
|
the KVM host runner nor a macOS host runner is containerized. This PRD relaxes
|
||||||
|
the guard to allow both host-mode backends (`firecracker`, `macos-container`)
|
||||||
|
through while still skipping on the containerized Docker job. Without this
|
||||||
|
change the macOS job would run green while skipping the exact test that proves
|
||||||
|
the backend launches — the very false-green this issue is about.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
- None known that block the job. git-gate is fully implemented on the macOS
|
||||||
|
backend (the gateway's consolidated `git-http` daemon plus dynamic key
|
||||||
|
provisioning/revocation), so `TestSandboxEscape` attack 5 — secret exfil
|
||||||
|
pushed through git-gate, rejected by the gitleaks hook before the upstream
|
||||||
|
push — runs the same as on the other backends. Any genuinely
|
||||||
|
macOS-specific test adjustment would surface at first runner bring-up, but
|
||||||
|
none is anticipated from the current backend implementation.
|
||||||
Executable
+123
@@ -0,0 +1,123 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# bot-bottle quick installer.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# curl -fsSL https://gitea.dideric.is/didericis/bot-bottle/raw/branch/main/install.sh | sh
|
||||||
|
#
|
||||||
|
# Python-native users can skip this entirely:
|
||||||
|
# pipx install bot-bottle # from a checkout or a published index
|
||||||
|
# uv tool install bot-bottle
|
||||||
|
#
|
||||||
|
# This script is a thin bootstrapper: it checks prerequisites, installs the
|
||||||
|
# package with pipx (falling back to pip --user), creates the config dir, and
|
||||||
|
# runs `bot-bottle doctor`. It is idempotent (safe to re-run) and never uses
|
||||||
|
# sudo. It does NOT install Docker or a VM backend for you — `doctor` reports
|
||||||
|
# what's missing after install.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
PACKAGE_SPEC="${BOT_BOTTLE_INSTALL_SPEC:-git+https://gitea.dideric.is/didericis/bot-bottle.git}"
|
||||||
|
MIN_PYTHON_MAJOR=3
|
||||||
|
MIN_PYTHON_MINOR=11
|
||||||
|
|
||||||
|
say() {
|
||||||
|
printf 'bot-bottle install: %s\n' "$*" >&2
|
||||||
|
}
|
||||||
|
|
||||||
|
die() {
|
||||||
|
say "error: $*"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- prerequisites -----------------------------------------------------------
|
||||||
|
|
||||||
|
command -v python3 >/dev/null 2>&1 \
|
||||||
|
|| die "python3 ${MIN_PYTHON_MAJOR}.${MIN_PYTHON_MINOR}+ is required but was not found"
|
||||||
|
|
||||||
|
python3 - "$MIN_PYTHON_MAJOR" "$MIN_PYTHON_MINOR" <<'PY' || die "python3 ${MIN_PYTHON_MAJOR}.${MIN_PYTHON_MINOR} or newer is required"
|
||||||
|
import sys
|
||||||
|
|
||||||
|
want = (int(sys.argv[1]), int(sys.argv[2]))
|
||||||
|
raise SystemExit(0 if sys.version_info[:2] >= want else 1)
|
||||||
|
PY
|
||||||
|
|
||||||
|
# Installing a `git+` spec (the default) shells out to git under the hood,
|
||||||
|
# whether via pipx or pip. Fail early with a clear message rather than deep
|
||||||
|
# inside the installer's output.
|
||||||
|
case "${PACKAGE_SPEC}" in
|
||||||
|
git+*|*.git)
|
||||||
|
command -v git >/dev/null 2>&1 || die \
|
||||||
|
"git is required to install from '${PACKAGE_SPEC}'. Install git, or set "\
|
||||||
|
"BOT_BOTTLE_INSTALL_SPEC to a non-git spec (e.g. a wheel path or a package index name)."
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# The pip fallback needs a usable pip. Externally-managed interpreters
|
||||||
|
# (PEP 668, common on Debian/Ubuntu/Homebrew) reject `pip install --user`;
|
||||||
|
# pipx sidesteps that, so recommend it when pip can't be used.
|
||||||
|
if ! command -v pipx >/dev/null 2>&1; then
|
||||||
|
python3 -m pip --version >/dev/null 2>&1 || die \
|
||||||
|
"neither pipx nor a usable 'python3 -m pip' was found. Install pipx "\
|
||||||
|
"(recommended): 'python3 -m pip install --user pipx' or your OS package manager."
|
||||||
|
if python3 - <<'PY'
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import sysconfig
|
||||||
|
|
||||||
|
# PEP 668: an EXTERNALLY-MANAGED marker in the stdlib dir means pip refuses
|
||||||
|
# to install into this interpreter without --break-system-packages.
|
||||||
|
marker = os.path.join(sysconfig.get_path("stdlib"), "EXTERNALLY-MANAGED")
|
||||||
|
raise SystemExit(0 if os.path.exists(marker) else 1)
|
||||||
|
PY
|
||||||
|
then
|
||||||
|
die "this Python is externally managed (PEP 668), so 'pip install --user' is "\
|
||||||
|
"blocked. Install pipx and re-run: 'python3 -m pip install --user --break-system-packages pipx', "\
|
||||||
|
"then 'pipx ensurepath'."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- config directories ------------------------------------------------------
|
||||||
|
|
||||||
|
mkdir -p \
|
||||||
|
"${HOME}/.bot-bottle/agents" \
|
||||||
|
"${HOME}/.bot-bottle/bottles" \
|
||||||
|
"${HOME}/.bot-bottle/contrib"
|
||||||
|
|
||||||
|
# --- install -----------------------------------------------------------------
|
||||||
|
|
||||||
|
if command -v pipx >/dev/null 2>&1; then
|
||||||
|
say "installing with pipx"
|
||||||
|
pipx install --force "${PACKAGE_SPEC}"
|
||||||
|
else
|
||||||
|
say "pipx not found; installing with 'python3 -m pip install --user'"
|
||||||
|
python3 -m pip install --user --upgrade "${PACKAGE_SPEC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- locate the entry point --------------------------------------------------
|
||||||
|
|
||||||
|
# The pip --user scripts directory is platform-specific: ~/.local/bin on Linux,
|
||||||
|
# but ~/Library/Python/<X.Y>/bin on a python.org macOS interpreter. Ask the
|
||||||
|
# interpreter for its own user-scheme scripts dir instead of hardcoding.
|
||||||
|
USER_SCRIPTS="$(python3 - <<'PY'
|
||||||
|
import sysconfig
|
||||||
|
print(sysconfig.get_path("scripts", sysconfig.get_preferred_scheme("user")))
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
|
||||||
|
if command -v bot-bottle >/dev/null 2>&1; then
|
||||||
|
BOT_BOTTLE_BIN="bot-bottle"
|
||||||
|
elif [ -n "${USER_SCRIPTS}" ] && [ -x "${USER_SCRIPTS}/bot-bottle" ]; then
|
||||||
|
BOT_BOTTLE_BIN="${USER_SCRIPTS}/bot-bottle"
|
||||||
|
say "note: add ${USER_SCRIPTS} to your PATH to run 'bot-bottle' directly"
|
||||||
|
else
|
||||||
|
die "bot-bottle was installed but is not on PATH; add ${USER_SCRIPTS:-your user scripts dir} to PATH and re-run"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- verify ------------------------------------------------------------------
|
||||||
|
|
||||||
|
say "running '${BOT_BOTTLE_BIN} doctor'"
|
||||||
|
if "${BOT_BOTTLE_BIN}" doctor; then
|
||||||
|
say "done. Run '${BOT_BOTTLE_BIN} --help' to get started."
|
||||||
|
else
|
||||||
|
say "install completed, but 'doctor' reported unmet prerequisites (see above)."
|
||||||
|
say "resolve them, then re-run '${BOT_BOTTLE_BIN} doctor'."
|
||||||
|
fi
|
||||||
+38
-1
@@ -4,5 +4,42 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "bot-bottle"
|
name = "bot-bottle"
|
||||||
version = "0.0.0"
|
version = "0.1.0"
|
||||||
|
description = "Self-hosted sandbox for running AI coding agents with egress controls"
|
||||||
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
|
license = { text = "Apache-2.0" }
|
||||||
|
authors = [{ name = "didericis" }]
|
||||||
|
keywords = ["ai", "agents", "sandbox", "security", "egress"]
|
||||||
|
classifiers = [
|
||||||
|
"Programming Language :: Python :: 3",
|
||||||
|
"Programming Language :: Python :: 3 :: Only",
|
||||||
|
"Operating System :: POSIX :: Linux",
|
||||||
|
"Operating System :: MacOS",
|
||||||
|
]
|
||||||
|
# The package itself has no runtime pip dependencies (stdlib-only); the
|
||||||
|
# only language runtime is the Python interpreter. Keep this empty.
|
||||||
|
dependencies = []
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
Homepage = "https://gitea.dideric.is/didericis/bot-bottle"
|
||||||
|
Source = "https://gitea.dideric.is/didericis/bot-bottle"
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
bot-bottle = "bot_bottle.cli:main"
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
include = ["bot_bottle*"]
|
||||||
|
|
||||||
|
# Non-Python assets the runtime reads from inside the package (container
|
||||||
|
# build contexts, entrypoints, netpool defaults). Keep in sync with the
|
||||||
|
# files shipped under bot_bottle/; test_pyproject.py asserts they exist.
|
||||||
|
[tool.setuptools.package-data]
|
||||||
|
bot_bottle = [
|
||||||
|
"gateway/egress/entrypoint.sh",
|
||||||
|
"contrib/claude/Dockerfile",
|
||||||
|
"contrib/codex/Dockerfile",
|
||||||
|
"contrib/pi/Dockerfile",
|
||||||
|
"backend/firecracker/netpool.defaults.env",
|
||||||
|
"backend/macos_container/nested-containers-init.sh",
|
||||||
|
]
|
||||||
|
|||||||
@@ -5,3 +5,6 @@
|
|||||||
pylint>=3.0.0
|
pylint>=3.0.0
|
||||||
pyright>=1.1.411
|
pyright>=1.1.411
|
||||||
coverage>=7.0.0
|
coverage>=7.0.0
|
||||||
|
# PEP 517 build front-end used by tests/unit/test_wheel_install.py to build and
|
||||||
|
# install a real wheel (proves the installed distribution is self-contained).
|
||||||
|
build>=1.0.0
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""Build shim. Project metadata lives in ``pyproject.toml``; this only adds a
|
||||||
|
build step that copies the root-level build resources (the Dockerfiles, the
|
||||||
|
nix netpool module, the netpool script, and ``pyproject.toml``) into
|
||||||
|
``bot_bottle/_resources/`` so an installed wheel is self-contained and can
|
||||||
|
build its gateway/infra/orchestrator images without a source checkout.
|
||||||
|
|
||||||
|
Kept in sync with ``bot_bottle.resources.BUNDLED_RESOURCES`` — the
|
||||||
|
``test_resources`` suite guards against drift between the two lists.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from setuptools import setup
|
||||||
|
from setuptools.command.build_py import build_py
|
||||||
|
|
||||||
|
_ROOT = Path(__file__).resolve().parent
|
||||||
|
|
||||||
|
# Must match bot_bottle.resources.BUNDLED_RESOURCES (paths relative to root).
|
||||||
|
_BUNDLED_RESOURCES = (
|
||||||
|
"pyproject.toml",
|
||||||
|
"Dockerfile.gateway",
|
||||||
|
"Dockerfile.orchestrator",
|
||||||
|
"Dockerfile.orchestrator.fc",
|
||||||
|
"nix/firecracker-netpool.nix",
|
||||||
|
"scripts/firecracker-netpool.sh",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _BundleResources(build_py):
|
||||||
|
"""Copy the root-level build resources into the built package tree so they
|
||||||
|
ship inside the wheel under ``bot_bottle/_resources/``."""
|
||||||
|
|
||||||
|
def run(self) -> None:
|
||||||
|
super().run()
|
||||||
|
pkg_resources = Path(self.build_lib) / "bot_bottle" / "_resources"
|
||||||
|
for rel in _BUNDLED_RESOURCES:
|
||||||
|
src = _ROOT / rel
|
||||||
|
dst = pkg_resources / rel
|
||||||
|
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy2(src, dst)
|
||||||
|
|
||||||
|
|
||||||
|
setup(cmdclass={"build_py": _BundleResources})
|
||||||
@@ -67,14 +67,25 @@ _DUMMY_HOST_KEY = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Backends whose CI runner is HOST-mode (self-hosted), so the test process
|
||||||
|
# and the backend share a host. The containerized act_runner (docker on
|
||||||
|
# ubuntu-latest) is the one that can't see the host bind mount egress_tls_init
|
||||||
|
# uses and hides sibling-gateway network topology; host-mode runners
|
||||||
|
# (firecracker/KVM, macos-container) don't have those constraints, so the test
|
||||||
|
# runs there. Keep this in sync with the `runs-on` labels in
|
||||||
|
# .gitea/workflows/test.yml.
|
||||||
|
_HOST_MODE_CI_BACKENDS = frozenset({"firecracker", "macos-container"})
|
||||||
|
|
||||||
|
|
||||||
@skip_unless_selected_backend_available()
|
@skip_unless_selected_backend_available()
|
||||||
@unittest.skipIf(
|
@unittest.skipIf(
|
||||||
os.environ.get("GITEA_ACTIONS") == "true"
|
os.environ.get("GITEA_ACTIONS") == "true"
|
||||||
and os.environ.get("BOT_BOTTLE_BACKEND") != "firecracker",
|
and os.environ.get("BOT_BOTTLE_BACKEND") not in _HOST_MODE_CI_BACKENDS,
|
||||||
"skipped under act_runner unless BOT_BOTTLE_BACKEND=firecracker: "
|
"skipped under the containerized act_runner (docker on ubuntu-latest): "
|
||||||
"egress_tls_init uses a host bind mount the runner container can't "
|
"egress_tls_init uses a host bind mount the runner container can't "
|
||||||
"see, and the network topology hides sibling-gateway visibility — "
|
"see, and the network topology hides sibling-gateway visibility — "
|
||||||
"these constraints don't apply on the self-hosted KVM runner",
|
"these constraints don't apply on the self-hosted host-mode runners "
|
||||||
|
"(firecracker/KVM, macos-container)",
|
||||||
)
|
)
|
||||||
class TestSandboxEscape(unittest.TestCase):
|
class TestSandboxEscape(unittest.TestCase):
|
||||||
"""End-to-end attacks against a real bottle. The bottle stays
|
"""End-to-end attacks against a real bottle. The bottle stays
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
"""Unit: `bot-bottle doctor` host prerequisite checks (ADR 0004).
|
||||||
|
|
||||||
|
`doctor` is a store-free diagnostic — it must run on a fresh install
|
||||||
|
before any DB migration, and its exit code gates only the two hard
|
||||||
|
prerequisites (Python and at least one *ready* backend). The config-dir
|
||||||
|
check is advisory and never affects the exit code.
|
||||||
|
|
||||||
|
Backend readiness is probed with `is_backend_ready()` (a full status()
|
||||||
|
check), not the cheap PATH-only `is_backend_available()` — a host with a
|
||||||
|
stopped daemon or half-configured backend must not report `ok`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from contextlib import redirect_stdout
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from bot_bottle.cli.commands import doctor
|
||||||
|
|
||||||
|
|
||||||
|
def _run(argv: list[str] | None = None) -> tuple[int, str]:
|
||||||
|
buf = io.StringIO()
|
||||||
|
with redirect_stdout(buf):
|
||||||
|
code = doctor.cmd_doctor(argv or [])
|
||||||
|
return code, buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
class TestDoctor(unittest.TestCase):
|
||||||
|
def test_passes_when_python_and_backend_ready(self):
|
||||||
|
with patch.object(doctor, "known_backend_names", return_value=("docker",)), \
|
||||||
|
patch.object(doctor, "is_backend_ready", return_value=True):
|
||||||
|
code, out = _run()
|
||||||
|
self.assertEqual(0, code)
|
||||||
|
self.assertIn("ok: python", out)
|
||||||
|
self.assertIn("ok: backend: docker: ready", out)
|
||||||
|
|
||||||
|
def test_fails_when_no_backend_ready(self):
|
||||||
|
# The regression the reviewer flagged: a backend whose binary is on PATH
|
||||||
|
# but whose daemon/pool isn't ready must NOT pass. is_backend_ready is
|
||||||
|
# the full status() check, so returning False here means "not ready".
|
||||||
|
with patch.object(doctor, "known_backend_names", return_value=("docker", "firecracker")), \
|
||||||
|
patch.object(doctor, "is_backend_ready", return_value=False):
|
||||||
|
code, out = _run()
|
||||||
|
self.assertEqual(1, code)
|
||||||
|
self.assertIn("fail: backend", out)
|
||||||
|
self.assertIn("warn: backend: docker: not ready", out)
|
||||||
|
|
||||||
|
def test_passes_when_at_least_one_backend_ready(self):
|
||||||
|
# docker not ready, firecracker ready → overall pass, mixed report.
|
||||||
|
def ready(name: str, *, quiet: bool = False) -> bool:
|
||||||
|
del quiet
|
||||||
|
return name == "firecracker"
|
||||||
|
|
||||||
|
with patch.object(doctor, "known_backend_names", return_value=("docker", "firecracker")), \
|
||||||
|
patch.object(doctor, "is_backend_ready", side_effect=ready):
|
||||||
|
code, out = _run()
|
||||||
|
self.assertEqual(0, code)
|
||||||
|
self.assertIn("warn: backend: docker: not ready", out)
|
||||||
|
self.assertIn("ok: backend: firecracker: ready", out)
|
||||||
|
|
||||||
|
def test_fails_when_python_too_old(self):
|
||||||
|
# Force the version gate to fail without touching the interpreter.
|
||||||
|
with patch.object(doctor, "MIN_PYTHON", (99, 0)), \
|
||||||
|
patch.object(doctor, "known_backend_names", return_value=("docker",)), \
|
||||||
|
patch.object(doctor, "is_backend_ready", return_value=True):
|
||||||
|
code, out = _run()
|
||||||
|
self.assertEqual(1, code)
|
||||||
|
self.assertIn("fail: python", out)
|
||||||
|
|
||||||
|
def test_missing_config_dir_is_advisory_not_fatal(self):
|
||||||
|
# A missing ~/.bot-bottle warns but must not fail. Point home at a
|
||||||
|
# fresh empty dir so the shared suite HOME (which other tests may
|
||||||
|
# populate) can't turn this into an "ok: config".
|
||||||
|
with tempfile.TemporaryDirectory() as tmp, \
|
||||||
|
patch.object(doctor.Path, "home", return_value=Path(tmp)), \
|
||||||
|
patch.object(doctor, "known_backend_names", return_value=("docker",)), \
|
||||||
|
patch.object(doctor, "is_backend_ready", return_value=True):
|
||||||
|
code, out = _run()
|
||||||
|
self.assertEqual(0, code)
|
||||||
|
self.assertIn("warn: config", out)
|
||||||
|
|
||||||
|
def test_present_config_dir_reports_ok(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp, \
|
||||||
|
patch.object(doctor.Path, "home", return_value=Path(tmp)), \
|
||||||
|
patch.object(doctor, "known_backend_names", return_value=("docker",)), \
|
||||||
|
patch.object(doctor, "is_backend_ready", return_value=True):
|
||||||
|
(Path(tmp) / ".bot-bottle").mkdir()
|
||||||
|
code, out = _run()
|
||||||
|
self.assertEqual(0, code)
|
||||||
|
self.assertIn("ok: config", out)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -19,7 +19,9 @@ _ORCH = "bot_bottle.backend.docker.orchestrator"
|
|||||||
_RUN = f"{_ORCH}.run_docker"
|
_RUN = f"{_ORCH}.run_docker"
|
||||||
_SLEEP = f"{_ORCH}.time.sleep"
|
_SLEEP = f"{_ORCH}.time.sleep"
|
||||||
_MONOTONIC = f"{_ORCH}.time.monotonic"
|
_MONOTONIC = f"{_ORCH}.time.monotonic"
|
||||||
_TOKEN = f"{_ORCH}.host_orchestrator_token"
|
# The signing key is read through the shared provisioning contract (#476); patch
|
||||||
|
# its host-canonical key file read to keep it off the real host file.
|
||||||
|
_TOKEN = "bot_bottle.trust_domain.host_signing_key"
|
||||||
# The ABC's is_healthy probes /health via urllib in the lifecycle module.
|
# The ABC's is_healthy probes /health via urllib in the lifecycle module.
|
||||||
_URLOPEN = "bot_bottle.orchestrator.lifecycle.urllib.request.urlopen"
|
_URLOPEN = "bot_bottle.orchestrator.lifecycle.urllib.request.urlopen"
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ class TestEnsureRunning(unittest.TestCase):
|
|||||||
vm = infra_vm.InfraVm(guest_ip="10.243.255.1", private_key=Path("/k"), vm=MagicMock())
|
vm = infra_vm.InfraVm(guest_ip="10.243.255.1", private_key=Path("/k"), vm=MagicMock())
|
||||||
with patch.object(infra_vm, "boot_vm", return_value=vm) as boot, \
|
with patch.object(infra_vm, "boot_vm", return_value=vm) as boot, \
|
||||||
patch.object(infra_vm, "push_secret") as push, \
|
patch.object(infra_vm, "push_secret") as push, \
|
||||||
patch(f"{_ORCH}.host_orchestrator_token", return_value="host-key"), \
|
patch("bot_bottle.trust_domain.host_signing_key", return_value="host-key"), \
|
||||||
patch.object(orch, "is_running", return_value=False), \
|
patch.object(orch, "is_running", return_value=False), \
|
||||||
patch.object(orch, "_ensure_registry_volume", return_value=Path("/reg")), \
|
patch.object(orch, "_ensure_registry_volume", return_value=Path("/reg")), \
|
||||||
patch.object(orch, "_wait_for_health"):
|
patch.object(orch, "_wait_for_health"):
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
"""Unit: install.sh bootstrapper contract.
|
||||||
|
|
||||||
|
The installer is a thin, sudo-free, idempotent bootstrapper. These are
|
||||||
|
static checks on the script text (no network / no real install) so CI can
|
||||||
|
run them anywhere: it must be executable, fail-fast, never call sudo,
|
||||||
|
create the config tree, install the package, and verify with `doctor`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sysconfig
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
INSTALL_SH = REPO_ROOT / "install.sh"
|
||||||
|
|
||||||
|
|
||||||
|
class TestInstallScript(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
cls.text = INSTALL_SH.read_text()
|
||||||
|
|
||||||
|
def test_exists_and_executable(self):
|
||||||
|
self.assertTrue(INSTALL_SH.is_file())
|
||||||
|
self.assertTrue(os.access(INSTALL_SH, os.X_OK), "install.sh must be executable")
|
||||||
|
|
||||||
|
def test_posix_shebang_and_failfast(self):
|
||||||
|
first = self.text.splitlines()[0]
|
||||||
|
self.assertEqual("#!/bin/sh", first)
|
||||||
|
self.assertIn("set -eu", self.text)
|
||||||
|
|
||||||
|
def test_never_uses_sudo(self):
|
||||||
|
# Only executable lines matter; the header comment may mention sudo.
|
||||||
|
code = [
|
||||||
|
ln for ln in self.text.splitlines()
|
||||||
|
if ln.strip() and not ln.lstrip().startswith("#")
|
||||||
|
]
|
||||||
|
self.assertNotIn("sudo", "\n".join(code))
|
||||||
|
|
||||||
|
def test_creates_config_tree(self):
|
||||||
|
self.assertIn(".bot-bottle/agents", self.text)
|
||||||
|
self.assertIn(".bot-bottle/bottles", self.text)
|
||||||
|
|
||||||
|
def test_installs_via_pipx_with_pip_fallback(self):
|
||||||
|
self.assertIn("pipx install", self.text)
|
||||||
|
self.assertIn("pip install --user", self.text)
|
||||||
|
|
||||||
|
def test_runs_doctor_after_install(self):
|
||||||
|
self.assertIn("doctor", self.text)
|
||||||
|
|
||||||
|
def test_install_spec_is_overridable(self):
|
||||||
|
# Tests / local installs point BOT_BOTTLE_INSTALL_SPEC at a checkout.
|
||||||
|
self.assertIn("BOT_BOTTLE_INSTALL_SPEC", self.text)
|
||||||
|
|
||||||
|
def test_requires_git_for_git_specs(self):
|
||||||
|
# A git+ / .git spec (the default) shells out to git; the script must
|
||||||
|
# gate on it rather than failing opaquely inside pipx/pip.
|
||||||
|
self.assertIn("command -v git", self.text)
|
||||||
|
self.assertIn("git+*|*.git", self.text)
|
||||||
|
|
||||||
|
def test_checks_pip_usable_before_fallback(self):
|
||||||
|
self.assertIn("python3 -m pip --version", self.text)
|
||||||
|
|
||||||
|
def test_detects_externally_managed_python(self):
|
||||||
|
# PEP 668: 'pip install --user' is blocked on externally-managed
|
||||||
|
# interpreters; the script must detect this and point at pipx.
|
||||||
|
self.assertIn("EXTERNALLY-MANAGED", self.text)
|
||||||
|
self.assertIn("pipx", self.text)
|
||||||
|
|
||||||
|
def test_resolves_user_scripts_dir_not_hardcoded(self):
|
||||||
|
# The pip --user scripts dir differs by platform; the script must ask
|
||||||
|
# the interpreter (sysconfig + the preferred *user* scheme) rather than
|
||||||
|
# hardcoding Linux's ~/.local/bin (which is wrong on macOS python.org).
|
||||||
|
self.assertIn("get_preferred_scheme", self.text)
|
||||||
|
self.assertIn("sysconfig", self.text)
|
||||||
|
# No hardcoded Linux path in executable lines (a comment may mention it).
|
||||||
|
code = "\n".join(
|
||||||
|
ln for ln in self.text.splitlines()
|
||||||
|
if ln.strip() and not ln.lstrip().startswith("#")
|
||||||
|
)
|
||||||
|
self.assertNotIn(".local/bin", code)
|
||||||
|
|
||||||
|
def test_macos_user_scheme_is_not_dot_local_bin(self):
|
||||||
|
# The case the fix exists for: a python.org macOS interpreter uses the
|
||||||
|
# osx_framework_user scheme, whose scripts land under
|
||||||
|
# ~/Library/Python/<X.Y>/bin — NOT ~/.local/bin. Drive the same
|
||||||
|
# sysconfig lookup install.sh uses, with a mac-like userbase, to prove
|
||||||
|
# it resolves a non-~/.local/bin directory.
|
||||||
|
self.assertIn("osx_framework_user", sysconfig.get_scheme_names())
|
||||||
|
scripts = sysconfig.get_path(
|
||||||
|
"scripts", "osx_framework_user",
|
||||||
|
vars={"userbase": "/Users/dev/Library/Python/3.11"},
|
||||||
|
)
|
||||||
|
self.assertEqual("/Users/dev/Library/Python/3.11/bin", scripts)
|
||||||
|
self.assertNotIn("/.local/bin", scripts)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -283,7 +283,7 @@ class TestBuildOrLoadImages(unittest.TestCase):
|
|||||||
images = launch_mod.build_or_load_images(plan)
|
images = launch_mod.build_or_load_images(plan)
|
||||||
|
|
||||||
build.assert_called_once_with(
|
build.assert_called_once_with(
|
||||||
"agent:base", launch_mod._REPO_DIR, # pylint: disable=protected-access
|
"agent:base", str(launch_mod.resources.build_root()),
|
||||||
dockerfile="/repo/Dockerfile",
|
dockerfile="/repo/Dockerfile",
|
||||||
)
|
)
|
||||||
derived.assert_called_once_with("agent:base", build)
|
derived.assert_called_once_with("agent:base", build)
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ class TestMacosOrchestratorRun(unittest.TestCase):
|
|||||||
def _run(self) -> list[str]:
|
def _run(self) -> list[str]:
|
||||||
run = Mock(return_value=_ok())
|
run = Mock(return_value=_ok())
|
||||||
with patch(f"{_ORCH}.container_mod") as mod, \
|
with patch(f"{_ORCH}.container_mod") as mod, \
|
||||||
patch(f"{_ORCH}.host_orchestrator_token", return_value="k"):
|
patch("bot_bottle.trust_domain.host_signing_key", return_value="k"):
|
||||||
mod.dns_server.return_value = "1.1.1.1"
|
mod.dns_server.return_value = "1.1.1.1"
|
||||||
mod.bind_mount_spec.side_effect = _spec
|
mod.bind_mount_spec.side_effect = _spec
|
||||||
mod.run_container_argv = run
|
mod.run_container_argv = run
|
||||||
@@ -65,7 +65,7 @@ class TestMacosOrchestratorRun(unittest.TestCase):
|
|||||||
|
|
||||||
def test_start_failure_raises(self) -> None:
|
def test_start_failure_raises(self) -> None:
|
||||||
with patch(f"{_ORCH}.container_mod") as mod, \
|
with patch(f"{_ORCH}.container_mod") as mod, \
|
||||||
patch(f"{_ORCH}.host_orchestrator_token", return_value="k"):
|
patch("bot_bottle.trust_domain.host_signing_key", return_value="k"):
|
||||||
mod.dns_server.return_value = "1.1.1.1"
|
mod.dns_server.return_value = "1.1.1.1"
|
||||||
mod.run_container_argv = Mock(return_value=_fail())
|
mod.run_container_argv = Mock(return_value=_fail())
|
||||||
with self.assertRaises(OrchestratorStartError):
|
with self.assertRaises(OrchestratorStartError):
|
||||||
|
|||||||
@@ -82,5 +82,26 @@ class TestMintVerify(unittest.TestCase):
|
|||||||
mint(ROLE_GATEWAY, "")
|
mint(ROLE_GATEWAY, "")
|
||||||
|
|
||||||
|
|
||||||
|
class TestCustomRoleSet(unittest.TestCase):
|
||||||
|
"""The `roles=` seam a trust domain other than the control plane uses (#476):
|
||||||
|
mint/verify are scoped to the passed role set, not the module default."""
|
||||||
|
|
||||||
|
_ROLES = frozenset({"host"})
|
||||||
|
|
||||||
|
def test_round_trips_a_role_in_the_custom_set(self) -> None:
|
||||||
|
tok = mint("host", _KEY, roles=self._ROLES)
|
||||||
|
self.assertEqual("host", verify(tok, _KEY, roles=self._ROLES))
|
||||||
|
|
||||||
|
def test_mint_rejects_a_role_outside_the_custom_set(self) -> None:
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
mint(ROLE_CLI, _KEY, roles=self._ROLES)
|
||||||
|
|
||||||
|
def test_verify_rejects_a_role_outside_the_verifiers_set(self) -> None:
|
||||||
|
# A validly signed token whose role isn't in the verifier's set fails —
|
||||||
|
# this is what keeps one domain's key from asserting another's role.
|
||||||
|
tok = mint(ROLE_CLI, _KEY) # a control-plane `cli` token
|
||||||
|
self.assertIsNone(verify(tok, _KEY, roles=self._ROLES))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -20,13 +20,15 @@ _URLOPEN = "bot_bottle.orchestrator.client.urllib.request.urlopen"
|
|||||||
|
|
||||||
class TestHostAuthToken(unittest.TestCase):
|
class TestHostAuthToken(unittest.TestCase):
|
||||||
def test_mints_a_cli_token_from_the_host_key(self) -> None:
|
def test_mints_a_cli_token_from_the_host_key(self) -> None:
|
||||||
with patch("bot_bottle.orchestrator.client.host_orchestrator_token",
|
# The CLI mints its `cli` token from the control-plane trust domain's
|
||||||
|
# host-canonical key (#476) — patch the key file read underneath it.
|
||||||
|
with patch("bot_bottle.trust_domain.host_signing_key",
|
||||||
return_value="signing-key"):
|
return_value="signing-key"):
|
||||||
tok = _host_auth_token()
|
tok = _host_auth_token()
|
||||||
self.assertEqual(ROLE_CLI, verify(tok, "signing-key"))
|
self.assertEqual(ROLE_CLI, verify(tok, "signing-key"))
|
||||||
|
|
||||||
def test_returns_empty_when_key_unreadable(self) -> None:
|
def test_returns_empty_when_key_unreadable(self) -> None:
|
||||||
with patch("bot_bottle.orchestrator.client.host_orchestrator_token",
|
with patch("bot_bottle.trust_domain.host_signing_key",
|
||||||
side_effect=OSError("no host root")):
|
side_effect=OSError("no host root")):
|
||||||
self.assertEqual("", _host_auth_token())
|
self.assertEqual("", _host_auth_token())
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""Unit: pyproject.toml packaging contract.
|
||||||
|
|
||||||
|
Guards the install/distribution surface: the console-script entry point,
|
||||||
|
the stdlib-only (empty) dependency list, and that every package-data glob
|
||||||
|
still points at a file that exists (so an installed wheel isn't missing a
|
||||||
|
Dockerfile or entrypoint the runtime reads).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tomllib
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
PYPROJECT = REPO_ROOT / "pyproject.toml"
|
||||||
|
|
||||||
|
|
||||||
|
class TestPyproject(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
with PYPROJECT.open("rb") as fh:
|
||||||
|
cls.data = tomllib.load(fh)
|
||||||
|
|
||||||
|
def test_entry_point_targets_cli_main(self):
|
||||||
|
scripts = self.data["project"]["scripts"]
|
||||||
|
self.assertEqual("bot_bottle.cli:main", scripts["bot-bottle"])
|
||||||
|
|
||||||
|
def test_no_runtime_dependencies(self):
|
||||||
|
# AGENTS.md: the package has no runtime pip dependencies.
|
||||||
|
self.assertEqual([], self.data["project"]["dependencies"])
|
||||||
|
|
||||||
|
def test_requires_python_311(self):
|
||||||
|
self.assertEqual(">=3.11", self.data["project"]["requires-python"])
|
||||||
|
|
||||||
|
def test_package_data_files_exist(self):
|
||||||
|
pkg_data = self.data["tool"]["setuptools"]["package-data"]["bot_bottle"]
|
||||||
|
self.assertTrue(pkg_data, "expected package-data entries")
|
||||||
|
for rel in pkg_data:
|
||||||
|
path = REPO_ROOT / "bot_bottle" / rel
|
||||||
|
self.assertTrue(path.is_file(), f"package-data missing: {path}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
"""Unit: bot_bottle.resources — build-resource resolution for both a source
|
||||||
|
checkout and an installed wheel.
|
||||||
|
|
||||||
|
The checkout path is what the whole test suite already runs under; the wheel
|
||||||
|
path is exercised here by faking an installed layout (a package dir with a
|
||||||
|
bundled ``_resources/`` and no sibling Dockerfiles) and asserting that
|
||||||
|
``build_root()`` stages a repo-root-shaped context. See
|
||||||
|
``test_wheel_install.py`` for the end-to-end build+install check.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from bot_bottle import resources
|
||||||
|
|
||||||
|
from tests.unit import use_bottle_root
|
||||||
|
|
||||||
|
|
||||||
|
class TestCheckoutMode(unittest.TestCase):
|
||||||
|
"""The environment the suite runs in: a real source checkout."""
|
||||||
|
|
||||||
|
def test_is_source_checkout(self):
|
||||||
|
self.assertTrue(resources.is_source_checkout())
|
||||||
|
|
||||||
|
def test_build_root_is_repo_root(self):
|
||||||
|
root = resources.build_root()
|
||||||
|
self.assertTrue((root / "bot_bottle").is_dir())
|
||||||
|
self.assertTrue((root / "pyproject.toml").is_file())
|
||||||
|
self.assertTrue((root / "Dockerfile.gateway").is_file())
|
||||||
|
|
||||||
|
def test_resource_helpers_resolve(self):
|
||||||
|
self.assertTrue(resources.dockerfile("Dockerfile.gateway").is_file())
|
||||||
|
self.assertTrue(resources.nix_netpool_module().is_file())
|
||||||
|
self.assertTrue(resources.netpool_script().is_file())
|
||||||
|
|
||||||
|
def test_bundled_resources_all_exist_at_root(self):
|
||||||
|
# Drift guard: every path setup.py bundles must exist in the checkout.
|
||||||
|
root = resources.build_root()
|
||||||
|
for rel in resources.BUNDLED_RESOURCES:
|
||||||
|
self.assertTrue((root / rel).is_file(), f"missing bundled resource: {rel}")
|
||||||
|
|
||||||
|
|
||||||
|
class TestWheelMode(unittest.TestCase):
|
||||||
|
"""Fake an installed wheel: a package dir with _resources/ and no
|
||||||
|
checkout Dockerfiles beside it."""
|
||||||
|
|
||||||
|
def _fake_install(self, tmp: Path) -> Path:
|
||||||
|
pkg = tmp / "site-packages" / "bot_bottle"
|
||||||
|
(pkg / "cli").mkdir(parents=True)
|
||||||
|
(pkg / "__init__.py").write_text("")
|
||||||
|
(pkg / "cli" / "__init__.py").write_text("# module\n")
|
||||||
|
bundled = pkg / "_resources"
|
||||||
|
for rel in resources.BUNDLED_RESOURCES:
|
||||||
|
dst = bundled / rel
|
||||||
|
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
dst.write_text(f"# fake {rel}\n")
|
||||||
|
return pkg
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _wheel(self):
|
||||||
|
"""Point `resources` at a fake installed wheel with an isolated
|
||||||
|
app-data dir; yields the package dir so a test can mutate it."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpname:
|
||||||
|
tmp = Path(tmpname)
|
||||||
|
pkg = self._fake_install(tmp)
|
||||||
|
self.addCleanup(use_bottle_root(tmp / "appdata"))
|
||||||
|
with patch.object(resources, "_PKG", pkg), \
|
||||||
|
patch.object(resources, "_BUNDLED", pkg / "_resources"), \
|
||||||
|
patch.object(resources, "_CHECKOUT_ROOT", tmp / "no-checkout"):
|
||||||
|
yield pkg
|
||||||
|
|
||||||
|
def test_stage_and_resolve(self):
|
||||||
|
with self._wheel():
|
||||||
|
self.assertFalse(resources.is_source_checkout())
|
||||||
|
root = resources.build_root()
|
||||||
|
# Staged context looks like a repo root.
|
||||||
|
self.assertTrue((root / "bot_bottle" / "__init__.py").is_file())
|
||||||
|
self.assertTrue((root / "bot_bottle" / "cli" / "__init__.py").is_file())
|
||||||
|
self.assertTrue((root / "pyproject.toml").is_file())
|
||||||
|
self.assertTrue((root / "Dockerfile.gateway").is_file())
|
||||||
|
self.assertTrue((root / "nix" / "firecracker-netpool.nix").is_file())
|
||||||
|
self.assertTrue((root / "scripts" / "firecracker-netpool.sh").is_file())
|
||||||
|
# The bundled-resource copies are NOT re-nested under the staged
|
||||||
|
# package (keeps it byte-identical to a checkout package).
|
||||||
|
self.assertFalse((root / "bot_bottle" / "_resources").exists())
|
||||||
|
# Helpers resolve off the staged root.
|
||||||
|
self.assertEqual(root / "Dockerfile.gateway",
|
||||||
|
resources.dockerfile("Dockerfile.gateway"))
|
||||||
|
# Idempotent: second call returns the same completed dir.
|
||||||
|
self.assertEqual(root, resources.build_root())
|
||||||
|
|
||||||
|
def test_refreshes_when_content_changes_at_same_version(self):
|
||||||
|
# Regression for the stale-cache bug: `pipx install --force` of a newer
|
||||||
|
# commit keeps version 0.1.0, so keying on version would reuse the old
|
||||||
|
# tree. Keying on content must re-stage when a package file changes.
|
||||||
|
with self._wheel() as pkg:
|
||||||
|
root1 = resources.build_root()
|
||||||
|
self.assertTrue((root1 / ".complete").is_file())
|
||||||
|
(pkg / "cli" / "__init__.py").write_text("# new commit, same version\n")
|
||||||
|
root2 = resources.build_root()
|
||||||
|
self.assertNotEqual(root1, root2)
|
||||||
|
self.assertEqual(
|
||||||
|
"# new commit, same version\n",
|
||||||
|
(root2 / "bot_bottle" / "cli" / "__init__.py").read_text(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_failed_stage_cleans_up_temp_dir(self):
|
||||||
|
# A failure mid-stage must not leave a half-written temp dir behind.
|
||||||
|
with self._wheel():
|
||||||
|
base = resources.bot_bottle_root() / "build-root"
|
||||||
|
with patch.object(resources.shutil, "copytree", side_effect=OSError("boom")):
|
||||||
|
with self.assertRaises(OSError):
|
||||||
|
resources.build_root()
|
||||||
|
self.assertEqual([], list(base.glob(".staging-*")))
|
||||||
|
|
||||||
|
def test_rebuilds_when_stage_incomplete(self):
|
||||||
|
# A crash mid-stage can leave a dir without its `.complete` marker; the
|
||||||
|
# next call must rebuild it rather than trust the partial tree.
|
||||||
|
with self._wheel():
|
||||||
|
root = resources.build_root()
|
||||||
|
(root / ".complete").unlink()
|
||||||
|
(root / "sentinel").write_text("stale")
|
||||||
|
again = resources.build_root()
|
||||||
|
self.assertEqual(root, again) # same content digest → same dir
|
||||||
|
self.assertTrue((again / ".complete").is_file())
|
||||||
|
self.assertFalse((again / "sentinel").exists()) # rebuilt clean
|
||||||
|
|
||||||
|
def test_reuses_peer_stage_after_lock_wait(self):
|
||||||
|
# Regression for the staging race: a caller that loses the lock must,
|
||||||
|
# once it wins, see the peer's completed tree and reuse it — never
|
||||||
|
# re-clobber a shared path. Drive it deterministically: hold the lock,
|
||||||
|
# let a worker block after its fast-path miss, publish a complete tree
|
||||||
|
# as the "peer", then release so the worker takes the reuse path.
|
||||||
|
import fcntl
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
with self._wheel():
|
||||||
|
base = resources.bot_bottle_root() / "build-root"
|
||||||
|
base.mkdir(parents=True, exist_ok=True)
|
||||||
|
dest = base / resources._content_digest() # pylint: disable=protected-access
|
||||||
|
|
||||||
|
result: dict[str, Path] = {}
|
||||||
|
with open(base / ".stage.lock", "w", encoding="utf-8") as held:
|
||||||
|
fcntl.flock(held, fcntl.LOCK_EX)
|
||||||
|
|
||||||
|
def worker() -> None:
|
||||||
|
result["root"] = resources.build_root()
|
||||||
|
|
||||||
|
t = threading.Thread(target=worker)
|
||||||
|
t.start()
|
||||||
|
# Let the worker miss the fast path (dest not yet complete) and
|
||||||
|
# block on the held lock, then publish a complete tree as a peer
|
||||||
|
# would have and release the lock.
|
||||||
|
time.sleep(0.3)
|
||||||
|
dest.mkdir(parents=True)
|
||||||
|
(dest / ".complete").write_text("")
|
||||||
|
fcntl.flock(held, fcntl.LOCK_UN)
|
||||||
|
t.join(timeout=10)
|
||||||
|
|
||||||
|
self.assertEqual(dest, result["root"])
|
||||||
|
self.assertFalse(t.is_alive())
|
||||||
|
|
||||||
|
def test_missing_bundle_raises(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmpname:
|
||||||
|
tmp = Path(tmpname)
|
||||||
|
pkg = tmp / "bot_bottle"
|
||||||
|
pkg.mkdir()
|
||||||
|
restore = use_bottle_root(tmp / "appdata")
|
||||||
|
self.addCleanup(restore)
|
||||||
|
with patch.object(resources, "_PKG", pkg), \
|
||||||
|
patch.object(resources, "_BUNDLED", pkg / "_resources"), \
|
||||||
|
patch.object(resources, "_CHECKOUT_ROOT", tmp / "no-checkout"):
|
||||||
|
with self.assertRaises(resources.ResourceError):
|
||||||
|
resources.build_root()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
"""Unit: trust domains + the shared control-plane provisioning contract (#476)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from bot_bottle import orchestrator_auth
|
||||||
|
from bot_bottle.orchestrator_auth import ROLE_CLI, ROLE_GATEWAY
|
||||||
|
from bot_bottle.trust_domain import (
|
||||||
|
CONTROL_PLANE,
|
||||||
|
ControlPlaneProvisioning,
|
||||||
|
ProvisioningError,
|
||||||
|
TrustDomain,
|
||||||
|
)
|
||||||
|
|
||||||
|
# A second, unrelated domain — the shape #468's host controller would take: its
|
||||||
|
# own key file, its own role, its own env vars. Distinct from CONTROL_PLANE.
|
||||||
|
_HOST_CTRL = TrustDomain(
|
||||||
|
name="host-controller",
|
||||||
|
key_filename="host-controller-token",
|
||||||
|
roles=frozenset({"host"}),
|
||||||
|
key_env="BOT_BOTTLE_HOST_CONTROLLER_TOKEN",
|
||||||
|
token_env="BOT_BOTTLE_HOST_CONTROLLER_JWT",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestTrustDomainMintVerify(unittest.TestCase):
|
||||||
|
def test_mint_verify_round_trips_within_a_domain(self) -> None:
|
||||||
|
with patch("bot_bottle.trust_domain.host_signing_key", return_value="k"):
|
||||||
|
tok = CONTROL_PLANE.mint(ROLE_GATEWAY)
|
||||||
|
self.assertEqual(ROLE_GATEWAY, CONTROL_PLANE.verify(tok, "k"))
|
||||||
|
|
||||||
|
def test_mint_rejects_a_role_outside_the_domain(self) -> None:
|
||||||
|
# `host` is a valid role in _HOST_CTRL but not in the control plane —
|
||||||
|
# the control-plane key must refuse to mint it.
|
||||||
|
with patch("bot_bottle.trust_domain.host_signing_key", return_value="k"):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
CONTROL_PLANE.mint("host")
|
||||||
|
|
||||||
|
def test_a_custom_role_set_verifies_its_own_role(self) -> None:
|
||||||
|
with patch("bot_bottle.trust_domain.host_signing_key", return_value="k"):
|
||||||
|
tok = _HOST_CTRL.mint("host")
|
||||||
|
self.assertEqual("host", _HOST_CTRL.verify(tok, "k"))
|
||||||
|
|
||||||
|
def test_key_from_env_reads_the_domains_env_var(self) -> None:
|
||||||
|
self.assertEqual(
|
||||||
|
"secret", CONTROL_PLANE.key_from_env({CONTROL_PLANE.key_env: " secret "}))
|
||||||
|
self.assertEqual("", CONTROL_PLANE.key_from_env({}))
|
||||||
|
|
||||||
|
|
||||||
|
class TestDomainBoundary(unittest.TestCase):
|
||||||
|
"""The #476/#468 invariant: two domains, two keys, two role sets — one
|
||||||
|
domain's key can neither mint nor verify the other's tokens."""
|
||||||
|
|
||||||
|
def test_a_control_plane_token_does_not_verify_under_another_domains_key(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
# Even with an IDENTICAL underlying key, a `cli` token minted under the
|
||||||
|
# control-plane domain must not verify as a role in the host-controller
|
||||||
|
# domain — the role isn't in that domain's set.
|
||||||
|
cli_tok = orchestrator_auth.mint(ROLE_CLI, "shared-bytes")
|
||||||
|
self.assertIsNone(_HOST_CTRL.verify(cli_tok, "shared-bytes"))
|
||||||
|
|
||||||
|
def test_distinct_keys_do_not_cross_verify(self) -> None:
|
||||||
|
# The realistic case: distinct host-canonical keys per domain. A token
|
||||||
|
# signed by one key never verifies under the other.
|
||||||
|
host_tok = orchestrator_auth.mint(
|
||||||
|
"host", "host-ctrl-key", roles=_HOST_CTRL.roles)
|
||||||
|
self.assertIsNone(_HOST_CTRL.verify(host_tok, "control-plane-key"))
|
||||||
|
self.assertEqual("host", _HOST_CTRL.verify(host_tok, "host-ctrl-key"))
|
||||||
|
|
||||||
|
|
||||||
|
class TestControlPlaneProvisioning(unittest.TestCase):
|
||||||
|
def test_orchestrator_key_returns_the_canonical_key(self) -> None:
|
||||||
|
prov = ControlPlaneProvisioning()
|
||||||
|
with patch("bot_bottle.trust_domain.host_signing_key", return_value="key"):
|
||||||
|
self.assertEqual("key", prov.orchestrator_key())
|
||||||
|
|
||||||
|
def test_orchestrator_key_fail_closes_when_empty(self) -> None:
|
||||||
|
# Invariant 4: the orchestrator must never start without a key — it would
|
||||||
|
# run OPEN and grant every caller that reaches it full `cli`. There is no
|
||||||
|
# topology opt-out: a separate host does not stop the gateway (or any
|
||||||
|
# other caller) from reaching the control-plane listener.
|
||||||
|
prov = ControlPlaneProvisioning()
|
||||||
|
with patch("bot_bottle.trust_domain.host_signing_key", return_value=""):
|
||||||
|
with self.assertRaises(ProvisioningError):
|
||||||
|
prov.orchestrator_key()
|
||||||
|
|
||||||
|
def test_gateway_token_is_a_verifiable_gateway_role_token(self) -> None:
|
||||||
|
prov = ControlPlaneProvisioning()
|
||||||
|
with patch("bot_bottle.trust_domain.host_signing_key", return_value="k"):
|
||||||
|
tok = prov.gateway_token()
|
||||||
|
self.assertEqual(ROLE_GATEWAY, CONTROL_PLANE.verify(tok, "k"))
|
||||||
|
|
||||||
|
def test_gateway_token_cannot_be_reused_as_cli(self) -> None:
|
||||||
|
# The data plane's token is `gateway`-scoped: it never carries `cli`.
|
||||||
|
prov = ControlPlaneProvisioning()
|
||||||
|
with patch("bot_bottle.trust_domain.host_signing_key", return_value="k"):
|
||||||
|
tok = prov.gateway_token()
|
||||||
|
self.assertNotEqual(ROLE_CLI, CONTROL_PLANE.verify(tok, "k"))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
"""Integration: build the wheel, install it into an isolated venv, and prove
|
||||||
|
the installed distribution is self-contained.
|
||||||
|
|
||||||
|
This is the boundary a source-tree existence test can't reach (issue #197
|
||||||
|
review): under an installed wheel the package lives in ``site-packages`` with
|
||||||
|
no repo root above it, so anything resolving Dockerfiles / nix / scripts from
|
||||||
|
``__file__``'s parents would break. Here we install for real and assert that
|
||||||
|
``bot-bottle doctor`` runs from the console script and that
|
||||||
|
``bot_bottle.resources`` stages a valid, repo-root-shaped build context.
|
||||||
|
|
||||||
|
It does NOT run `start` — building images needs a Docker/KVM host (CI). It
|
||||||
|
skips cleanly when the build/venv toolchain isn't available.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
|
||||||
|
|
||||||
|
class TestWheelInstall(unittest.TestCase):
|
||||||
|
"""`build` is a declared dev dependency (requirements-dev.txt), so this runs
|
||||||
|
in CI. A build/install failure is a real packaging regression and FAILS —
|
||||||
|
only genuinely-unsupported infra (no `venv`/`ensurepip`) skips."""
|
||||||
|
|
||||||
|
_tmp: "tempfile.TemporaryDirectory[str]"
|
||||||
|
venv_py: Path
|
||||||
|
app_root: Path
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
cls._tmp = tempfile.TemporaryDirectory( # pylint: disable=consider-using-with
|
||||||
|
prefix="bb-wheel-")
|
||||||
|
tmp = Path(cls._tmp.name)
|
||||||
|
dist = tmp / "dist"
|
||||||
|
|
||||||
|
# A failed wheel build is exactly the regression this test guards — fail,
|
||||||
|
# don't skip. `build` is installed via requirements-dev.txt.
|
||||||
|
built = subprocess.run(
|
||||||
|
[sys.executable, "-m", "build", "--wheel", "--outdir", str(dist), str(REPO_ROOT)],
|
||||||
|
capture_output=True, text=True, check=False,
|
||||||
|
)
|
||||||
|
if built.returncode != 0:
|
||||||
|
raise AssertionError(f"wheel build failed:\n{built.stderr[-2000:]}")
|
||||||
|
wheels = list(dist.glob("*.whl"))
|
||||||
|
if not wheels:
|
||||||
|
raise AssertionError(f"no wheel produced:\n{built.stdout[-2000:]}")
|
||||||
|
|
||||||
|
# A missing `venv`/`ensurepip` is unsupported optional infra, not a
|
||||||
|
# packaging bug — skip only here.
|
||||||
|
venv = tmp / "venv"
|
||||||
|
made = subprocess.run([sys.executable, "-m", "venv", str(venv)],
|
||||||
|
capture_output=True, text=True, check=False)
|
||||||
|
if made.returncode != 0:
|
||||||
|
raise unittest.SkipTest(f"venv/ensurepip unavailable:\n{made.stderr[-1500:]}")
|
||||||
|
cls.venv_py = venv / "bin" / "python"
|
||||||
|
|
||||||
|
# Installing the freshly-built wheel must succeed — fail if it doesn't.
|
||||||
|
install = subprocess.run(
|
||||||
|
[str(cls.venv_py), "-m", "pip", "install", "--quiet", str(wheels[0])],
|
||||||
|
capture_output=True, text=True, check=False,
|
||||||
|
)
|
||||||
|
if install.returncode != 0:
|
||||||
|
raise AssertionError(f"pip install of the wheel failed:\n{install.stderr[-2000:]}")
|
||||||
|
|
||||||
|
# Isolate the staged build root the wheel writes under the app-data dir.
|
||||||
|
cls.app_root = tmp / "appdata"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def tearDownClass(cls):
|
||||||
|
cls._tmp.cleanup()
|
||||||
|
|
||||||
|
def _run(self, tail: "list[str]") -> "subprocess.CompletedProcess[str]":
|
||||||
|
"""Run the installed venv's python with `tail` appended, from a neutral
|
||||||
|
cwd so the source checkout isn't on sys.path — we must import the
|
||||||
|
*installed* package, not the repo we built from."""
|
||||||
|
env = {"BOT_BOTTLE_ROOT": str(self.app_root), "PATH": "/usr/bin:/bin"}
|
||||||
|
return subprocess.run(
|
||||||
|
[str(self.venv_py), *tail],
|
||||||
|
capture_output=True, text=True, env=env, cwd=self._tmp.name, check=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_console_entry_point_installed(self):
|
||||||
|
# The `bot-bottle` script the wheel declares must exist in the venv.
|
||||||
|
script = self.venv_py.parent / "bot-bottle"
|
||||||
|
self.assertTrue(script.is_file(), "bot-bottle console script not installed")
|
||||||
|
|
||||||
|
def test_doctor_runs_from_installed_package(self):
|
||||||
|
proc = self._run(["-m", "bot_bottle.cli", "doctor"])
|
||||||
|
# doctor exits non-zero here (no backend), but it must RUN and report.
|
||||||
|
self.assertIn("python", proc.stdout)
|
||||||
|
self.assertIn(proc.returncode, (0, 1))
|
||||||
|
|
||||||
|
def test_installed_wheel_is_self_contained(self):
|
||||||
|
# From the installed layout (not a checkout), resources must resolve
|
||||||
|
# Dockerfiles and stage a repo-root-shaped build context.
|
||||||
|
script = (
|
||||||
|
"import bot_bottle.resources as r\n"
|
||||||
|
"assert not r.is_source_checkout(), 'should not look like a checkout'\n"
|
||||||
|
"assert r.dockerfile('Dockerfile.gateway').is_file()\n"
|
||||||
|
"assert r.nix_netpool_module().is_file()\n"
|
||||||
|
"assert r.netpool_script().is_file()\n"
|
||||||
|
"root = r.build_root()\n"
|
||||||
|
"assert (root / 'bot_bottle' / '__init__.py').is_file(), 'no package in context'\n"
|
||||||
|
"assert (root / 'pyproject.toml').is_file(), 'no pyproject in context'\n"
|
||||||
|
"assert (root / 'Dockerfile.gateway').is_file(), 'no Dockerfile in context'\n"
|
||||||
|
"print('SELF_CONTAINED_OK')\n"
|
||||||
|
)
|
||||||
|
proc = self._run(["-c", script])
|
||||||
|
self.assertIn("SELF_CONTAINED_OK", proc.stdout, msg=proc.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user