Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 99040ea0b1 | |||
| 17052cbf88 | |||
| cc166f4b67 | |||
| 6ad817cedd | |||
| 55ee91f356 | |||
| f76180cb57 | |||
| 9c06702b32 | |||
| cd0983d943 | |||
| 99176b1edf | |||
| 652f14dcb1 | |||
| 38c13708c7 | |||
| 82669b22d5 | |||
| 1a4b390e8a | |||
| 955cb3bcbd |
@@ -20,3 +20,9 @@ omit =
|
||||
bot_bottle/cli/tui.py
|
||||
bot_bottle/cli/init.py
|
||||
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
|
||||
|
||||
@@ -17,6 +17,9 @@ __pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.egg-info/
|
||||
# setuptools/build_meta output (wheels, sdists, build tree)
|
||||
/build/
|
||||
/dist/
|
||||
.venv/
|
||||
venv/
|
||||
.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
|
||||
|
||||
[](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)
|
||||
|
||||
**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.
|
||||
|
||||
+11
-75
@@ -23,14 +23,14 @@ from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Generator, Generic, Sequence, TypeVar
|
||||
|
||||
from ..agent_provider import AgentProvisionPlan, get_provider, build_agent_provision_plan
|
||||
from ..agent_provider import AgentProvisionPlan, get_provider
|
||||
from ..egress import EgressPlan
|
||||
from ..git_gate import GitGatePlan
|
||||
from ..log import die, info
|
||||
from ..util import expand_tilde
|
||||
from ..manifest import Manifest, ManifestIndex
|
||||
from ..supervisor.plan import SupervisePlan
|
||||
from ..env import resolve_env, ResolvedEnv
|
||||
from ..env import ResolvedEnv
|
||||
from ..workspace import WorkspacePlan, workspace_plan
|
||||
from .print_util import print_multi, visible_agent_env_names
|
||||
from .util import host_skill_dir
|
||||
@@ -296,82 +296,18 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
||||
backend-specific resolution (names, scratch files, etc.). The
|
||||
validation step is enforced here so a future backend cannot
|
||||
accidentally skip it. No remote/runtime resources are created."""
|
||||
from .resolve_common import (
|
||||
merge_provision_env_vars,
|
||||
mint_slug,
|
||||
prepare_agent_state_dir,
|
||||
prepare_egress,
|
||||
prepare_git_gate,
|
||||
prepare_supervise,
|
||||
reject_nested_containers,
|
||||
resolve_manifest_dockerfile,
|
||||
write_launch_metadata,
|
||||
)
|
||||
|
||||
manifest = self._validate(spec)
|
||||
|
||||
if not self.supports_nested_containers:
|
||||
reject_nested_containers(self.name, manifest)
|
||||
|
||||
self._preflight()
|
||||
|
||||
from ..git_gate import GitGate
|
||||
manifest = GitGate().preflight_host_keys(
|
||||
manifest,
|
||||
headless=spec.headless,
|
||||
home_md=spec.manifest.home_md,
|
||||
)
|
||||
|
||||
manifest_bottle = manifest.bottle
|
||||
manifest_agent_provider = manifest_bottle.agent_provider
|
||||
agent_provider = get_provider(manifest_agent_provider.template)
|
||||
resolved_env = resolve_env(manifest)
|
||||
workspace = workspace_plan(spec, guest_home=agent_provider.guest_home)
|
||||
|
||||
slug = mint_slug(spec)
|
||||
write_launch_metadata(slug, spec, compose_project="", backend=self.name)
|
||||
|
||||
# Manifest may override the Dockerfile per-bottle; otherwise fall
|
||||
# back to the provider plugin's bundled Dockerfile (next to its
|
||||
# agent_provider.py module).
|
||||
if manifest_agent_provider.dockerfile:
|
||||
agent_dockerfile_path = resolve_manifest_dockerfile(
|
||||
manifest_agent_provider.dockerfile, spec,
|
||||
)
|
||||
else:
|
||||
agent_dockerfile_path = str(agent_provider.dockerfile)
|
||||
|
||||
agent_dir, prompt_file = prepare_agent_state_dir(slug, manifest)
|
||||
|
||||
agent_provision_plan = build_agent_provision_plan(
|
||||
template=manifest_agent_provider.template,
|
||||
dockerfile=agent_dockerfile_path,
|
||||
state_dir=agent_dir,
|
||||
instance_name=f"bot-bottle-{slug}",
|
||||
prompt_file=prompt_file,
|
||||
guest_env=self._build_guest_env(resolved_env),
|
||||
forward_host_credentials=manifest_agent_provider.forward_host_credentials,
|
||||
auth_token=manifest_agent_provider.auth_token,
|
||||
host_env=dict(os.environ),
|
||||
trusted_project_path=workspace.workdir,
|
||||
label=spec.label,
|
||||
color=spec.color,
|
||||
provider_settings=manifest_agent_provider.settings,
|
||||
)
|
||||
agent_provision_plan = merge_provision_env_vars(agent_provision_plan)
|
||||
egress_plan = prepare_egress(manifest_bottle, slug, agent_provision_plan)
|
||||
supervise_plan = prepare_supervise(manifest_bottle, slug)
|
||||
git_gate_plan = prepare_git_gate(manifest_bottle, slug)
|
||||
from .preparation import BottlePreparationPlanner
|
||||
prepared = BottlePreparationPlanner(self).prepare(spec)
|
||||
|
||||
return self._resolve_plan(
|
||||
spec,
|
||||
manifest=manifest,
|
||||
slug=slug,
|
||||
resolved_env=resolved_env,
|
||||
agent_provision_plan=agent_provision_plan,
|
||||
egress_plan=egress_plan,
|
||||
supervise_plan=supervise_plan,
|
||||
git_gate_plan=git_gate_plan,
|
||||
manifest=prepared.manifest,
|
||||
slug=prepared.slug,
|
||||
resolved_env=prepared.resolved_env,
|
||||
agent_provision_plan=prepared.agent_provision_plan,
|
||||
egress_plan=prepared.egress_plan,
|
||||
supervise_plan=prepared.supervise_plan,
|
||||
git_gate_plan=prepared.git_gate_plan,
|
||||
stage_dir=stage_dir,
|
||||
)
|
||||
|
||||
|
||||
@@ -10,9 +10,10 @@ from ...paths import (
|
||||
ORCHESTRATOR_AUTH_JWT_ENV,
|
||||
host_gateway_ca_dir,
|
||||
)
|
||||
from ... import resources
|
||||
from ...gateway import (
|
||||
Gateway, GatewayTransport, GATEWAY_IMAGE, GATEWAY_NAME, GATEWAY_NETWORK,
|
||||
GATEWAY_DOCKERFILE, REPO_ROOT, GATEWAY_LABEL, MITMPROXY_HOME,
|
||||
GATEWAY_DOCKERFILE, GATEWAY_LABEL, MITMPROXY_HOME,
|
||||
DEFAULT_CA_TIMEOUT_SECONDS, CA_POLL_SECONDS, GATEWAY_CA_CERT, GatewayError
|
||||
)
|
||||
|
||||
@@ -50,7 +51,9 @@ class DockerGateway(Gateway):
|
||||
# `address` / `stop` work on an already-running gateway without it.
|
||||
self._orchestrator_url = ""
|
||||
self._gateway_token = ""
|
||||
self._build_context = build_context or REPO_ROOT
|
||||
# Resolved lazily in ensure_built() so merely constructing a gateway to
|
||||
# read its CA never stages a build root from an installed wheel.
|
||||
self._build_context = build_context
|
||||
self._dockerfile = dockerfile
|
||||
# Ports published on the host (0.0.0.0). Used by the Firecracker
|
||||
# backend's dev-harness gateway so VMs can reach it via their TAP link;
|
||||
@@ -72,9 +75,10 @@ class DockerGateway(Gateway):
|
||||
forces a full rebuild (parity with `start --no-cache`)."""
|
||||
if self._dockerfile is None:
|
||||
return
|
||||
context = self._build_context or resources.build_root()
|
||||
argv = ["docker", "build", "-t", self.image_ref,
|
||||
"-f", str(self._build_context / self._dockerfile),
|
||||
str(self._build_context)]
|
||||
"-f", str(context / self._dockerfile),
|
||||
str(context)]
|
||||
if os.environ.get("BOT_BOTTLE_NO_CACHE"):
|
||||
argv.insert(2, "--no-cache")
|
||||
proc = run_docker(argv)
|
||||
|
||||
@@ -34,6 +34,7 @@ from .orchestrator import (
|
||||
ORCHESTRATOR_NETWORK,
|
||||
)
|
||||
from ...paths import bot_bottle_root
|
||||
from ... import resources
|
||||
from ...gateway import (
|
||||
GATEWAY_IMAGE,
|
||||
GATEWAY_NAME,
|
||||
@@ -50,8 +51,6 @@ from ...orchestrator.lifecycle import (
|
||||
# the pair's public identity.
|
||||
INFRA_NAME = GATEWAY_NAME # the container agents attribute against is the gateway
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
|
||||
|
||||
class DockerInfraService(InfraService):
|
||||
"""Composes the per-host control plane + gateway as two containers.
|
||||
@@ -68,7 +67,7 @@ class DockerInfraService(InfraService):
|
||||
control_network: str = ORCHESTRATOR_NETWORK,
|
||||
orchestrator_image: str = ORCHESTRATOR_IMAGE,
|
||||
gateway_image: str = GATEWAY_IMAGE,
|
||||
repo_root: Path = _REPO_ROOT,
|
||||
repo_root: Path | None = None,
|
||||
host_root: Path | None = None,
|
||||
orchestrator_name: str = ORCHESTRATOR_NAME,
|
||||
orchestrator_label: str = ORCHESTRATOR_LABEL,
|
||||
@@ -79,7 +78,9 @@ class DockerInfraService(InfraService):
|
||||
self.control_network = control_network
|
||||
self.orchestrator_image = orchestrator_image
|
||||
self.gateway_image = gateway_image
|
||||
self._repo_root = repo_root
|
||||
# Build context / bind-mount source: the repo root in a checkout, a
|
||||
# staged copy from the installed wheel otherwise (bot_bottle.resources).
|
||||
self._repo_root = repo_root if repo_root is not None else resources.build_root()
|
||||
self._host_root = host_root or bot_bottle_root()
|
||||
self._orchestrator_name = orchestrator_name
|
||||
self._orchestrator_label = orchestrator_label
|
||||
|
||||
@@ -33,7 +33,6 @@ from __future__ import annotations
|
||||
import dataclasses
|
||||
import os
|
||||
from contextlib import ExitStack, contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Callable, Generator
|
||||
|
||||
from ...agent_provider import runtime_for
|
||||
@@ -65,10 +64,7 @@ from ...orchestrator.store.config_store import resolve_teardown_timeout
|
||||
from .consolidated_launch import launch_consolidated, deprovision_consolidated
|
||||
from .infra import INFRA_NAME
|
||||
from .gateway import DockerGateway
|
||||
|
||||
|
||||
# Where the repo root lives, for `docker build` context. Computed once.
|
||||
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
|
||||
from ... import resources
|
||||
|
||||
|
||||
def build_or_load_images(plan: DockerBottlePlan) -> BottleImages:
|
||||
@@ -88,7 +84,7 @@ def build_or_load_images(plan: DockerBottlePlan) -> BottleImages:
|
||||
)
|
||||
info(f"using cached agent image {plan.image!r}")
|
||||
return BottleImages(agent=plan.image)
|
||||
docker_mod.build_image(plan.image, _REPO_DIR, dockerfile=plan.dockerfile_path)
|
||||
docker_mod.build_image(plan.image, str(resources.build_root()), dockerfile=plan.dockerfile_path)
|
||||
docker_mod.verify_agent_image(
|
||||
plan.image, runtime_for(plan.agent_provider_template).smoke_test,
|
||||
)
|
||||
|
||||
@@ -15,6 +15,7 @@ import time
|
||||
from pathlib import Path
|
||||
|
||||
from ... import log
|
||||
from ... import resources
|
||||
from .util import run_docker
|
||||
from ...paths import (
|
||||
ORCHESTRATOR_TOKEN_ENV,
|
||||
@@ -55,8 +56,6 @@ _ROOT_IN_CONTAINER = "/bot-bottle-root"
|
||||
|
||||
_HEALTH_POLL_SECONDS = 0.25
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
|
||||
|
||||
class DockerOrchestrator(Orchestrator):
|
||||
"""The control plane as a single fixed-name container. `ensure_built` builds
|
||||
@@ -71,7 +70,7 @@ class DockerOrchestrator(Orchestrator):
|
||||
label: str = ORCHESTRATOR_LABEL,
|
||||
port: int = DEFAULT_PORT,
|
||||
control_network: str = ORCHESTRATOR_NETWORK,
|
||||
repo_root: Path = _REPO_ROOT,
|
||||
repo_root: Path | None = None,
|
||||
host_root: Path | None = None,
|
||||
dockerfile: str | None = ORCHESTRATOR_DOCKERFILE,
|
||||
) -> None:
|
||||
@@ -80,7 +79,9 @@ class DockerOrchestrator(Orchestrator):
|
||||
self.label = label
|
||||
self.port = port
|
||||
self.control_network = control_network
|
||||
self._repo_root = repo_root
|
||||
# Build context / bind-mount source: the repo root in a checkout, a
|
||||
# staged copy from the installed wheel otherwise (bot_bottle.resources).
|
||||
self._repo_root = repo_root if repo_root is not None else resources.build_root()
|
||||
self._host_root = host_root or bot_bottle_root()
|
||||
self._dockerfile = dockerfile
|
||||
|
||||
|
||||
@@ -6,12 +6,17 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from typing import Iterator
|
||||
|
||||
from ...log import die, info
|
||||
from ...util import slugify as _slugify
|
||||
|
||||
|
||||
def slugify(name: str) -> str:
|
||||
"""Compatibility wrapper; new generic callers import ``bot_bottle.util``."""
|
||||
return _slugify(name)
|
||||
|
||||
|
||||
def run_docker(
|
||||
@@ -114,19 +119,6 @@ def docker_cp(src: str, dest: str) -> None:
|
||||
f"{(result.stderr or '').strip() or '<no stderr>'}")
|
||||
|
||||
|
||||
_SLUG_RE = re.compile(r"[^a-z0-9]+")
|
||||
|
||||
|
||||
def slugify(name: str) -> str:
|
||||
"""Lowercase, non-alnum runs → '-', trimmed. Dies on empty result."""
|
||||
if not name:
|
||||
die("slugify: missing name")
|
||||
slug = _SLUG_RE.sub("-", name.lower()).strip("-")
|
||||
if not slug:
|
||||
die(f"name '{name}' produced an empty slug; use alphanumeric characters")
|
||||
return slug
|
||||
|
||||
|
||||
def build_image(ref: str, context: str, *, dockerfile: str = "") -> None:
|
||||
"""Invokes `docker build` every call. Layer cache makes no-change
|
||||
rebuilds cheap; running every time means Dockerfile edits land
|
||||
|
||||
@@ -11,7 +11,8 @@ from pathlib import Path
|
||||
|
||||
from ..bottle_state import egress_state_dir
|
||||
from ..egress import EGRESS_ROUTES_FILENAME
|
||||
from ..gateway.egress.addon_core import LOG_OFF, load_config
|
||||
from ..gateway.egress.schema import load_config
|
||||
from ..gateway.egress.types import LOG_OFF
|
||||
|
||||
|
||||
class EgressApplyError(RuntimeError):
|
||||
|
||||
@@ -37,6 +37,7 @@ import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
from ... import resources
|
||||
from ...log import die, info
|
||||
from . import util
|
||||
|
||||
@@ -44,8 +45,6 @@ from . import util
|
||||
# scheme can't collide with a cached/published artifact of the old one.
|
||||
_ARTIFACT_FORMAT = "1"
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
|
||||
# The two per-plane infra VM roles. Each publishes/pulls its own rootfs artifact
|
||||
# from its own generic package; the Dockerfiles baked into each differ (only the
|
||||
# orchestrator rootfs carries buildah), so the versions are hashed separately.
|
||||
@@ -74,7 +73,7 @@ def local_build_requested() -> bool:
|
||||
|
||||
|
||||
def infra_artifact_version(
|
||||
init_script: str, role: str, *, repo_root: Path = _REPO_ROOT,
|
||||
init_script: str, role: str, *, repo_root: Path | None = None,
|
||||
) -> str:
|
||||
"""Content hash (16 hex) of everything baked into `role`'s infra rootfs: the
|
||||
whole shipped `bot_bottle` package, that role's Dockerfiles, and its guest
|
||||
@@ -89,6 +88,8 @@ def infra_artifact_version(
|
||||
version or a launch host could boot a stale rootfs whose code differs from
|
||||
its checkout. `__pycache__`/`.pyc` are the only exclusions — build artifacts,
|
||||
never copied."""
|
||||
if repo_root is None:
|
||||
repo_root = resources.build_root()
|
||||
h = hashlib.sha256()
|
||||
h.update(f"format={_ARTIFACT_FORMAT}\nrole={role}\n".encode())
|
||||
pkg = repo_root / "bot_bottle"
|
||||
|
||||
@@ -42,6 +42,7 @@ from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Generator
|
||||
|
||||
from ... import resources
|
||||
from ...log import die, info
|
||||
from ..docker import util as docker_mod
|
||||
from . import firecracker_vm, infra_artifact, netpool, util
|
||||
@@ -65,7 +66,6 @@ _GUEST_GATEWAY_JWT_PATH = "/var/lib/bot-bottle/gateway-jwt"
|
||||
_GATEWAY_IMAGE = "bot-bottle-gateway:latest"
|
||||
_ORCHESTRATOR_IMAGE = "bot-bottle-orchestrator:latest"
|
||||
_ORCHESTRATOR_FC_IMAGE = "bot-bottle-orchestrator-fc:latest"
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
|
||||
# Per-role rootfs source image + the extra free space `mke2fs` leaves for the
|
||||
# guest to grow into. The orchestrator keeps buildah's large build slack; the
|
||||
@@ -130,12 +130,13 @@ def build_infra_images_with_docker() -> None:
|
||||
orchestrator + buildah). The gateway VM boots the gateway image directly.
|
||||
The launch host uses this only in `BOT_BOTTLE_INFRA_BUILD=local` mode;
|
||||
`publish_infra` uses it off-host to produce the published artifacts."""
|
||||
root = str(resources.build_root())
|
||||
docker_mod.build_image(
|
||||
_ORCHESTRATOR_IMAGE, str(_REPO_ROOT), dockerfile="Dockerfile.orchestrator")
|
||||
_ORCHESTRATOR_IMAGE, root, dockerfile="Dockerfile.orchestrator")
|
||||
docker_mod.build_image(
|
||||
_GATEWAY_IMAGE, str(_REPO_ROOT), dockerfile="Dockerfile.gateway")
|
||||
_GATEWAY_IMAGE, root, dockerfile="Dockerfile.gateway")
|
||||
docker_mod.build_image(
|
||||
_ORCHESTRATOR_FC_IMAGE, str(_REPO_ROOT), dockerfile="Dockerfile.orchestrator.fc")
|
||||
_ORCHESTRATOR_FC_IMAGE, root, dockerfile="Dockerfile.orchestrator.fc")
|
||||
|
||||
|
||||
def build_rootfs_dir(role: str) -> Path:
|
||||
|
||||
@@ -20,6 +20,7 @@ import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from ... import resources
|
||||
from . import netpool
|
||||
from . import util
|
||||
|
||||
@@ -42,13 +43,13 @@ def _has_systemd() -> bool:
|
||||
|
||||
|
||||
def _module_path() -> str:
|
||||
"""Absolute path to the importable NixOS module in this checkout."""
|
||||
return str(Path(__file__).resolve().parents[3] / "nix" / "firecracker-netpool.nix")
|
||||
"""Absolute path to the importable NixOS module (checkout or wheel)."""
|
||||
return str(resources.nix_netpool_module())
|
||||
|
||||
|
||||
def _script_path() -> str:
|
||||
"""Absolute path to the bundled bring-up script in this checkout."""
|
||||
return str(Path(__file__).resolve().parents[3] / "scripts" / "firecracker-netpool.sh")
|
||||
"""Absolute path to the bundled bring-up script (checkout or wheel)."""
|
||||
return str(resources.netpool_script())
|
||||
|
||||
|
||||
def _print_prereqs() -> None:
|
||||
|
||||
@@ -28,6 +28,7 @@ from ...paths import (
|
||||
ORCHESTRATOR_AUTH_JWT_ENV,
|
||||
host_gateway_ca_dir,
|
||||
)
|
||||
from ... import resources
|
||||
from .. import util as backend_util
|
||||
from . import util as container_mod
|
||||
|
||||
@@ -52,8 +53,6 @@ GATEWAY_DAEMONS = "egress,git-http,supervise"
|
||||
|
||||
GATEWAY_IMAGE = os.environ.get("BOT_BOTTLE_GATEWAY_IMAGE", "bot-bottle-gateway:latest")
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
|
||||
|
||||
def ensure_networks(
|
||||
network: str = GATEWAY_NETWORK,
|
||||
@@ -84,14 +83,16 @@ class MacosGateway(Gateway):
|
||||
network: str = GATEWAY_NETWORK,
|
||||
egress_network: str = GATEWAY_EGRESS_NETWORK,
|
||||
control_network: str = CONTROL_NETWORK,
|
||||
repo_root: Path = _REPO_ROOT,
|
||||
repo_root: Path | None = None,
|
||||
) -> None:
|
||||
self.image_ref = image_ref
|
||||
self.name = name
|
||||
self.network = network
|
||||
self.egress_network = egress_network
|
||||
self.control_network = control_network
|
||||
self._repo_root = repo_root
|
||||
# Build context: the repo root in a checkout, a staged copy from the
|
||||
# installed wheel otherwise (bot_bottle.resources).
|
||||
self._repo_root = repo_root if repo_root is not None else resources.build_root()
|
||||
# Set by `connect_to_orchestrator`: the URL the daemons resolve policy
|
||||
# against + the pre-minted `gateway` token they present. The gateway
|
||||
# never mints, so it never holds the signing key (#469).
|
||||
|
||||
@@ -24,6 +24,7 @@ from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from ... import resources
|
||||
from ...orchestrator.lifecycle import (
|
||||
DEFAULT_PORT,
|
||||
DEFAULT_STARTUP_TIMEOUT_SECONDS,
|
||||
@@ -53,8 +54,6 @@ from .orchestrator import (
|
||||
# still import it (probe / reprovision attribute against the gateway).
|
||||
INFRA_NAME = GATEWAY_NAME
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
|
||||
|
||||
class MacosInfraService(InfraService):
|
||||
"""Composes the per-host orchestrator + gateway containers. Callers use
|
||||
@@ -70,7 +69,7 @@ class MacosInfraService(InfraService):
|
||||
control_network: str = CONTROL_NETWORK,
|
||||
gateway_image: str = GATEWAY_IMAGE,
|
||||
orchestrator_image: str = ORCHESTRATOR_IMAGE,
|
||||
repo_root: Path = _REPO_ROOT,
|
||||
repo_root: Path | None = None,
|
||||
orchestrator_name: str = ORCHESTRATOR_NAME,
|
||||
gateway_name: str = INFRA_NAME,
|
||||
db_volume: str = ORCHESTRATOR_DB_VOLUME,
|
||||
@@ -81,7 +80,9 @@ class MacosInfraService(InfraService):
|
||||
self.control_network = control_network
|
||||
self.gateway_image = gateway_image
|
||||
self.orchestrator_image = orchestrator_image
|
||||
self._repo_root = repo_root
|
||||
# Build context / bind-mount source: the repo root in a checkout, a
|
||||
# staged copy from the installed wheel otherwise (bot_bottle.resources).
|
||||
self._repo_root = repo_root if repo_root is not None else resources.build_root()
|
||||
self._orchestrator_name = orchestrator_name
|
||||
self._gateway_name = gateway_name
|
||||
self._db_volume = db_volume
|
||||
|
||||
@@ -36,7 +36,6 @@ import dataclasses
|
||||
import os
|
||||
import subprocess
|
||||
from contextlib import ExitStack, contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Callable, Generator
|
||||
|
||||
from ...bottle_state import (
|
||||
@@ -49,6 +48,7 @@ from ...git_gate import GitGate
|
||||
from ...gateway.git_gate.http_backend import DEFAULT_PORT as _GIT_HTTP_PORT
|
||||
from ...image_cache import check_stale
|
||||
from ...log import die, info, warn
|
||||
from ... import resources
|
||||
from .. import BottleImages
|
||||
from ...supervisor.types import SUPERVISE_PORT
|
||||
from ..docker.egress import EGRESS_PORT
|
||||
@@ -71,7 +71,6 @@ from .consolidated_launch import (
|
||||
deprovision_consolidated,
|
||||
)
|
||||
|
||||
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
|
||||
_AGENT_SLEEP_SECONDS = "2147483647"
|
||||
|
||||
|
||||
@@ -94,7 +93,7 @@ def _agent_image(plan: MacosContainerBottlePlan) -> str:
|
||||
)
|
||||
info(f"using cached agent image {plan.image!r}")
|
||||
return plan.image
|
||||
container_mod.build_image(plan.image, _REPO_DIR, dockerfile=plan.dockerfile_path)
|
||||
container_mod.build_image(plan.image, str(resources.build_root()), dockerfile=plan.dockerfile_path)
|
||||
return plan.image
|
||||
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
from ... import log
|
||||
from ... import resources
|
||||
from ...paths import ORCHESTRATOR_TOKEN_ENV
|
||||
from ...orchestrator.lifecycle import (
|
||||
DEFAULT_HEALTH_TIMEOUT_SECONDS,
|
||||
@@ -45,7 +46,6 @@ _DB_ROOT_IN_CONTAINER = "/var/lib/bot-bottle"
|
||||
_SRC_IN_CONTAINER = "/bot-bottle-src"
|
||||
|
||||
_HEALTH_POLL_SECONDS = 0.25
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
|
||||
|
||||
class MacosOrchestrator(Orchestrator):
|
||||
@@ -61,7 +61,7 @@ class MacosOrchestrator(Orchestrator):
|
||||
label: str = ORCHESTRATOR_LABEL,
|
||||
port: int = DEFAULT_PORT,
|
||||
control_network: str = CONTROL_NETWORK,
|
||||
repo_root: Path = _REPO_ROOT,
|
||||
repo_root: Path | None = None,
|
||||
db_volume: str = ORCHESTRATOR_DB_VOLUME,
|
||||
) -> None:
|
||||
self.image_ref = image_ref
|
||||
@@ -69,7 +69,9 @@ class MacosOrchestrator(Orchestrator):
|
||||
self.label = label
|
||||
self.port = port
|
||||
self.control_network = control_network
|
||||
self._repo_root = repo_root
|
||||
# Build context / bind-mount source: the repo root in a checkout, a
|
||||
# staged copy from the installed wheel otherwise (bot_bottle.resources).
|
||||
self._repo_root = repo_root if repo_root is not None else resources.build_root()
|
||||
self._db_volume = db_volume
|
||||
|
||||
def url(self) -> str:
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Backend-neutral preparation planner.
|
||||
|
||||
This module owns the shared transformation from a CLI ``BottleSpec`` to the
|
||||
typed inputs consumed by a concrete backend's ``_resolve_plan``. Backend
|
||||
classes retain only their validation/preflight/env hooks and their
|
||||
backend-specific final resolution.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Protocol
|
||||
|
||||
from ..agent_provider import AgentProvisionPlan, build_agent_provision_plan, get_provider
|
||||
from ..egress import EgressPlan
|
||||
from ..env import ResolvedEnv, resolve_env
|
||||
from ..git_gate import GitGate, GitGatePlan
|
||||
from ..manifest import Manifest
|
||||
from ..supervisor.plan import SupervisePlan
|
||||
from ..workspace import workspace_plan
|
||||
from .resolve_common import (
|
||||
merge_provision_env_vars,
|
||||
mint_slug,
|
||||
prepare_agent_state_dir,
|
||||
prepare_egress,
|
||||
prepare_git_gate,
|
||||
prepare_supervise,
|
||||
reject_nested_containers,
|
||||
resolve_manifest_dockerfile,
|
||||
write_launch_metadata,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .base import BottleSpec
|
||||
|
||||
|
||||
class PreparationBackend(Protocol):
|
||||
"""Backend hooks needed by the shared planner."""
|
||||
|
||||
name: str
|
||||
supports_nested_containers: bool
|
||||
|
||||
def _validate(self, spec: BottleSpec) -> Manifest: ...
|
||||
def _preflight(self) -> None: ...
|
||||
def _build_guest_env(self, resolved_env: ResolvedEnv) -> dict[str, str]: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PreparedBottle:
|
||||
"""Typed, backend-neutral result of shared launch preparation."""
|
||||
|
||||
manifest: Manifest
|
||||
slug: str
|
||||
resolved_env: ResolvedEnv
|
||||
agent_provision_plan: AgentProvisionPlan
|
||||
egress_plan: EgressPlan
|
||||
git_gate_plan: GitGatePlan
|
||||
supervise_plan: SupervisePlan | None
|
||||
|
||||
|
||||
class BottlePreparationPlanner:
|
||||
"""Run the common, side-effect-limited part of bottle preparation."""
|
||||
|
||||
def __init__(self, backend: PreparationBackend) -> None:
|
||||
self._backend = backend
|
||||
|
||||
def prepare(self, spec: BottleSpec) -> PreparedBottle:
|
||||
backend = self._backend
|
||||
# These are deliberately protected backend hooks: only this shared
|
||||
# planner orchestrates them, while concrete backends provide the
|
||||
# implementation.
|
||||
manifest = backend._validate(spec) # pylint: disable=protected-access
|
||||
if not backend.supports_nested_containers:
|
||||
reject_nested_containers(backend.name, manifest)
|
||||
|
||||
backend._preflight() # pylint: disable=protected-access
|
||||
manifest = GitGate().preflight_host_keys(
|
||||
manifest,
|
||||
headless=spec.headless,
|
||||
home_md=spec.manifest.home_md,
|
||||
)
|
||||
|
||||
bottle = manifest.bottle
|
||||
provider_config = bottle.agent_provider
|
||||
provider = get_provider(provider_config.template)
|
||||
resolved_env = resolve_env(manifest)
|
||||
workspace = workspace_plan(spec, guest_home=provider.guest_home)
|
||||
slug = mint_slug(spec)
|
||||
write_launch_metadata(slug, spec, compose_project="", backend=backend.name)
|
||||
|
||||
dockerfile = (
|
||||
resolve_manifest_dockerfile(provider_config.dockerfile, spec)
|
||||
if provider_config.dockerfile
|
||||
else str(provider.dockerfile)
|
||||
)
|
||||
agent_dir, prompt_file = prepare_agent_state_dir(slug, manifest)
|
||||
provision = build_agent_provision_plan(
|
||||
template=provider_config.template,
|
||||
dockerfile=dockerfile,
|
||||
state_dir=agent_dir,
|
||||
instance_name=f"bot-bottle-{slug}",
|
||||
prompt_file=prompt_file,
|
||||
guest_env=backend._build_guest_env( # pylint: disable=protected-access
|
||||
resolved_env
|
||||
),
|
||||
forward_host_credentials=provider_config.forward_host_credentials,
|
||||
auth_token=provider_config.auth_token,
|
||||
host_env=dict(os.environ),
|
||||
trusted_project_path=workspace.workdir,
|
||||
label=spec.label,
|
||||
color=spec.color,
|
||||
provider_settings=provider_config.settings,
|
||||
)
|
||||
provision = merge_provision_env_vars(provision)
|
||||
return PreparedBottle(
|
||||
manifest=manifest,
|
||||
slug=slug,
|
||||
resolved_env=resolved_env,
|
||||
agent_provision_plan=provision,
|
||||
egress_plan=prepare_egress(bottle, slug, provision),
|
||||
git_gate_plan=prepare_git_gate(bottle, slug),
|
||||
supervise_plan=prepare_supervise(bottle, slug),
|
||||
)
|
||||
@@ -30,6 +30,7 @@ from ..log import die
|
||||
from ..manifest import Manifest, ManifestBottle
|
||||
from ..supervisor.plan import SupervisePlan
|
||||
from ..orchestrator.supervisor import Supervisor
|
||||
from ..util import slugify
|
||||
from . import BottleSpec
|
||||
|
||||
|
||||
@@ -44,8 +45,7 @@ def mint_slug(spec: BottleSpec) -> str:
|
||||
if spec.identity:
|
||||
return spec.identity
|
||||
if spec.label:
|
||||
from .docker import util as docker_mod
|
||||
return docker_mod.slugify(spec.label)
|
||||
return slugify(spec.label)
|
||||
return bottle_identity(spec.agent_name)
|
||||
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ _HANDLERS: dict[str, str] = {
|
||||
"backend": "backend:cmd_backend",
|
||||
"cleanup": "cleanup:cmd_cleanup",
|
||||
"commit": "commit:cmd_commit",
|
||||
"doctor": "doctor:cmd_doctor",
|
||||
"edit": "edit:cmd_edit",
|
||||
"help": "help:cmd_help",
|
||||
"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
|
||||
# isn't a TTY and the migration prompt can't be answered. `help` and `login`
|
||||
# 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"]
|
||||
|
||||
@@ -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(" 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(" doctor check host prerequisites (Python, backend, config dir)\n")
|
||||
w(" edit open an agent in vim for editing\n")
|
||||
w(" help show this command list\n")
|
||||
w(" init interactively create a new agent and add it to bot-bottle.json\n")
|
||||
|
||||
@@ -25,12 +25,11 @@ from typing import Callable
|
||||
from ...agent_provider import get_provider, runtime_for
|
||||
from ...backend import (
|
||||
Bottle,
|
||||
BottlePlan,
|
||||
BottleSpec,
|
||||
enumerate_active_agents,
|
||||
get_bottle_backend,
|
||||
)
|
||||
from ...backend.docker import util as docker_mod
|
||||
from ...backend.docker.bottle_plan import DockerBottlePlan
|
||||
from ...bottle_state import (
|
||||
cleanup_state,
|
||||
is_preserved,
|
||||
@@ -40,7 +39,7 @@ from ...image_cache import StaleImageError
|
||||
from ...log import info, die
|
||||
from ...manifest import Manifest, ManifestIndex
|
||||
from ..constants import PROG
|
||||
from ...util import read_tty_line
|
||||
from ...util import read_tty_line, slugify
|
||||
from .. import tui
|
||||
|
||||
|
||||
@@ -257,10 +256,10 @@ def _uniquify_label_headless(label: str) -> str:
|
||||
logging the chosen label. Orchestrators fire-and-forget many bottles,
|
||||
so silently picking a free name beats erroring on every collision."""
|
||||
active_slugs = {a.slug for a in enumerate_active_agents()}
|
||||
if docker_mod.slugify(label) not in active_slugs:
|
||||
if slugify(label) not in active_slugs:
|
||||
return label
|
||||
n = 2
|
||||
while docker_mod.slugify(f"{label}-{n}") in active_slugs:
|
||||
while slugify(f"{label}-{n}") in active_slugs:
|
||||
n += 1
|
||||
chosen = f"{label}-{n}"
|
||||
info(f"label '{label}' already in use; using '{chosen}'")
|
||||
@@ -274,11 +273,11 @@ def prepare_with_preflight(
|
||||
spec: BottleSpec,
|
||||
*,
|
||||
stage_dir: Path,
|
||||
render_preflight: Callable[[DockerBottlePlan, str], None],
|
||||
render_preflight: Callable[[BottlePlan, str], None],
|
||||
prompt_yes: Callable[[], bool],
|
||||
dry_run: bool = False,
|
||||
backend_name: str | None = None,
|
||||
) -> tuple[DockerBottlePlan | None, str]:
|
||||
) -> tuple[BottlePlan | None, str]:
|
||||
"""Run `backend.prepare`, render the preflight summary via the
|
||||
injected callable, prompt y/N via the injected callable.
|
||||
|
||||
@@ -405,7 +404,7 @@ def _resolve_unique_label(label: str, color: str) -> tuple[str, str]:
|
||||
in use among running bottles. Passes through unchanged when no
|
||||
collision is found on the first check."""
|
||||
while True:
|
||||
slug_candidate = docker_mod.slugify(label)
|
||||
slug_candidate = slugify(label)
|
||||
active_slugs = {a.slug for a in enumerate_active_agents()}
|
||||
if slug_candidate not in active_slugs:
|
||||
return label, color
|
||||
@@ -432,7 +431,7 @@ def _select_image_policy() -> str | None:
|
||||
|
||||
|
||||
def _text_render_preflight():
|
||||
def _render(plan: DockerBottlePlan, backend_name: str) -> None:
|
||||
def _render(plan: BottlePlan, backend_name: str) -> None:
|
||||
print(file=sys.stderr)
|
||||
print(f"backend: {backend_name}", file=sys.stderr)
|
||||
print(_manifest_to_yaml(plan.manifest), file=sys.stderr)
|
||||
|
||||
@@ -368,7 +368,7 @@ def _main_loop(stdscr: "curses._CursesWindow") -> None: # type: ignore # pragm
|
||||
elif key in (curses.KEY_UP, ord("k")):
|
||||
selected = max(selected - 1, 0)
|
||||
elif key in (curses.KEY_ENTER, 10, 13):
|
||||
_detail_view(stdscr, qp, green_attr=green_attr)
|
||||
status_line = _detail_view(stdscr, qp, green_attr=green_attr)
|
||||
elif key == ord("a"):
|
||||
try:
|
||||
status_line = _approve_from_tui(stdscr, qp)
|
||||
@@ -456,7 +456,7 @@ def _detail_view(
|
||||
qp: QueuedProposal,
|
||||
*,
|
||||
green_attr: int = 0,
|
||||
) -> None: # pragma: no cover
|
||||
) -> str: # pragma: no cover
|
||||
"""Render the full proposal. Scrollable. Press q to return."""
|
||||
lines = _detail_lines(qp, green_attr=green_attr)
|
||||
offset = 0
|
||||
@@ -473,7 +473,7 @@ def _detail_view(
|
||||
stdscr.refresh()
|
||||
key = stdscr.getch()
|
||||
if key in (ord("q"), 27):
|
||||
return
|
||||
return ""
|
||||
if key in (curses.KEY_DOWN, ord("j")):
|
||||
offset = min(offset + 1, max(0, len(lines) - 1))
|
||||
elif key in (curses.KEY_UP, ord("k")):
|
||||
@@ -484,31 +484,34 @@ def _detail_view(
|
||||
offset = max(0, len(lines) - 1)
|
||||
elif key == ord("a"):
|
||||
try:
|
||||
_approve_from_tui(stdscr, qp)
|
||||
except ApplyError:
|
||||
pass
|
||||
return
|
||||
return _approve_from_tui(stdscr, qp)
|
||||
except ApplyError as exc:
|
||||
return f"apply failed: {exc}"
|
||||
elif key == ord("m"):
|
||||
if qp.proposal.tool in _REPORT_ONLY_TOOLS:
|
||||
return
|
||||
return f"modify unavailable for {qp.proposal.tool}"
|
||||
edited = _modify(stdscr, qp)
|
||||
if edited is not None:
|
||||
try:
|
||||
_approve_from_tui(
|
||||
stdscr, qp, final_file=edited,
|
||||
notes="operator modified before approving",
|
||||
)
|
||||
except ApplyError:
|
||||
pass
|
||||
return
|
||||
if edited is None:
|
||||
return "modify aborted (no change)"
|
||||
try:
|
||||
return _approve_from_tui(
|
||||
stdscr, qp, final_file=edited,
|
||||
notes="operator modified before approving",
|
||||
)
|
||||
except ApplyError as exc:
|
||||
return f"apply failed: {exc}"
|
||||
elif key == ord("r"):
|
||||
reason = _prompt(stdscr, "reject reason: ")
|
||||
if reason:
|
||||
reject(qp, reason=reason)
|
||||
return
|
||||
return f"rejected {qp.proposal.tool} for [{qp.label}]"
|
||||
return "reject aborted (empty reason)"
|
||||
|
||||
|
||||
def _modify(stdscr: "curses._CursesWindow", qp: QueuedProposal) -> str | None: # type: ignore # pragma: no cover
|
||||
def _modify(
|
||||
stdscr: "curses._CursesWindow", # type: ignore
|
||||
qp: QueuedProposal,
|
||||
) -> str | None: # pragma: no cover
|
||||
"""Suspend curses, open $EDITOR on the proposed file, return edited content."""
|
||||
suffix = _suffix_for_tool(qp.proposal.tool)
|
||||
curses.endwin()
|
||||
|
||||
+32
-6
@@ -16,6 +16,8 @@ import os
|
||||
import sys
|
||||
from typing import Any, Optional
|
||||
|
||||
from ..log import debug
|
||||
|
||||
|
||||
def filter_multiselect(
|
||||
items: list[str],
|
||||
@@ -42,7 +44,11 @@ def filter_multiselect(
|
||||
|
||||
try:
|
||||
tty_fd = open(tty_path, "r+b", buffering=0)
|
||||
except OSError:
|
||||
except OSError as exc:
|
||||
debug(
|
||||
"multi-select unavailable; treating it as cancellation",
|
||||
context={"error_type": type(exc).__name__, "tty": tty_path},
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
@@ -73,7 +79,11 @@ def filter_select(
|
||||
|
||||
try:
|
||||
tty_fd = open(tty_path, "r+b", buffering=0)
|
||||
except OSError:
|
||||
except OSError as exc:
|
||||
debug(
|
||||
"filter-select unavailable; treating it as cancellation",
|
||||
context={"error_type": type(exc).__name__, "tty": tty_path},
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
@@ -129,7 +139,11 @@ def _run_picker(items: list[str], *, title: str, tty_fd: int) -> Optional[str]:
|
||||
curses.nocbreak()
|
||||
curses.echo()
|
||||
curses.endwin()
|
||||
except Exception: # noqa: W0718 — curses can raise many error types
|
||||
except Exception as exc: # noqa: W0718 — curses can raise many error types
|
||||
debug(
|
||||
"filter-select display failed; treating it as cancellation",
|
||||
context={"error_type": type(exc).__name__},
|
||||
)
|
||||
return None
|
||||
finally:
|
||||
sys.__stdin__ = orig_stdin # type: ignore[assignment]
|
||||
@@ -292,7 +306,11 @@ def _run_multiselect(
|
||||
curses.nocbreak()
|
||||
curses.echo()
|
||||
curses.endwin()
|
||||
except Exception: # noqa: W0718
|
||||
except Exception as exc: # noqa: W0718
|
||||
debug(
|
||||
"multi-select display failed; treating it as cancellation",
|
||||
context={"error_type": type(exc).__name__},
|
||||
)
|
||||
return None
|
||||
finally:
|
||||
sys.__stdin__ = orig_stdin # type: ignore[assignment]
|
||||
@@ -558,13 +576,21 @@ def name_color_modal(
|
||||
"""
|
||||
try:
|
||||
tty_fd = open(tty_path, "r+b", buffering=0) # pylint: disable=consider-using-with
|
||||
except OSError:
|
||||
except OSError as exc:
|
||||
debug(
|
||||
"name/color picker unavailable; using defaults",
|
||||
context={"error_type": type(exc).__name__, "tty": tty_path},
|
||||
)
|
||||
return default_label, ""
|
||||
|
||||
try:
|
||||
fd_dup = os.dup(tty_fd.fileno())
|
||||
return _run_name_color(default_label, tty_fd=fd_dup, disclaimer=disclaimer)
|
||||
except Exception: # noqa: BLE001 # pylint: disable=broad-exception-caught
|
||||
except Exception as exc: # noqa: BLE001 # pylint: disable=broad-exception-caught
|
||||
debug(
|
||||
"name/color picker failed; using defaults",
|
||||
context={"error_type": type(exc).__name__},
|
||||
)
|
||||
return default_label, ""
|
||||
finally:
|
||||
tty_fd.close()
|
||||
|
||||
@@ -11,7 +11,7 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from ..gateway.egress.addon_core import Route
|
||||
from ..gateway.egress.types import Route
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -19,7 +19,7 @@ class EgressRoute(Route):
|
||||
"""Host-side extension of the addon's `Route`.
|
||||
|
||||
Inherits `host`, `matches`, `auth_scheme`, and `token_env`
|
||||
from `egress_addon_core.Route` — those are the fields that cross the
|
||||
from the gateway's wire `Route` — those are the fields that cross the
|
||||
YAML wire into the gateway. The fields below are host-only and
|
||||
are never serialised to the addon.
|
||||
|
||||
|
||||
@@ -14,8 +14,8 @@ import secrets
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ..gateway.egress.addon_core import (
|
||||
ON_MATCH_REDACT,
|
||||
from ..gateway.egress.dlp_config import ON_MATCH_REDACT
|
||||
from ..gateway.egress.types import (
|
||||
HeaderMatch as CoreHeaderMatch,
|
||||
MatchEntry as CoreMatchEntry,
|
||||
PathMatch as CorePathMatch,
|
||||
|
||||
@@ -62,7 +62,6 @@ GATEWAY_CA_GLOB = "mitmproxy-ca*"
|
||||
# that lands. Env override matches the backend's BOT_BOTTLE_GATEWAY_IMAGE.
|
||||
GATEWAY_IMAGE = os.environ.get("BOT_BOTTLE_GATEWAY_IMAGE", "bot-bottle-gateway:latest")
|
||||
GATEWAY_DOCKERFILE = "Dockerfile.gateway"
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def rotate_gateway_ca(ca_dir: Path | None = None) -> list[Path]:
|
||||
|
||||
@@ -17,28 +17,34 @@ from mitmproxy import http # type: ignore[import-not-found] # pylint: disable=
|
||||
|
||||
from bot_bottle.constants import IDENTITY_HEADER
|
||||
from bot_bottle.gateway.egress.dlp_detectors import redact_tokens, strip_crlf
|
||||
from bot_bottle.gateway.egress.addon_core import (
|
||||
LOG_BLOCKS,
|
||||
LOG_FULL,
|
||||
from bot_bottle.gateway.egress.dlp_config import (
|
||||
DEFAULT_OUTBOUND_ON_MATCH,
|
||||
ON_MATCH_BLOCK,
|
||||
ON_MATCH_REDACT,
|
||||
Config,
|
||||
Route,
|
||||
ScanResult,
|
||||
)
|
||||
from bot_bottle.gateway.egress.context import resolve_client_context
|
||||
from bot_bottle.gateway.egress.dlp import (
|
||||
build_inbound_scan_text,
|
||||
build_outbound_scan_text,
|
||||
build_token_allow_payload,
|
||||
outbound_scan_headers,
|
||||
scan_inbound,
|
||||
scan_outbound,
|
||||
)
|
||||
from bot_bottle.gateway.egress.matching import (
|
||||
decide,
|
||||
decide_git_fetch,
|
||||
is_git_fetch_request,
|
||||
is_git_push_request,
|
||||
match_route,
|
||||
resolve_client_context,
|
||||
outbound_scan_headers,
|
||||
route_to_yaml_dict,
|
||||
scan_inbound,
|
||||
scan_outbound,
|
||||
)
|
||||
from bot_bottle.gateway.egress.schema import route_to_yaml_dict
|
||||
from bot_bottle.gateway.egress.types import (
|
||||
LOG_BLOCKS,
|
||||
LOG_FULL,
|
||||
Config,
|
||||
Route,
|
||||
ScanResult,
|
||||
)
|
||||
from bot_bottle.gateway.policy_resolver import PolicyResolveError, PolicyResolver
|
||||
from bot_bottle.supervisor.types import (
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,77 @@
|
||||
"""Fail-closed resolution of a client's policy and egress credentials."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
|
||||
from ...log import debug
|
||||
from .types import Config
|
||||
|
||||
|
||||
DENY_UNATTRIBUTED = (
|
||||
"egress: this request was not attributed to any bottle, so no egress policy "
|
||||
"applies and every host is denied. Either the bottle's registry row is "
|
||||
"missing/ambiguous (torn down, or another bottle claimed its source IP), or "
|
||||
"the request carried no matching identity token — check that the caller's "
|
||||
"proxy URL includes it. This is not an allowlist problem."
|
||||
)
|
||||
DENY_UNPARSEABLE = (
|
||||
"egress: this bottle's egress policy could not be parsed, so it is being "
|
||||
"treated as deny-all. Fix the bottle's egress.routes; every host is denied "
|
||||
"until it loads."
|
||||
)
|
||||
DENY_RESOLVER_ERROR = (
|
||||
"egress: the orchestrator could not be reached to resolve this bottle's "
|
||||
"egress policy, so every host is denied (fail-closed). Check that the "
|
||||
"control plane is up; this is not an allowlist problem."
|
||||
)
|
||||
|
||||
|
||||
class PolicyResolverLike(typing.Protocol):
|
||||
def resolve(self, source_ip: str, identity_token: str = ...) -> str | None: ...
|
||||
|
||||
|
||||
class ContextResolverLike(typing.Protocol):
|
||||
def resolve_policy_and_bottle_id(
|
||||
self, source_ip: str, identity_token: str = ...,
|
||||
) -> tuple[str | None, str | None, dict[str, str]]: ...
|
||||
|
||||
|
||||
def _config_from_policy(policy: str | None) -> Config:
|
||||
# Local import keeps schema parsing independent of resolver protocols.
|
||||
from .schema import load_config
|
||||
if not policy:
|
||||
return Config(routes=(), deny_reason=DENY_UNATTRIBUTED)
|
||||
try:
|
||||
return load_config(policy)
|
||||
except ValueError:
|
||||
return Config(routes=(), deny_reason=DENY_UNPARSEABLE)
|
||||
|
||||
|
||||
def resolve_client_config(
|
||||
resolver: PolicyResolverLike, client_ip: str, identity_token: str = "",
|
||||
) -> Config:
|
||||
try:
|
||||
policy = resolver.resolve(client_ip, identity_token)
|
||||
except Exception as exc: # noqa: BLE001 - a policy lookup failure must deny
|
||||
debug(
|
||||
"egress policy resolution failed; applying deny-all",
|
||||
context={"error_type": type(exc).__name__},
|
||||
)
|
||||
return Config(routes=(), deny_reason=DENY_RESOLVER_ERROR)
|
||||
return _config_from_policy(policy)
|
||||
|
||||
|
||||
def resolve_client_context(
|
||||
resolver: ContextResolverLike, client_ip: str, identity_token: str = "",
|
||||
) -> tuple[Config, str, dict[str, str]]:
|
||||
try:
|
||||
policy, bottle_id, tokens = resolver.resolve_policy_and_bottle_id(
|
||||
client_ip, identity_token)
|
||||
except Exception as exc: # noqa: BLE001 - a policy lookup failure must deny
|
||||
debug(
|
||||
"egress context resolution failed; applying deny-all",
|
||||
context={"error_type": type(exc).__name__},
|
||||
)
|
||||
return Config(routes=(), deny_reason=DENY_RESOLVER_ERROR), "", {}
|
||||
return _config_from_policy(policy), (bottle_id or ""), tokens
|
||||
@@ -0,0 +1,99 @@
|
||||
"""DLP scan dispatch and safe proposal rendering for egress requests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
|
||||
from .types import Route, ScanResult
|
||||
|
||||
|
||||
def build_outbound_scan_text(host: str, path: str, query: str,
|
||||
headers: typing.Mapping[str, str], body: str) -> str:
|
||||
parts = [host, path]
|
||||
if query:
|
||||
parts.append(query)
|
||||
parts.extend(f"{name}: {value}" for name, value in headers.items())
|
||||
if body:
|
||||
parts.append(body)
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def outbound_scan_headers(route: Route, headers: typing.Mapping[str, str]) -> dict[str, str]:
|
||||
"""Drop agent Authorization when the route injects gateway-owned auth."""
|
||||
skip_auth = bool(route.auth_scheme and route.token_env)
|
||||
return {name: value for name, value in headers.items()
|
||||
if not (skip_auth and name.lower() == "authorization")}
|
||||
|
||||
|
||||
def build_inbound_scan_text(headers: typing.Mapping[str, str], body: str) -> str:
|
||||
parts = [f"{name}: {value}" for name, value in headers.items()]
|
||||
if body:
|
||||
parts.append(body)
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def _enabled(configured: tuple[str, ...] | None, name: str) -> bool:
|
||||
return configured is None or name in configured
|
||||
|
||||
|
||||
def scan_outbound(route: Route, body: str | bytes, environ: typing.Mapping[str, str], *,
|
||||
safe_tokens: typing.AbstractSet[str] | None = None,
|
||||
crlf_text: str | None = None) -> ScanResult | None:
|
||||
if not route.inspect:
|
||||
return None
|
||||
try:
|
||||
from dlp_detectors import ( # type: ignore[import-not-found]
|
||||
scan_crlf_injection, scan_entropy, scan_known_secrets, scan_token_patterns)
|
||||
except ImportError: # pragma: no cover - gateway's flat module path
|
||||
from .dlp_detectors import (
|
||||
scan_crlf_injection, scan_entropy, scan_known_secrets, scan_token_patterns)
|
||||
if isinstance(body, bytes):
|
||||
try:
|
||||
text = body.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
text = body.decode("latin-1")
|
||||
else:
|
||||
text = body
|
||||
result = scan_crlf_injection(text if crlf_text is None else crlf_text)
|
||||
if result is not None:
|
||||
return result
|
||||
if _enabled(route.outbound_detectors, "token_patterns"):
|
||||
result = scan_token_patterns(text, location="body", safe_tokens=safe_tokens)
|
||||
if result is not None:
|
||||
return result
|
||||
if _enabled(route.outbound_detectors, "known_secrets"):
|
||||
extra = tuple(prefix for prefix in environ.get(
|
||||
"BOT_BOTTLE_SENSITIVE_PREFIXES", "").split(",") if prefix)
|
||||
result = scan_known_secrets(text, location="body", env=environ,
|
||||
sensitive_prefixes=("EGRESS_TOKEN_",) + extra,
|
||||
safe_tokens=safe_tokens)
|
||||
if result is not None:
|
||||
return result
|
||||
if route.outbound_detectors is not None and "entropy" in route.outbound_detectors:
|
||||
return scan_entropy(text, location="body")
|
||||
return None
|
||||
|
||||
|
||||
def build_token_allow_payload(host: str, method: str, path: str, result: ScanResult) -> str:
|
||||
"""Render redacted operator context; the raw matched secret is excluded."""
|
||||
lines = [
|
||||
"egress blocked an outbound request carrying a detected token",
|
||||
f"host: {host}", f"method: {method}", f"path: {path}",
|
||||
f"detector: {result.reason}",
|
||||
]
|
||||
if result.context:
|
||||
lines.append(f"context: {result.context}")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def scan_inbound(route: Route, body: str | bytes) -> ScanResult | None:
|
||||
if not route.inspect:
|
||||
return None
|
||||
try:
|
||||
from dlp_detectors import scan_naive_injection # type: ignore[import-not-found]
|
||||
except ImportError: # pragma: no cover - gateway's flat module path
|
||||
from .dlp_detectors import scan_naive_injection
|
||||
text = body if isinstance(body, str) else body.decode("utf-8", errors="replace")
|
||||
if _enabled(route.inbound_detectors, "naive_injection_detection"):
|
||||
return scan_naive_injection(text)
|
||||
return None
|
||||
@@ -19,7 +19,7 @@ from math import log2
|
||||
from collections import Counter
|
||||
from urllib.parse import quote as url_quote
|
||||
|
||||
from .addon_core import ScanResult
|
||||
from .types import ScanResult
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Route matching and request-policy decisions for the egress gateway."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
|
||||
from .types import Decision, MatchEntry, PathMatch, Route
|
||||
|
||||
|
||||
def _path_matches(pm: PathMatch, request_path: str) -> bool:
|
||||
if pm.type == "exact":
|
||||
return request_path == pm.value
|
||||
if pm.type == "prefix":
|
||||
if request_path == pm.value:
|
||||
return True
|
||||
if not pm.value.endswith("/"):
|
||||
return request_path.startswith(pm.value + "/")
|
||||
return request_path.startswith(pm.value)
|
||||
return (
|
||||
pm.type == "regex"
|
||||
and pm.compiled is not None
|
||||
and pm.compiled.search(request_path) is not None
|
||||
)
|
||||
|
||||
|
||||
def _entry_matches(
|
||||
entry: MatchEntry, request_path: str, request_method: str,
|
||||
request_headers: typing.Mapping[str, str],
|
||||
) -> bool:
|
||||
if entry.paths and not any(_path_matches(pm, request_path) for pm in entry.paths):
|
||||
return False
|
||||
if entry.methods and request_method.upper() not in entry.methods:
|
||||
return False
|
||||
for match in entry.headers:
|
||||
value = request_headers.get(match.name.lower())
|
||||
if value is None:
|
||||
return False
|
||||
if match.type == "exact" and value != match.value:
|
||||
return False
|
||||
if match.type == "regex" and (
|
||||
match.compiled is None or match.compiled.search(value) is None
|
||||
):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def evaluate_matches(
|
||||
route: Route, request_path: str, request_method: str = "GET",
|
||||
request_headers: typing.Mapping[str, str] | None = None,
|
||||
) -> bool:
|
||||
"""Return whether a request satisfies a route's optional match entries."""
|
||||
if not route.matches:
|
||||
return True
|
||||
return any(_entry_matches(entry, request_path, request_method, request_headers or {})
|
||||
for entry in route.matches)
|
||||
|
||||
|
||||
def is_git_push_request(path: str, query: str) -> bool:
|
||||
return path.endswith("/git-receive-pack") or (
|
||||
path.endswith("/info/refs") and any(
|
||||
pair.partition("=") == ("service", "=", "git-receive-pack")
|
||||
for pair in query.split("&")
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def is_git_fetch_request(path: str, query: str) -> bool:
|
||||
return path.endswith("/git-upload-pack") or (
|
||||
path.endswith("/info/refs") and any(
|
||||
pair.partition("=") == ("service", "=", "git-upload-pack")
|
||||
for pair in query.split("&")
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def match_route(routes: typing.Sequence[Route], request_host: str) -> Route | None:
|
||||
target = request_host.lower()
|
||||
return next((route for route in routes if route.host.lower() == target), None)
|
||||
|
||||
|
||||
def decide(
|
||||
routes: typing.Sequence[Route], request_host: str, request_path: str,
|
||||
environ: typing.Mapping[str, str], *, request_method: str = "GET",
|
||||
request_headers: typing.Mapping[str, str] | None = None, deny_reason: str = "",
|
||||
) -> Decision:
|
||||
route = match_route(routes, request_host)
|
||||
if route is None:
|
||||
return Decision("block", deny_reason or (
|
||||
f"egress: host {request_host!r} is not in the bottle's egress.routes "
|
||||
"allowlist. Declare a route for it or remove the request."))
|
||||
if not evaluate_matches(route, request_path, request_method, request_headers):
|
||||
return Decision("block", (
|
||||
f"egress: request {request_method} {request_path!r} does not match any "
|
||||
f"entry in matches for {route.host!r}"))
|
||||
if route.auth_scheme and route.token_env:
|
||||
token = environ.get(route.token_env, "")
|
||||
if not token:
|
||||
return Decision("block", (
|
||||
f"egress: route for {route.host!r} declared auth but env var "
|
||||
f"{route.token_env!r} is unset"))
|
||||
return Decision("forward", inject_authorization=f"{route.auth_scheme} {token}")
|
||||
return Decision("forward")
|
||||
|
||||
|
||||
def decide_git_fetch(routes: typing.Sequence[Route], request_host: str) -> Decision:
|
||||
route = match_route(routes, request_host)
|
||||
if route is not None and route.git_fetch:
|
||||
return Decision("forward")
|
||||
return Decision("block", (
|
||||
"egress: git fetch/clone over HTTPS is not allowed by default; use git-gate "
|
||||
"for declared repos or set egress.routes[].git.fetch=true for explicit "
|
||||
"read-only HTTPS Git access."))
|
||||
@@ -0,0 +1,349 @@
|
||||
"""Egress policy schema parsing and serialization (PRD 0017 / 0053)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import typing
|
||||
|
||||
from ...yaml_subset import YamlSubsetError, parse_yaml_subset
|
||||
from .dlp_config import parse_inspect_block
|
||||
from .types import (
|
||||
HEADER_MATCH_TYPES,
|
||||
LOG_BLOCKS,
|
||||
LOG_FULL,
|
||||
LOG_OFF,
|
||||
PATH_MATCH_TYPES,
|
||||
VALID_METHODS,
|
||||
Config,
|
||||
HeaderMatch,
|
||||
MatchEntry,
|
||||
PathMatch,
|
||||
Route,
|
||||
)
|
||||
|
||||
# Parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _parse_path_match(idx: int, j: int, raw: object) -> PathMatch:
|
||||
label = f"route[{idx}] matches paths[{j}]"
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError(f"{label}: must be an object")
|
||||
raw_dict: dict[str, object] = typing.cast(dict[str, object], raw)
|
||||
ptype = raw_dict.get("type", "prefix")
|
||||
if not isinstance(ptype, str) or ptype not in PATH_MATCH_TYPES:
|
||||
raise ValueError(
|
||||
f"{label}: 'type' must be one of {', '.join(PATH_MATCH_TYPES)} "
|
||||
f"(got {ptype!r})"
|
||||
)
|
||||
value = raw_dict.get("value")
|
||||
if not isinstance(value, str) or not value:
|
||||
raise ValueError(f"{label}: 'value' must be a non-empty string")
|
||||
if ptype in ("exact", "prefix") and not value.startswith("/"):
|
||||
raise ValueError(
|
||||
f"{label}: value {value!r} must start with '/' for "
|
||||
f"type {ptype!r}"
|
||||
)
|
||||
compiled: re.Pattern[str] | None = None
|
||||
if ptype == "regex":
|
||||
try:
|
||||
compiled = re.compile(value)
|
||||
except re.error as e:
|
||||
raise ValueError(
|
||||
f"{label}: regex {value!r} failed to compile: {e}"
|
||||
) from e
|
||||
for k in raw_dict:
|
||||
if k not in ("type", "value"):
|
||||
raise ValueError(f"{label}: unknown key {k!r}")
|
||||
return PathMatch(type=ptype, value=value, compiled=compiled)
|
||||
|
||||
|
||||
def _parse_header_match(idx: int, j: int, raw: object) -> HeaderMatch:
|
||||
label = f"route[{idx}] matches headers[{j}]"
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError(f"{label}: must be an object")
|
||||
raw_dict: dict[str, object] = typing.cast(dict[str, object], raw)
|
||||
name = raw_dict.get("name")
|
||||
if not isinstance(name, str) or not name:
|
||||
raise ValueError(f"{label}: 'name' must be a non-empty string")
|
||||
value = raw_dict.get("value")
|
||||
if not isinstance(value, str):
|
||||
raise ValueError(f"{label}: 'value' must be a string")
|
||||
htype = raw_dict.get("type", "exact")
|
||||
if not isinstance(htype, str) or htype not in HEADER_MATCH_TYPES:
|
||||
raise ValueError(
|
||||
f"{label}: 'type' must be one of {', '.join(HEADER_MATCH_TYPES)} "
|
||||
f"(got {htype!r})"
|
||||
)
|
||||
compiled: re.Pattern[str] | None = None
|
||||
if htype == "regex":
|
||||
try:
|
||||
compiled = re.compile(value)
|
||||
except re.error as e:
|
||||
raise ValueError(
|
||||
f"{label}: regex {value!r} failed to compile: {e}"
|
||||
) from e
|
||||
for k in raw_dict:
|
||||
if k not in ("name", "value", "type"):
|
||||
raise ValueError(f"{label}: unknown key {k!r}")
|
||||
return HeaderMatch(name=name, value=value, type=htype, compiled=compiled)
|
||||
|
||||
|
||||
def _parse_match_entry(idx: int, k: int, raw: object) -> MatchEntry:
|
||||
label = f"route[{idx}] matches[{k}]"
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError(f"{label}: must be an object")
|
||||
raw_dict: dict[str, object] = typing.cast(dict[str, object], raw)
|
||||
|
||||
paths: tuple[PathMatch, ...] = ()
|
||||
paths_raw = raw_dict.get("paths")
|
||||
if paths_raw is not None:
|
||||
if not isinstance(paths_raw, list):
|
||||
raise ValueError(f"{label}: 'paths' must be a list")
|
||||
paths_list = typing.cast(list[object], paths_raw)
|
||||
paths = tuple(_parse_path_match(idx, j, p) for j, p in enumerate(paths_list))
|
||||
|
||||
methods: tuple[str, ...] = ()
|
||||
methods_raw = raw_dict.get("methods")
|
||||
if methods_raw is not None:
|
||||
if not isinstance(methods_raw, list):
|
||||
raise ValueError(f"{label}: 'methods' must be a list")
|
||||
methods_list = typing.cast(list[object], methods_raw)
|
||||
normalised: list[str] = []
|
||||
for j, m in enumerate(methods_list):
|
||||
if not isinstance(m, str):
|
||||
raise ValueError(f"{label}: methods[{j}] must be a string")
|
||||
upper = m.upper()
|
||||
if upper not in VALID_METHODS:
|
||||
raise ValueError(
|
||||
f"{label}: methods[{j}] {m!r} is not a valid HTTP method"
|
||||
)
|
||||
normalised.append(upper)
|
||||
methods = tuple(normalised)
|
||||
|
||||
headers: tuple[HeaderMatch, ...] = ()
|
||||
headers_raw = raw_dict.get("headers")
|
||||
if headers_raw is not None:
|
||||
if not isinstance(headers_raw, list):
|
||||
raise ValueError(f"{label}: 'headers' must be a list")
|
||||
headers_list = typing.cast(list[object], headers_raw)
|
||||
headers = tuple(
|
||||
_parse_header_match(idx, j, h) for j, h in enumerate(headers_list)
|
||||
)
|
||||
|
||||
for key in raw_dict:
|
||||
if key not in ("paths", "methods", "headers"):
|
||||
raise ValueError(f"{label}: unknown key {key!r}")
|
||||
|
||||
return MatchEntry(paths=paths, methods=methods, headers=headers)
|
||||
|
||||
|
||||
def parse_routes(payload: object) -> tuple[Route, ...]:
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("routes payload: top-level must be an object")
|
||||
payload_dict: dict[str, object] = typing.cast(dict[str, object], payload)
|
||||
raw: object = payload_dict.get("routes")
|
||||
if not isinstance(raw, list):
|
||||
raise ValueError("routes payload: 'routes' must be a list")
|
||||
raw_list: list[object] = typing.cast(list[object], raw)
|
||||
out: list[Route] = []
|
||||
for i, r in enumerate(raw_list):
|
||||
out.append(_parse_one(i, r))
|
||||
return tuple(out)
|
||||
|
||||
|
||||
def _parse_one(idx: int, raw: object) -> Route:
|
||||
label = f"route[{idx}]"
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError(f"{label}: must be an object (got {type(raw).__name__})")
|
||||
raw_dict: dict[str, object] = typing.cast(dict[str, object], raw)
|
||||
host: object = raw_dict.get("host")
|
||||
if not isinstance(host, str) or not host:
|
||||
raise ValueError(f"{label}: 'host' must be a non-empty string")
|
||||
legacy_flat = "inspect" not in raw_dict
|
||||
inspect_raw = raw_dict.get("inspect", {})
|
||||
if inspect_raw is False:
|
||||
inspect = False
|
||||
settings: dict[str, object] = {}
|
||||
elif isinstance(inspect_raw, dict):
|
||||
inspect = True
|
||||
settings = (
|
||||
{k: v for k, v in raw_dict.items() if k != "host"}
|
||||
if legacy_flat
|
||||
else typing.cast(dict[str, object], inspect_raw)
|
||||
)
|
||||
legacy_dlp = settings.pop("dlp", None)
|
||||
if isinstance(legacy_dlp, dict):
|
||||
settings.update(typing.cast(dict[str, object], legacy_dlp))
|
||||
elif legacy_dlp is not None:
|
||||
raise ValueError(
|
||||
f"{label} ({host}): legacy 'dlp' must be an object"
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"{label} ({host}): 'inspect' must be false or an object")
|
||||
|
||||
# matches
|
||||
matches: tuple[MatchEntry, ...] = ()
|
||||
matches_raw = settings.get("matches")
|
||||
if matches_raw is not None:
|
||||
if not isinstance(matches_raw, list):
|
||||
raise ValueError(f"{label} ({host}): 'matches' must be a list")
|
||||
matches_list = typing.cast(list[object], matches_raw)
|
||||
matches = tuple(
|
||||
_parse_match_entry(idx, k, m) for k, m in enumerate(matches_list)
|
||||
)
|
||||
|
||||
# auth (unchanged wire format)
|
||||
auth_scheme: object = settings.get("auth_scheme", "")
|
||||
token_env: object = settings.get("token_env", "")
|
||||
if not isinstance(auth_scheme, str):
|
||||
raise ValueError(f"{label} ({host}): 'auth_scheme' must be a string")
|
||||
if not isinstance(token_env, str):
|
||||
raise ValueError(f"{label} ({host}): 'token_env' must be a string")
|
||||
if bool(auth_scheme) != bool(token_env):
|
||||
raise ValueError(
|
||||
f"{label} ({host}): 'auth_scheme' and 'token_env' must be both "
|
||||
f"set or both empty (got auth_scheme={auth_scheme!r}, "
|
||||
f"token_env={token_env!r})"
|
||||
)
|
||||
|
||||
# git-over-HTTPS policy
|
||||
git_fetch = False
|
||||
git_raw = settings.get("git")
|
||||
if git_raw is not None:
|
||||
if not isinstance(git_raw, dict):
|
||||
raise ValueError(f"{label} ({host}): 'git' must be an object")
|
||||
git_dict: dict[str, object] = typing.cast(dict[str, object], git_raw)
|
||||
fetch_raw = git_dict.get("fetch", False)
|
||||
if fetch_raw is True or fetch_raw is False:
|
||||
git_fetch = fetch_raw
|
||||
else:
|
||||
raise ValueError(f"{label} ({host}): 'git.fetch' must be a boolean")
|
||||
for k in git_dict:
|
||||
if k != "fetch":
|
||||
raise ValueError(
|
||||
f"{label} ({host}): git has unknown key {k!r}; "
|
||||
"accepted key is 'fetch'"
|
||||
)
|
||||
|
||||
# dlp detectors
|
||||
outbound_detectors, inbound_detectors, outbound_on_match = parse_inspect_block(
|
||||
idx, host, settings,
|
||||
)
|
||||
|
||||
preserve_auth_raw = settings.get("preserve_auth", False)
|
||||
if preserve_auth_raw is not True and preserve_auth_raw is not False:
|
||||
raise ValueError(
|
||||
f"{label} ({host}): 'preserve_auth' must be a boolean"
|
||||
)
|
||||
preserve_auth: bool = preserve_auth_raw
|
||||
|
||||
for k in settings:
|
||||
if k not in (
|
||||
"matches", "auth_scheme", "token_env", "git", "preserve_auth",
|
||||
"outbound_detectors", "inbound_detectors", "outbound_on_match",
|
||||
):
|
||||
raise ValueError(
|
||||
f"{label} ({host}): inspect has unknown key {k!r}"
|
||||
)
|
||||
for k in raw_dict:
|
||||
if not legacy_flat and k not in ("host", "inspect"):
|
||||
raise ValueError(
|
||||
f"{label} ({host}): unknown key {k!r}; accepted keys "
|
||||
f"are 'host' and 'inspect'"
|
||||
)
|
||||
|
||||
return Route(
|
||||
host=host,
|
||||
matches=matches,
|
||||
auth_scheme=auth_scheme,
|
||||
token_env=token_env,
|
||||
git_fetch=git_fetch,
|
||||
outbound_detectors=outbound_detectors,
|
||||
inbound_detectors=inbound_detectors,
|
||||
outbound_on_match=outbound_on_match,
|
||||
preserve_auth=preserve_auth,
|
||||
inspect=inspect,
|
||||
)
|
||||
|
||||
|
||||
def _path_match_to_dict(pm: PathMatch) -> dict[str, object]:
|
||||
d: dict[str, object] = {"value": pm.value}
|
||||
if pm.type != "prefix":
|
||||
d["type"] = pm.type
|
||||
return d
|
||||
|
||||
|
||||
def _header_match_to_dict(hm: HeaderMatch) -> dict[str, object]:
|
||||
d: dict[str, object] = {"name": hm.name, "value": hm.value}
|
||||
if hm.type != "exact":
|
||||
d["type"] = hm.type
|
||||
return d
|
||||
|
||||
|
||||
def _match_entry_to_dict(me: MatchEntry) -> dict[str, object]:
|
||||
d: dict[str, object] = {}
|
||||
if me.paths:
|
||||
d["paths"] = [_path_match_to_dict(p) for p in me.paths]
|
||||
if me.methods:
|
||||
d["methods"] = list(me.methods)
|
||||
if me.headers:
|
||||
d["headers"] = [_header_match_to_dict(h) for h in me.headers]
|
||||
return d
|
||||
|
||||
|
||||
def route_to_yaml_dict(r: Route) -> dict[str, object]:
|
||||
"""Serialize a Route to YAML-schema-compatible dict.
|
||||
|
||||
Uses the same field names the YAML parser accepts, so the output
|
||||
can be round-tripped directly into an `allow` or `egress-block`
|
||||
proposal without translation. Fields that are empty/default are
|
||||
omitted so the agent doesn't copy irrelevant keys."""
|
||||
d: dict[str, object] = {"host": r.host}
|
||||
if not r.inspect:
|
||||
d["inspect"] = False
|
||||
return d
|
||||
inspected: dict[str, object] = {}
|
||||
if r.auth_scheme:
|
||||
inspected["auth_scheme"] = r.auth_scheme
|
||||
inspected["token_env"] = r.token_env
|
||||
if r.matches:
|
||||
inspected["matches"] = [_match_entry_to_dict(m) for m in r.matches]
|
||||
if r.git_fetch:
|
||||
inspected["git"] = {"fetch": True}
|
||||
if r.outbound_detectors is not None:
|
||||
inspected["outbound_detectors"] = list(r.outbound_detectors)
|
||||
if r.inbound_detectors is not None:
|
||||
inspected["inbound_detectors"] = list(r.inbound_detectors)
|
||||
if r.outbound_on_match:
|
||||
inspected["outbound_on_match"] = r.outbound_on_match
|
||||
if r.preserve_auth:
|
||||
inspected["preserve_auth"] = True
|
||||
if inspected:
|
||||
d["inspect"] = inspected
|
||||
return d
|
||||
|
||||
|
||||
def parse_config(payload: object) -> "Config":
|
||||
"""Parse a full egress config payload (top-level log level + routes)."""
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("routes payload: top-level must be an object")
|
||||
payload_dict: dict[str, object] = typing.cast(dict[str, object], payload)
|
||||
|
||||
log_raw: object = payload_dict.get("log", LOG_OFF)
|
||||
if log_raw is True or log_raw is False or not isinstance(log_raw, int) \
|
||||
or log_raw not in (LOG_OFF, LOG_BLOCKS, LOG_FULL):
|
||||
raise ValueError(
|
||||
f"routes payload: 'log' must be {LOG_OFF}, {LOG_BLOCKS}, or {LOG_FULL}"
|
||||
)
|
||||
|
||||
routes = parse_routes(payload)
|
||||
return Config(routes=routes, log=log_raw)
|
||||
|
||||
|
||||
def load_config(text: str) -> "Config":
|
||||
"""Parse YAML text → Config (routes + log flag)."""
|
||||
try:
|
||||
payload = parse_yaml_subset(text)
|
||||
except YamlSubsetError as e:
|
||||
raise ValueError(f"routes payload: invalid YAML: {e}") from e
|
||||
return parse_config(payload)
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Shared egress policy value objects.
|
||||
|
||||
Kept dependency-free so the schema parser, matcher, DLP scanner, and addon
|
||||
adapter can use the same immutable public shapes without importing each other.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
PATH_MATCH_TYPES = ("exact", "prefix", "regex")
|
||||
HEADER_MATCH_TYPES = ("exact", "regex")
|
||||
VALID_METHODS = frozenset({
|
||||
"GET", "HEAD", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "TRACE",
|
||||
"CONNECT",
|
||||
})
|
||||
|
||||
LOG_OFF = 0
|
||||
LOG_BLOCKS = 1
|
||||
LOG_FULL = 2
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PathMatch:
|
||||
type: str
|
||||
value: str
|
||||
compiled: re.Pattern[str] | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HeaderMatch:
|
||||
name: str
|
||||
value: str
|
||||
type: str = "exact"
|
||||
compiled: re.Pattern[str] | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MatchEntry:
|
||||
paths: tuple[PathMatch, ...] = ()
|
||||
methods: tuple[str, ...] = ()
|
||||
headers: tuple[HeaderMatch, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Route:
|
||||
host: str
|
||||
matches: tuple[MatchEntry, ...] = ()
|
||||
auth_scheme: str = ""
|
||||
token_env: str = ""
|
||||
git_fetch: bool = False
|
||||
outbound_detectors: tuple[str, ...] | None = None
|
||||
inbound_detectors: tuple[str, ...] | None = None
|
||||
outbound_on_match: str = ""
|
||||
preserve_auth: bool = False
|
||||
inspect: bool = True
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Config:
|
||||
routes: tuple[Route, ...]
|
||||
log: int = LOG_OFF
|
||||
deny_reason: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Decision:
|
||||
action: str
|
||||
reason: str = ""
|
||||
inject_authorization: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ScanResult:
|
||||
severity: str
|
||||
reason: str
|
||||
location: str = ""
|
||||
context: str = ""
|
||||
matched: str = ""
|
||||
@@ -17,7 +17,7 @@ Each queued proposal tool call:
|
||||
4. On a decision within the window, returns the operator's
|
||||
`{status, notes}`. On timeout, returns `status: pending` **with the
|
||||
proposal id** and leaves the proposal queued — the flow is
|
||||
non-blocking past the grace window (PRD prd-new / issue #412).
|
||||
non-blocking past the grace window (PRD 0072 / issue #412).
|
||||
|
||||
`check-proposal` is the non-blocking companion: given a `proposal_id`
|
||||
returned by a `pending` response, it reports the current decision
|
||||
@@ -58,9 +58,9 @@ import typing
|
||||
from dataclasses import dataclass
|
||||
|
||||
from bot_bottle.constants import IDENTITY_HEADER
|
||||
from bot_bottle.gateway.egress.addon_core import (
|
||||
LOG_OFF, load_config, resolve_client_context, route_to_yaml_dict,
|
||||
)
|
||||
from bot_bottle.gateway.egress.context import resolve_client_context
|
||||
from bot_bottle.gateway.egress.schema import load_config, route_to_yaml_dict
|
||||
from bot_bottle.gateway.egress.types import LOG_OFF
|
||||
from bot_bottle.gateway.policy_resolver import PolicyResolveError, PolicyResolver
|
||||
from bot_bottle.supervisor import types as _sv
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import urllib.request
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from ..log import debug
|
||||
from ..orchestrator_auth import ROLE_CLI
|
||||
from ..trust_domain import CONTROL_PLANE
|
||||
from .server import ORCHESTRATOR_AUTH_HEADER
|
||||
@@ -53,6 +54,23 @@ class RegisteredBottle:
|
||||
env_var_secret: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BackendProbeFailure:
|
||||
"""Safe diagnostic for an optional backend discovery probe."""
|
||||
|
||||
backend: str
|
||||
error_type: str
|
||||
|
||||
|
||||
def _probe_failure(backend: str, exc: BaseException) -> BackendProbeFailure:
|
||||
failure = BackendProbeFailure(backend, type(exc).__name__)
|
||||
debug(
|
||||
"orchestrator discovery probe unavailable",
|
||||
context={"backend": failure.backend, "error_type": failure.error_type},
|
||||
)
|
||||
return failure
|
||||
|
||||
|
||||
class OrchestratorClient:
|
||||
"""Trusted host-side client for the orchestrator control plane.
|
||||
|
||||
@@ -245,32 +263,41 @@ def discover_orchestrator_url(*, timeout: float = 2.0) -> str:
|
||||
orchestrator TAP. Returns the first that answers `/health`; raises if none
|
||||
do (no orchestrator up — launch a bottle first)."""
|
||||
candidates: list[str] = []
|
||||
failures: list[BackendProbeFailure] = []
|
||||
try: # docker: loopback-published control plane
|
||||
from .lifecycle import DEFAULT_PORT as _DOCKER_PORT
|
||||
candidates.append(f"http://127.0.0.1:{_DOCKER_PORT}")
|
||||
except Exception: # noqa: BLE001 — backend optional
|
||||
except Exception as exc: # noqa: BLE001 — backend optional
|
||||
failures.append(_probe_failure("docker", exc))
|
||||
candidates.append("http://127.0.0.1:8099")
|
||||
try: # firecracker: infra VM control plane on the orchestrator TAP
|
||||
from ..backend.firecracker import netpool
|
||||
from ..backend.firecracker.infra_vm import ORCHESTRATOR_PORT
|
||||
candidates.append(
|
||||
f"http://{netpool.orch_slot().guest_ip}:{ORCHESTRATOR_PORT}")
|
||||
except Exception: # noqa: BLE001 — backend optional / not firecracker
|
||||
pass
|
||||
except Exception as exc: # noqa: BLE001 — backend optional / not firecracker
|
||||
failures.append(_probe_failure("firecracker", exc))
|
||||
try: # macOS: orchestrator container on its host-only address
|
||||
from ..backend.macos_container.infra import probe_orchestrator_url
|
||||
url = probe_orchestrator_url()
|
||||
if url:
|
||||
candidates.append(url)
|
||||
except Exception: # noqa: BLE001 — backend optional / not macOS
|
||||
pass
|
||||
except Exception as exc: # noqa: BLE001 — backend optional / not macOS
|
||||
failures.append(_probe_failure("macos-container", exc))
|
||||
for url in candidates:
|
||||
if OrchestratorClient(url, timeout=timeout).health():
|
||||
return url
|
||||
detail = ""
|
||||
if failures:
|
||||
detail = "; optional probes unavailable: " + ", ".join(
|
||||
f"{failure.backend} ({failure.error_type})" for failure in failures
|
||||
)
|
||||
raise OrchestratorClientError(
|
||||
"no running orchestrator control plane found (tried "
|
||||
+ ", ".join(candidates)
|
||||
+ "); launch a bottle first"
|
||||
+ ")"
|
||||
+ detail
|
||||
+ "; launch a bottle first"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ..log import debug
|
||||
from .client import OrchestratorClient, OrchestratorClientError
|
||||
|
||||
|
||||
@@ -27,7 +28,14 @@ def reprovision_bottles(
|
||||
try:
|
||||
if client.reprovision_gateway(bottle_id, secret):
|
||||
restored += 1
|
||||
except OrchestratorClientError:
|
||||
except OrchestratorClientError as exc:
|
||||
debug(
|
||||
"gateway secret reprovision failed; continuing with other bottles",
|
||||
context={
|
||||
"bottle_id": bottle_id,
|
||||
"error_type": type(exc).__name__,
|
||||
},
|
||||
)
|
||||
continue
|
||||
return restored
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ from __future__ import annotations
|
||||
|
||||
import http.server
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import socketserver
|
||||
import sys
|
||||
@@ -217,13 +218,18 @@ def dispatch( # pylint: disable=too-many-return-statements,too-many-branches
|
||||
raw_ips = data.get("live_source_ips")
|
||||
if not isinstance(raw_ips, list):
|
||||
return 400, {"error": "live_source_ips (list of strings) is required"}
|
||||
live = [ip for ip in raw_ips if isinstance(ip, str) and ip]
|
||||
if any(not isinstance(ip, str) or not ip for ip in raw_ips):
|
||||
return 400, {"error": "live_source_ips must contain non-empty strings"}
|
||||
live = raw_ips
|
||||
grace = data.get("grace_seconds")
|
||||
kwargs = (
|
||||
{"grace_seconds": float(grace)}
|
||||
if isinstance(grace, (int, float)) and not isinstance(grace, bool)
|
||||
else {}
|
||||
)
|
||||
kwargs: dict[str, float] = {}
|
||||
if grace is not None:
|
||||
if isinstance(grace, bool) or not isinstance(grace, (int, float)):
|
||||
return 400, {"error": "grace_seconds must be a non-negative finite number"}
|
||||
parsed_grace = float(grace)
|
||||
if not math.isfinite(parsed_grace) or parsed_grace < 0:
|
||||
return 400, {"error": "grace_seconds must be a non-negative finite number"}
|
||||
kwargs["grace_seconds"] = parsed_grace
|
||||
return 200, {"reaped": orch.reconcile(live, **kwargs)}
|
||||
|
||||
if method == "POST" and route == "/attribute":
|
||||
@@ -373,9 +379,15 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
status, payload = dispatch(
|
||||
server.orchestrator, method, self.path, body, role=role)
|
||||
except Exception as e: # noqa: BLE001 — the control plane must stay up
|
||||
sys.stderr.write(f"orchestrator: {method} {self.path} failed: {e!r}\n")
|
||||
# Do not echo exception messages to the caller or logs: broker and
|
||||
# persistence exceptions can contain request data. The operation,
|
||||
# route, and exception type are enough to correlate a traceback.
|
||||
sys.stderr.write(
|
||||
f"orchestrator: {method} {self.path} failed "
|
||||
f"[error_type={type(e).__name__}]\n"
|
||||
)
|
||||
sys.stderr.flush()
|
||||
status, payload = 500, {"error": f"internal error: {e}"}
|
||||
status, payload = 500, {"error": "internal error"}
|
||||
data = json.dumps(payload).encode()
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
|
||||
@@ -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
|
||||
@@ -17,7 +17,7 @@ instead would defeat that — the orchestrator holds that key, so it could forge
|
||||
`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`).
|
||||
`docs/prds/0079-control-plane-auth-provisioning.md`).
|
||||
|
||||
Stdlib-only: the HMAC lives in `orchestrator_auth`, the key file in `paths`.
|
||||
"""
|
||||
|
||||
@@ -9,8 +9,11 @@ import difflib
|
||||
import hashlib
|
||||
import ipaddress
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
from .log import die
|
||||
|
||||
|
||||
def sha256_hex(content: str) -> str:
|
||||
"""Hex SHA-256 of a UTF-8 string."""
|
||||
@@ -67,3 +70,20 @@ def expand_tilde(path: str) -> str:
|
||||
home = os.environ.get("HOME", "")
|
||||
return home + path[1:]
|
||||
return path
|
||||
|
||||
|
||||
_SLUG_RE = re.compile(r"[^a-z0-9]+")
|
||||
|
||||
|
||||
def slugify(name: str) -> str:
|
||||
"""Return a portable bottle identifier from a human-readable name.
|
||||
|
||||
This is deliberately a root utility: names are part of the generic CLI
|
||||
and state model, not a Docker container concern.
|
||||
"""
|
||||
if not name:
|
||||
die("slugify: missing name")
|
||||
slug = _SLUG_RE.sub("-", name.lower()).strip("-")
|
||||
if not slug:
|
||||
die(f"name '{name}' produced an empty slug; use alphanumeric characters")
|
||||
return slug
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
# PRD 0001: Per-agent egress proxy via pipelock
|
||||
|
||||
- **Status:** Active
|
||||
- **Status:** Superseded by [PRD 0017](0017-egress-proxy-via-mitmproxy.md)
|
||||
and [PRD 0052](0052-egress-dlp-addon.md)
|
||||
- **Author:** didericis
|
||||
- **Created:** 2026-05-08
|
||||
|
||||
> **Superseded.** Pipelock was removed in issue #193. PRD 0017 moved
|
||||
> egress enforcement and credential injection to mitmproxy; PRD 0052 moved DLP
|
||||
> enforcement into the egress addon. The design below is retained as history.
|
||||
|
||||
## Summary
|
||||
|
||||
Run pipelock as a sidecar container on each bot-bottle agent's only
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
# PRD 0006: pipelock native TLS interception
|
||||
|
||||
- **Status:** Active
|
||||
- **Status:** Superseded by [PRD 0017](0017-egress-proxy-via-mitmproxy.md)
|
||||
and [PRD 0052](0052-egress-dlp-addon.md)
|
||||
- **Author:** didericis
|
||||
- **Created:** 2026-05-12
|
||||
|
||||
> **Superseded.** Pipelock was removed in issue #193. TLS interception now
|
||||
> belongs to the mitmproxy egress design in PRD 0017, with DLP implemented by
|
||||
> the egress addon in PRD 0052. The design below is retained as history.
|
||||
|
||||
## Summary
|
||||
|
||||
Turn on pipelock's built-in `tls_interception` so its DLP / URL /
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
# PRD 0015: pipelock block remediation
|
||||
|
||||
- **Status:** Active
|
||||
- **Status:** Superseded by [PRD 0017](0017-egress-proxy-via-mitmproxy.md)
|
||||
and [PRD 0052](0052-egress-dlp-addon.md)
|
||||
- **Author:** didericis
|
||||
- **Created:** 2026-05-25
|
||||
- **Parent:** PRD 0012
|
||||
- **Depends on:** PRD 0013
|
||||
|
||||
> **Superseded.** Pipelock and its restart-based allowlist remediation path
|
||||
> were removed in issue #193. Current egress enforcement is the mitmproxy
|
||||
> design from PRD 0017 with DLP in PRD 0052. The design below is retained as
|
||||
> history.
|
||||
|
||||
## Summary
|
||||
|
||||
Wires the **pipelock block** path (PRD 0012 *Stuck categories*) end-to-end. The supervisor, on approval of a `pipelock-block` proposal, writes the new pipelock allowlist to the host and restarts pipelock; the agent's in-flight outbound calls may drop and rely on retry. The TUI gains a proactive `pipelock edit <bottle>` verb for operator-initiated edits unrelated to a tool call. The pipelock audit log (format defined in PRD 0013) is filled in with real entries on every edit.
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
# PRD 0024: Consolidate per-bottle sidecars into a single bundle
|
||||
|
||||
- **Status:** Active
|
||||
- **Status:** Superseded by [PRD 0070](0070-per-host-orchestrator.md)
|
||||
- **Author:** didericis
|
||||
- **Created:** 2026-05-26
|
||||
|
||||
> **Superseded.** PRD 0070 replaced the per-bottle sidecar bundle with a
|
||||
> persistent per-host gateway and separate orchestrator control plane. The
|
||||
> design below is retained as history.
|
||||
|
||||
## Summary
|
||||
|
||||
Replace the four per-bottle sidecar containers in the Docker
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
# PRD 0037: Pipelock YAML Render Contract
|
||||
|
||||
- **Status:** Active
|
||||
- **Status:** Superseded by [PRD 0017](0017-egress-proxy-via-mitmproxy.md)
|
||||
and [PRD 0052](0052-egress-dlp-addon.md)
|
||||
- **Author:** didericis-codex
|
||||
- **Created:** 2026-06-02
|
||||
- **Issue:** #130
|
||||
|
||||
> **Superseded.** Pipelock and its YAML renderer were removed in issue #193.
|
||||
> Current egress configuration is consumed by the mitmproxy design from PRD
|
||||
> 0017 and its DLP addon from PRD 0052. The contract below is retained as
|
||||
> history.
|
||||
|
||||
## Summary
|
||||
|
||||
Lock down the contract between `pipelock_build_config` and
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
# PRD 0067: SQLite local storage
|
||||
|
||||
- **Status:** Active
|
||||
- **Status:** Retargeted by [PRD 0070](0070-per-host-orchestrator.md) and
|
||||
issues #469/#471
|
||||
- **Author:** codex
|
||||
- **Created:** 2026-07-01
|
||||
- **Issue:** #319
|
||||
|
||||
> **Retargeted.** The SQLite storage and migration foundation remains in use,
|
||||
> but the writable data-plane database mount described below is no longer the
|
||||
> active ownership model. Issues #469/#471 removed `bot-bottle.db` from the
|
||||
> data plane; under PRD 0070 only the orchestrator control plane opens the
|
||||
> operational database, and gateway components reach state through RPC.
|
||||
|
||||
## Summary
|
||||
|
||||
Add a small stdlib SQLite storage layer for bot-bottle host runtime state,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# PRD 0069: Firecracker-native, Docker-free backend
|
||||
|
||||
- **Status:** Draft (partially superseded)
|
||||
- **Status:** Retargeted by [PRD 0070](0070-per-host-orchestrator.md)
|
||||
- **Author:** Claude
|
||||
- **Created:** 2026-07-12
|
||||
- **Issue:** #348
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# PRD 0070: Per-host orchestrator service
|
||||
|
||||
- **Status:** Draft
|
||||
- **Status:** Active
|
||||
- **Author:** Claude
|
||||
- **Created:** 2026-07-12
|
||||
- **Issue:** #351
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
# PRD prd-new: Claude forward_host_credentials
|
||||
# PRD 0071: Claude forward_host_credentials
|
||||
|
||||
- **Status:** Draft
|
||||
- **Status:** Active
|
||||
- **Author:** claude
|
||||
- **Created:** 2026-07-01
|
||||
- **Issue:** #325
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
# PRD prd-new: Non-blocking supervise (async approval + proposal polling)
|
||||
# PRD 0072: Non-blocking supervise (async approval + proposal polling)
|
||||
|
||||
- **Status:** Draft
|
||||
- **Status:** Active
|
||||
- **Author:** didericis
|
||||
- **Created:** 2026-07-18
|
||||
- **Issue:** #412
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
# PRD prd-new: Consolidate infra backend for Docker
|
||||
# PRD 0073: Consolidate infra backend for Docker
|
||||
|
||||
- **Status:** Active
|
||||
- **Author:** Claude
|
||||
@@ -1,4 +1,4 @@
|
||||
# PRD prd-new: CI artifact-based coverage and local Firecracker candidate flow
|
||||
# PRD 0074: CI artifact-based coverage and local Firecracker candidate flow
|
||||
|
||||
- **Status:** Active
|
||||
- **Author:** Claude
|
||||
@@ -1,6 +1,6 @@
|
||||
# PRD prd-new: Containers inside a bottle
|
||||
# PRD 0075: Containers inside a bottle
|
||||
|
||||
- **Status:** Draft
|
||||
- **Status:** Active
|
||||
- **Author:** Claude
|
||||
- **Created:** 2026-07-21
|
||||
- **Issue:** #392
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
# PRD prd-new: Modernize built-in agent images
|
||||
# PRD 0076: Modernize built-in agent images
|
||||
|
||||
- **Status:** Draft
|
||||
- **Status:** Active
|
||||
- **Author:** Codex
|
||||
- **Created:** 2026-07-21
|
||||
- **Issue:** #451
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
# PRD prd-new: macOS (Apple Container) CI runner
|
||||
# PRD 0077: macOS (Apple Container) CI runner
|
||||
|
||||
- **Status:** Draft
|
||||
- **Status:** Active
|
||||
- **Author:** Claude
|
||||
- **Created:** 2026-07-25
|
||||
- **Issue:** #426
|
||||
@@ -0,0 +1,172 @@
|
||||
# PRD 0078: Quick install script
|
||||
|
||||
- **Status:** Active
|
||||
- **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.
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
# PRD prd-new: Per-service signing keys for control-plane auth
|
||||
# PRD 0079: Per-service signing keys for control-plane auth
|
||||
|
||||
- **Status:** Draft
|
||||
- **Status:** Active
|
||||
- **Author:** claude
|
||||
- **Created:** 2026-07-26
|
||||
- **Issue:** #476
|
||||
@@ -4,7 +4,7 @@ Spike branch: `spike/rootless-docker-macos` (`a4d8461`)
|
||||
|
||||
**Outcome:** the podman recommendation below shipped as the
|
||||
`nested_containers` bottle flag — see
|
||||
[`docs/prds/prd-new-nested-containers.md`](../prds/prd-new-nested-containers.md).
|
||||
[`docs/prds/0075-nested-containers.md`](../prds/0075-nested-containers.md).
|
||||
The `docker_access` name used throughout the spike text was renamed on the
|
||||
way in; it granted no access to anything on the host.
|
||||
|
||||
|
||||
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]
|
||||
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"
|
||||
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
|
||||
pyright>=1.1.411
|
||||
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})
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Architecture rules that should fail before coupling becomes entrenched."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
class TestCliBackendBoundaries(unittest.TestCase):
|
||||
def test_cli_does_not_import_a_concrete_backend(self) -> None:
|
||||
forbidden = (
|
||||
"backend.docker", "backend.firecracker", "backend.macos_container",
|
||||
"bot_bottle.backend.docker", "bot_bottle.backend.firecracker",
|
||||
"bot_bottle.backend.macos_container",
|
||||
)
|
||||
violations: list[str] = []
|
||||
for path in (ROOT / "bot_bottle" / "cli").rglob("*.py"):
|
||||
tree = ast.parse(path.read_text(), filename=str(path))
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.ImportFrom):
|
||||
module = node.module
|
||||
if module and module.startswith(forbidden):
|
||||
violations.append(
|
||||
f"{path.relative_to(ROOT)}:{node.lineno}: {module}"
|
||||
)
|
||||
if isinstance(node, ast.Import):
|
||||
violations.extend(
|
||||
f"{path.relative_to(ROOT)}:{node.lineno}: {alias.name}"
|
||||
for alias in node.names if alias.name.startswith(forbidden)
|
||||
)
|
||||
self.assertEqual([], violations, "generic CLI imports concrete backend internals:\n" +
|
||||
"\n".join(violations))
|
||||
|
||||
|
||||
class TestRuntimeModuleSizes(unittest.TestCase):
|
||||
def test_no_runtime_module_grows_beyond_global_ceiling(self) -> None:
|
||||
"""A coarse ceiling catches new monoliths; focused caps stay tighter."""
|
||||
ceiling = 850
|
||||
oversized = [
|
||||
f"{path.relative_to(ROOT)} ({len(path.read_text().splitlines())})"
|
||||
for path in (ROOT / "bot_bottle").rglob("*.py")
|
||||
if len(path.read_text().splitlines()) > ceiling
|
||||
]
|
||||
self.assertEqual(
|
||||
[], oversized,
|
||||
f"runtime modules must stay at or below {ceiling} lines: "
|
||||
+ ", ".join(oversized),
|
||||
)
|
||||
|
||||
def test_egress_modules_stay_focused(self) -> None:
|
||||
caps = {
|
||||
"addon_core.py": 100,
|
||||
"schema.py": 400,
|
||||
"types.py": 180,
|
||||
"matching.py": 180,
|
||||
"dlp.py": 180,
|
||||
"context.py": 140,
|
||||
}
|
||||
directory = ROOT / "bot_bottle" / "gateway" / "egress"
|
||||
oversized = [f"{name} ({len((directory / name).read_text().splitlines())}>{cap})"
|
||||
for name, cap in caps.items()
|
||||
if len((directory / name).read_text().splitlines()) > cap]
|
||||
self.assertEqual([], oversized, "split a module rather than raising its cap: " +
|
||||
", ".join(oversized))
|
||||
|
||||
def test_runtime_code_uses_focused_egress_modules(self) -> None:
|
||||
"""addon_core is compatibility-only, never an internal dependency."""
|
||||
violations: list[str] = []
|
||||
package = ROOT / "bot_bottle"
|
||||
facade = package / "gateway" / "egress" / "addon_core.py"
|
||||
package_init = package / "gateway" / "egress" / "__init__.py"
|
||||
for path in package.rglob("*.py"):
|
||||
if path in (facade, package_init):
|
||||
continue
|
||||
text = path.read_text()
|
||||
if "gateway.egress.addon_core import" in text or \
|
||||
".addon_core import" in text:
|
||||
violations.append(str(path.relative_to(ROOT)))
|
||||
self.assertEqual([], violations)
|
||||
|
||||
def test_backend_contract_does_not_absorb_preparation_logic(self) -> None:
|
||||
caps = {
|
||||
ROOT / "bot_bottle" / "backend" / "base.py": 580,
|
||||
ROOT / "bot_bottle" / "backend" / "preparation.py": 160,
|
||||
}
|
||||
oversized = [
|
||||
f"{path.relative_to(ROOT)} "
|
||||
f"({len(path.read_text().splitlines())}>{cap})"
|
||||
for path, cap in caps.items()
|
||||
if len(path.read_text().splitlines()) > cap
|
||||
]
|
||||
self.assertEqual([], oversized)
|
||||
@@ -48,12 +48,13 @@ class TestSharedReprovision(unittest.TestCase):
|
||||
client.reprovision_gateway.side_effect = [
|
||||
OrchestratorClientError("bad key"), True,
|
||||
]
|
||||
self.assertEqual(
|
||||
1,
|
||||
reprovision_bottles(
|
||||
with patch("bot_bottle.orchestrator.reprovision.debug") as debug:
|
||||
count = reprovision_bottles(
|
||||
client, {"10.0.0.1": "key-1", "10.0.0.2": "key-2"},
|
||||
),
|
||||
)
|
||||
)
|
||||
self.assertEqual(1, count)
|
||||
self.assertEqual("b1", debug.call_args.kwargs["context"]["bottle_id"])
|
||||
self.assertNotIn("bad key", repr(debug.call_args))
|
||||
|
||||
|
||||
class TestMacosReprovision(unittest.TestCase):
|
||||
|
||||
@@ -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()
|
||||
@@ -9,6 +9,7 @@ from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from typing import Any, Optional
|
||||
from unittest.mock import patch
|
||||
|
||||
from bot_bottle.cli.tui import _filter_items, _multiselect_loop, filter_multiselect, filter_select
|
||||
|
||||
@@ -49,8 +50,10 @@ class TestFilterSelectEmptyItems(unittest.TestCase):
|
||||
|
||||
def test_returns_none_when_tty_unavailable(self):
|
||||
# /nonexistent is guaranteed to not open.
|
||||
result = filter_select(["a", "b"], tty_path="/nonexistent/tty")
|
||||
with patch("bot_bottle.cli.tui.debug") as debug:
|
||||
result = filter_select(["a", "b"], tty_path="/nonexistent/tty")
|
||||
self.assertIsNone(result)
|
||||
self.assertEqual("FileNotFoundError", debug.call_args.kwargs["context"]["error_type"])
|
||||
|
||||
|
||||
class TestFilterMultiselectEmptyItems(unittest.TestCase):
|
||||
@@ -60,8 +63,10 @@ class TestFilterMultiselectEmptyItems(unittest.TestCase):
|
||||
self.assertEqual([], result)
|
||||
|
||||
def test_returns_none_when_tty_unavailable(self):
|
||||
result = filter_multiselect(["a", "b"], tty_path="/nonexistent/tty")
|
||||
with patch("bot_bottle.cli.tui.debug") as debug:
|
||||
result = filter_multiselect(["a", "b"], tty_path="/nonexistent/tty")
|
||||
self.assertIsNone(result)
|
||||
self.assertEqual("FileNotFoundError", debug.call_args.kwargs["context"]["error_type"])
|
||||
|
||||
|
||||
class TestMultiselectLoopReordering(unittest.TestCase):
|
||||
|
||||
@@ -8,17 +8,19 @@ from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from bot_bottle.gateway.egress.addon_core import (
|
||||
HeaderMatch,
|
||||
MatchEntry,
|
||||
PathMatch,
|
||||
Route,
|
||||
evaluate_matches,
|
||||
from bot_bottle.gateway.egress.matching import evaluate_matches
|
||||
from bot_bottle.gateway.egress.schema import (
|
||||
load_config,
|
||||
parse_config,
|
||||
parse_routes,
|
||||
route_to_yaml_dict,
|
||||
)
|
||||
from bot_bottle.gateway.egress.types import (
|
||||
HeaderMatch,
|
||||
MatchEntry,
|
||||
PathMatch,
|
||||
Route,
|
||||
)
|
||||
|
||||
|
||||
def _route(d: dict[str, object]) -> Route:
|
||||
|
||||
@@ -3,15 +3,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from bot_bottle.gateway.egress.addon_core import (
|
||||
from bot_bottle.gateway.egress.context import (
|
||||
DENY_RESOLVER_ERROR,
|
||||
DENY_UNATTRIBUTED,
|
||||
DENY_UNPARSEABLE,
|
||||
decide,
|
||||
resolve_client_config,
|
||||
resolve_client_context,
|
||||
)
|
||||
from bot_bottle.gateway.egress.matching import decide
|
||||
from bot_bottle.gateway.policy_resolver import PolicyResolveError
|
||||
|
||||
|
||||
@@ -44,7 +45,13 @@ class TestResolveClientConfig(unittest.TestCase):
|
||||
|
||||
def test_resolver_error_denies_all(self) -> None:
|
||||
# Orchestrator unreachable/errored must never widen egress.
|
||||
self.assertEqual((), resolve_client_config(_FakeResolver(raises=True), "10.243.0.1").routes)
|
||||
with patch("bot_bottle.gateway.egress.context.debug") as debug:
|
||||
config = resolve_client_config(_FakeResolver(raises=True), "10.243.0.1")
|
||||
self.assertEqual((), config.routes)
|
||||
self.assertEqual(
|
||||
"PolicyResolveError", debug.call_args.kwargs["context"]["error_type"],
|
||||
)
|
||||
self.assertNotIn("orchestrator down", repr(debug.call_args))
|
||||
|
||||
def test_unparseable_policy_denies_all(self) -> None:
|
||||
cfg = resolve_client_config(_FakeResolver(result="routes: notalist\n"), "10.243.0.1")
|
||||
|
||||
@@ -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)
|
||||
|
||||
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",
|
||||
)
|
||||
derived.assert_called_once_with("agent:base", build)
|
||||
|
||||
@@ -12,7 +12,9 @@ from bot_bottle.orchestrator.client import (
|
||||
OrchestratorClient,
|
||||
OrchestratorClientError,
|
||||
RegisteredBottle,
|
||||
BackendProbeFailure,
|
||||
_host_auth_token,
|
||||
_probe_failure,
|
||||
)
|
||||
|
||||
_URLOPEN = "bot_bottle.orchestrator.client.urllib.request.urlopen"
|
||||
@@ -33,6 +35,15 @@ class TestHostAuthToken(unittest.TestCase):
|
||||
self.assertEqual("", _host_auth_token())
|
||||
|
||||
|
||||
class TestBackendProbeFailure(unittest.TestCase):
|
||||
def test_records_safe_typed_diagnostic(self) -> None:
|
||||
with patch("bot_bottle.orchestrator.client.debug") as debug:
|
||||
result = _probe_failure("firecracker", RuntimeError("secret detail"))
|
||||
self.assertEqual(BackendProbeFailure("firecracker", "RuntimeError"), result)
|
||||
rendered = repr(debug.call_args)
|
||||
self.assertNotIn("secret detail", rendered)
|
||||
|
||||
|
||||
def _resp(status: int, payload: object) -> MagicMock:
|
||||
m = MagicMock()
|
||||
inner = m.__enter__.return_value
|
||||
|
||||
@@ -7,6 +7,7 @@ server tests), plus one real-socket round-trip to prove the handler wiring.
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
import secrets
|
||||
import sqlite3
|
||||
@@ -17,7 +18,7 @@ import urllib.error
|
||||
import urllib.request
|
||||
from contextlib import closing
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from bot_bottle.orchestrator_auth import ROLE_CLI, ROLE_GATEWAY, mint
|
||||
from bot_bottle.orchestrator.broker import StubBroker
|
||||
@@ -283,6 +284,25 @@ class TestServerRoundTrip(unittest.TestCase):
|
||||
))
|
||||
self.assertEqual(reg["bottle_id"], attr["bottle_id"])
|
||||
|
||||
def test_internal_failure_is_contextual_but_redacted(self) -> None:
|
||||
orch = MagicMock()
|
||||
orch.registry.all.side_effect = RuntimeError("SENSITIVE request value")
|
||||
with patch("sys.stderr", io.StringIO()) as stderr:
|
||||
server = make_server(orch, "127.0.0.1", 0)
|
||||
self.addCleanup(server.server_close)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
self.addCleanup(server.shutdown)
|
||||
host, port = server.server_address[0], server.server_address[1]
|
||||
with self.assertRaises(urllib.error.HTTPError) as raised:
|
||||
urllib.request.urlopen(f"http://{host}:{port}/bottles", timeout=5)
|
||||
payload = json.loads(raised.exception.read())
|
||||
output = stderr.getvalue()
|
||||
self.assertEqual({"error": "internal error"}, payload)
|
||||
self.assertIn("GET /bottles", output)
|
||||
self.assertIn("RuntimeError", output)
|
||||
self.assertNotIn("SENSITIVE", output)
|
||||
|
||||
|
||||
class TestOrchestratorAuth(unittest.TestCase):
|
||||
"""Role-scoped control-plane tokens (issue #400 / #469 review): every route
|
||||
@@ -647,10 +667,24 @@ class TestReconcileRoute(unittest.TestCase):
|
||||
self.assertEqual(200, status)
|
||||
self.assertEqual([], payload["reaped"])
|
||||
|
||||
def test_non_string_entries_are_ignored(self) -> None:
|
||||
dead = self._old("10.0.0.4")
|
||||
def test_non_string_entries_are_rejected(self) -> None:
|
||||
status, payload = dispatch(
|
||||
self.orch, "POST", "/reconcile",
|
||||
_body({"live_source_ips": [None, 7, "10.0.0.9"]}))
|
||||
self.assertEqual(200, status)
|
||||
self.assertEqual([dead], payload["reaped"])
|
||||
self.assertEqual(400, status)
|
||||
self.assertIn("live_source_ips", str(payload["error"]))
|
||||
|
||||
def test_empty_live_source_ip_is_rejected(self) -> None:
|
||||
status, payload = dispatch(
|
||||
self.orch, "POST", "/reconcile", _body({"live_source_ips": [""]}))
|
||||
self.assertEqual(400, status)
|
||||
self.assertIn("live_source_ips", str(payload["error"]))
|
||||
|
||||
def test_invalid_grace_seconds_is_rejected(self) -> None:
|
||||
for value in (True, "30", -1, float("inf"), float("nan")):
|
||||
with self.subTest(value=value):
|
||||
status, payload = dispatch(
|
||||
self.orch, "POST", "/reconcile",
|
||||
_body({"live_source_ips": [], "grace_seconds": value}))
|
||||
self.assertEqual(400, status)
|
||||
self.assertIn("grace_seconds", str(payload["error"]))
|
||||
|
||||
@@ -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()
|
||||
@@ -728,7 +728,7 @@ class TestResolvedRoutesPayload(unittest.TestCase):
|
||||
|
||||
|
||||
class TestNonBlockingSupervise(unittest.TestCase):
|
||||
"""PRD prd-new / issue #412: pending responses carry the proposal id, and
|
||||
"""PRD 0072 / issue #412: pending responses carry the proposal id, and
|
||||
`check-proposal` polls a queued proposal without blocking or re-proposing."""
|
||||
|
||||
_ROUTES = "routes:\n - host: example.com\n"
|
||||
|
||||
@@ -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