Compare commits

..

1 Commits

Author SHA1 Message Date
didericis-claude 01cee056be feat(secrets): encrypt egress tokens at rest with per-bottle ENV_VAR_SECRET
test / integration-docker (push) Successful in 45s
test / unit (push) Successful in 48s
Update Quality Badges / update-badges (push) Failing after 52s
test / integration-firecracker (push) Successful in 5m27s
test / coverage (push) Failing after 29s
test / publish-infra (push) Has been skipped
lint / lint (push) Successful in 2m31s
Implements the interim secret-provider design (PRD prd-new-secret-provider):
each agent receives a random ENV_VAR_SECRET injected into its container env
at launch. The host uses this key to encrypt each egress auth token value
(HMAC-SHA256 CTR mode, stdlib-only) and store it in a new
bottled_agent_secrets table (one row per env var, key column plaintext for
auditing). The key never touches the DB.

On infra container restart the in-memory token map is lost. launch_consolidated
now calls _reprovision_running_bottles after ensure_running: for each
registered bottle still alive on the gateway network it execs
`printenv ENV_VAR_SECRET` into the agent container and posts the result to the
new POST /bottles/<id>/reprovision_gateway control-plane endpoint, which
decrypts the stored rows and restores _tokens — no manual intervention needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-22 00:12:34 +00:00
29 changed files with 82 additions and 1079 deletions
+11 -24
View File
@@ -1,5 +1,4 @@
# Run the project's test suite when package or runtime inputs change on a PR # Run the project's test suite on every PR push and on push to main.
# or on push to main.
# #
# The suite uses stdlib `unittest` discovery — no external Python # The suite uses stdlib `unittest` discovery — no external Python
# dependencies are required to execute it. Tests are split by directory: # dependencies are required to execute it. Tests are split by directory:
@@ -24,34 +23,22 @@ on:
branches: branches:
- main - main
paths: paths:
- 'bot_bottle/**' - '**.py'
- 'tests/**/*.py' - '.gitea/workflows/**.yml'
- 'cli.py' - 'scripts/**'
- 'scripts/coverage.sh' - 'README.md'
- 'scripts/critical-modules.txt' # Dockerfiles and pyproject.toml are baked into the infra rootfs; a
- 'scripts/diff_coverage.py' # change here alters what the integration/coverage jobs build locally.
- 'scripts/tracker_policy.py'
- 'scripts/firecracker-netpool.sh'
- 'Dockerfile*' - 'Dockerfile*'
- 'pyproject.toml' - 'pyproject.toml'
- 'requirements-dev.txt'
- '.coveragerc'
- '.dockerignore'
pull_request: pull_request:
paths: paths:
- 'bot_bottle/**' - '**.py'
- 'tests/**/*.py' - '.gitea/workflows/**.yml'
- 'cli.py' - 'scripts/**'
- 'scripts/coverage.sh' - 'README.md'
- 'scripts/critical-modules.txt'
- 'scripts/diff_coverage.py'
- 'scripts/tracker_policy.py'
- 'scripts/firecracker-netpool.sh'
- 'Dockerfile*' - 'Dockerfile*'
- 'pyproject.toml' - 'pyproject.toml'
- 'requirements-dev.txt'
- '.coveragerc'
- '.dockerignore'
workflow_dispatch: workflow_dispatch:
jobs: jobs:
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
with: with:
fetch-depth: 0 fetch-depth: 0
token: ${{ secrets.BADGE_PUSH_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
# No actions/setup-python: the runner image ships Python 3.12 and older # No actions/setup-python: the runner image ships Python 3.12 and older
# act_runner engines mishandle setup-python's PATH. Install into the # act_runner engines mishandle setup-python's PATH. Install into the
+1 -1
View File
@@ -5,7 +5,7 @@
# bot-bottle # bot-bottle
[![test](https://gitea.dideric.is/didericis/bot-bottle/actions/workflows/test.yml/badge.svg?branch=main)](https://gitea.dideric.is/didericis/bot-bottle/actions?workflow=test.yml) [![test](https://gitea.dideric.is/didericis/bot-bottle/actions/workflows/test.yml/badge.svg?branch=main)](https://gitea.dideric.is/didericis/bot-bottle/actions?workflow=test.yml)
[![coverage](https://img.shields.io/badge/coverage-83%25-brightgreen)](https://coverage.readthedocs.io/) [![coverage](https://img.shields.io/badge/coverage-81%25-brightgreen)](https://coverage.readthedocs.io/)
[![core coverage](https://img.shields.io/badge/core%20coverage-94%25-brightgreen)](https://gitea.dideric.is/didericis/bot-bottle/src/branch/main/docs/decisions/0004-coverage-policy.md) [![core coverage](https://img.shields.io/badge/core%20coverage-94%25-brightgreen)](https://gitea.dideric.is/didericis/bot-bottle/src/branch/main/docs/decisions/0004-coverage-policy.md)
**Problem:** Developer wants to run a coding agent without supervision, but they don't want a prompt injected or misbehaving agent wrecking their environment or exfiltrating sensitive data. **Problem:** Developer wants to run a coding agent without supervision, but they don't want a prompt injected or misbehaving agent wrecking their environment or exfiltrating sensitive data.
+1 -2
View File
@@ -26,7 +26,6 @@ def provision_bottle(
*, *,
image_ref: str = "", image_ref: str = "",
tokens: dict[str, str] | None = None, tokens: dict[str, str] | None = None,
env_var_secret: str | None = None,
) -> RegisteredBottle: ) -> RegisteredBottle:
"""Register the bottle and provision its git-gate state. Rolls back the """Register the bottle and provision its git-gate state. Rolls back the
registration if provisioning fails so no orphan is left. registration if provisioning fails so no orphan is left.
@@ -36,7 +35,7 @@ def provision_bottle(
``RegisteredBottle`` so callers can inject it into the agent container's ``RegisteredBottle`` so callers can inject it into the agent container's
environment.""" environment."""
inputs = registration_inputs(egress_plan) inputs = registration_inputs(egress_plan)
env_var_secret = env_var_secret or new_env_var_secret() env_var_secret = new_env_var_secret()
reg = client.register_bottle( reg = client.register_bottle(
source_ip, image_ref=image_ref, policy=inputs.policy, source_ip, image_ref=image_ref, policy=inputs.policy,
metadata=inputs.metadata, tokens=tokens, env_var_secret=env_var_secret, metadata=inputs.metadata, tokens=tokens, env_var_secret=env_var_secret,
@@ -23,7 +23,6 @@ from ...orchestrator.client import OrchestratorClient
from ...orchestrator.gateway import GATEWAY_NETWORK from ...orchestrator.gateway import GATEWAY_NETWORK
from ...orchestrator.lifecycle import INFRA_NAME, OrchestratorService from ...orchestrator.lifecycle import INFRA_NAME, OrchestratorService
from ...orchestrator.secret_store import ENV_VAR_SECRET_NAME from ...orchestrator.secret_store import ENV_VAR_SECRET_NAME
from ...orchestrator.reprovision import reprovision_bottles
from ..consolidated_util import provision_bottle from ..consolidated_util import provision_bottle
from ..consolidated_util import teardown_consolidated as _teardown_util from ..consolidated_util import teardown_consolidated as _teardown_util
from .gateway_provision import DockerGatewayTransport from .gateway_provision import DockerGatewayTransport
@@ -103,17 +102,17 @@ def _reprovision_running_bottles(
when the orchestrator already has all tokens loaded. Best-effort: a single when the orchestrator already has all tokens loaded. Best-effort: a single
container exec failure never blocks a new bottle launch.""" container exec failure never blocks a new bottle launch."""
client = OrchestratorClient(orchestrator_url) client = OrchestratorClient(orchestrator_url)
bottles = client.list_bottles()
if not bottles:
return
# Build {source_ip: container_name} from live containers on the gateway # Build {source_ip: container_name} from live containers on the gateway
# network, excluding the infra container itself. # network, excluding the infra container itself.
try: proc = run_docker([
proc = run_docker([ "docker", "network", "inspect",
"docker", "network", "inspect", "--format", "{{range .Containers}}{{.Name}} {{.IPv4Address}}\n{{end}}",
"--format", "{{range .Containers}}{{.Name}} {{.IPv4Address}}\n{{end}}", network,
network, ])
])
except OSError as exc:
log.info(f"egress token reprovision skipped: {exc}")
return
ip_to_container: dict[str, str] = {} ip_to_container: dict[str, str] = {}
for line in proc.stdout.splitlines(): for line in proc.stdout.splitlines():
parts = line.strip().split() parts = line.strip().split()
@@ -122,15 +121,26 @@ def _reprovision_running_bottles(
if ip: if ip:
ip_to_container[ip] = parts[0] ip_to_container[ip] = parts[0]
secrets_by_ip: dict[str, str] = {} reprovisioned = 0
for source_ip, container_name in ip_to_container.items(): for bottle in bottles:
bottle_id = bottle.get("bottle_id")
source_ip = bottle.get("source_ip")
if not isinstance(bottle_id, str) or not isinstance(source_ip, str):
continue
container_name = ip_to_container.get(source_ip)
if not container_name:
continue
proc = run_docker( proc = run_docker(
["docker", "exec", container_name, "printenv", ENV_VAR_SECRET_NAME] ["docker", "exec", container_name, "printenv", ENV_VAR_SECRET_NAME]
) )
if proc.returncode == 0 and proc.stdout.strip(): if proc.returncode != 0 or not proc.stdout.strip():
secrets_by_ip[source_ip] = proc.stdout.strip() continue
try:
if client.reprovision_gateway(bottle_id, proc.stdout.strip()):
reprovisioned += 1
except Exception: # noqa: BLE001 — best-effort, never block a launch
pass
reprovisioned = reprovision_bottles(client, secrets_by_ip)
if reprovisioned: if reprovisioned:
log.info( log.info(
"reprovisioned egress tokens", "reprovisioned egress tokens",
@@ -22,9 +22,6 @@ class FirecrackerBottlePlan(BottlePlan):
# (egress proxy credentials, git-gate/supervise headers); set by launch # (egress proxy credentials, git-gate/supervise headers); set by launch
# from the orchestrator registration. Empty pre-registration. # from the orchestrator registration. Empty pre-registration.
identity_token: str = "" identity_token: str = ""
# Applied to every agent SSH exec and mirrored into /run inside the VM so
# the host can recover it after the infra VM restarts.
env_var_secret: str = ""
@property @property
def container_name(self) -> str: def container_name(self) -> str:
+16 -72
View File
@@ -1,23 +1,8 @@
"""Cleanup for the Firecracker backend. """Cleanup for the Firecracker backend.
Reaps *orphans* only — resources with no live VM behind them: Orphans are: firecracker VMM processes whose config lives under our run
dir, and the per-bottle run dirs. TAP slots free themselves (the flock
* orphan run dirs: a per-bottle run dir (holding the ~1G rootfs.ext4) drops when the launcher exits), so there is nothing to reclaim there.
whose firecracker process has exited. These leak when a launch is
hard-killed before its teardown runs (host OOM/crash, a cancelled CI
job, `kill -9`); the clean-exit path already removes its own dir in
launch.py.
* orphan VM pids: a firecracker process whose run dir is already gone
— a VMM left lingering after its dir was removed.
A run dir with a *live* firecracker process is a running bottle and is
left strictly alone: it is neither killed nor removed. (The backend's
`enumerate_active` registry is still a stub — #354 — so a live process
is the only reliable "this bottle is in use" signal we have. Once the
registry lands, registry-orphaned-but-running VMs can be reaped too.)
TAP slots free themselves (the flock drops when the launcher exits), so
there is nothing to reclaim there.
""" """
from __future__ import annotations from __future__ import annotations
@@ -37,79 +22,38 @@ def _run_root() -> Path:
return util.cache_dir() / "run" return util.cache_dir() / "run"
def _run_dir_of(cmd: str, run_root: Path) -> Path | None: def _orphan_vm_pids() -> list[int]:
"""The bottle run dir a firecracker cmdline belongs to, or None. """firecracker processes whose --config-file is under our run dir."""
run_root = str(_run_root())
A bottle VM is launched with `--config-file <run_root>/<slug>/config.json`,
so the run dir is the config file's parent when it sits directly under
the run root. Anything else (a builder VM, the infra VM elsewhere) is
not ours to reap here.
"""
toks = cmd.split()
for i, tok in enumerate(toks):
if tok == "--config-file" and i + 1 < len(toks):
parent = Path(toks[i + 1]).parent
if parent.parent == run_root:
return parent
return None
def _scan_processes(run_root: Path) -> tuple[set[str], list[int]]:
"""Inspect running firecracker VMs under ``run_root``.
Returns ``(live_run_dirs, orphan_pids)``:
* ``live_run_dirs`` — run dirs backed by a running VM (never reaped);
* ``orphan_pids`` — firecracker pids whose run dir no longer exists
(a lingering VMM to kill).
"""
result = subprocess.run( result = subprocess.run(
["pgrep", "-a", "firecracker"], ["pgrep", "-a", "firecracker"],
capture_output=True, text=True, check=False, capture_output=True, text=True, check=False,
) )
if result.returncode != 0: if result.returncode != 0:
return set(), [] return []
live: set[str] = set() pids: list[int] = []
orphan_pids: list[int] = []
for line in result.stdout.splitlines(): for line in result.stdout.splitlines():
parts = line.split(None, 1) parts = line.split(None, 1)
if len(parts) != 2: if len(parts) != 2 or run_root not in parts[1]:
continue continue
try: try:
pid = int(parts[0]) pids.append(int(parts[0]))
except ValueError: except ValueError:
continue continue
run_dir = _run_dir_of(parts[1], run_root) return pids
if run_dir is None:
continue
if run_dir.is_dir():
live.add(str(run_dir))
else:
orphan_pids.append(pid)
return live, orphan_pids
def live_run_dirs() -> tuple[Path, ...]: def _run_dirs() -> list[str]:
"""Run directories backed by currently running agent microVMs.""" run_root = _run_root()
live, _ = _scan_processes(_run_root())
return tuple(Path(path) for path in sorted(live))
def _orphan_run_dirs(run_root: Path, live: set[str]) -> list[str]:
"""Run dirs with no live VM behind them — the leaked ones to remove."""
if not run_root.is_dir(): if not run_root.is_dir():
return [] return []
return sorted( return sorted(str(p) for p in run_root.iterdir() if p.is_dir())
str(p) for p in run_root.iterdir()
if p.is_dir() and str(p) not in live
)
def prepare_cleanup() -> FirecrackerBottleCleanupPlan: def prepare_cleanup() -> FirecrackerBottleCleanupPlan:
run_root = _run_root()
live, orphan_pids = _scan_processes(run_root)
return FirecrackerBottleCleanupPlan( return FirecrackerBottleCleanupPlan(
vm_pids=tuple(orphan_pids), vm_pids=tuple(_orphan_vm_pids()),
run_dirs=tuple(_orphan_run_dirs(run_root, live)), run_dirs=tuple(_run_dirs()),
) )
@@ -25,27 +25,16 @@ The TAP slot allocation, rootfs build, and VM boot are the caller's job.
from __future__ import annotations from __future__ import annotations
import json
import subprocess
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path
from ...egress import EgressPlan from ...egress import EgressPlan
from ...git_gate import GitGatePlan from ...git_gate import GitGatePlan
from ...log import info from ...orchestrator.client import OrchestratorClient
from ...orchestrator.client import OrchestratorClient, OrchestratorClientError
from ...orchestrator.lifecycle import ( from ...orchestrator.lifecycle import (
OrchestratorStartError, # re-exported so callers can catch it OrchestratorStartError, # re-exported so callers can catch it
) )
from ...orchestrator.reprovision import reprovision_bottles from ..consolidated_util import provision_bottle, teardown_consolidated as _teardown_util
from ...orchestrator.secret_store import ENV_VAR_SECRET_NAME from . import infra_vm
from ..consolidated_util import (
provision_bottle,
teardown_consolidated as _teardown_util,
)
from . import cleanup, infra_vm, util
_ENV_VAR_SECRET_PATH = "/run/bot-bottle/env-var-secret"
class ConsolidatedLaunchError(RuntimeError): class ConsolidatedLaunchError(RuntimeError):
@@ -64,54 +53,6 @@ class LaunchContext:
env_var_secret: str = "" # encryption key injected into the agent's env env_var_secret: str = "" # encryption key injected into the agent's env
def _guest_ip_from_config(config_path: Path) -> str:
"""Read the kernel's configured guest IP from a Firecracker config."""
try:
config = json.loads(config_path.read_text())
args = config["boot-source"]["boot_args"]
ip_arg = next(part for part in args.split() if part.startswith("ip="))
return ip_arg.removeprefix("ip=").split(":", 1)[0]
except (OSError, ValueError, KeyError, TypeError, StopIteration):
return ""
def persist_env_var_secret(private_key: Path, guest_ip: str, secret: str) -> None:
"""Mirror the exec-time key into guest tmpfs for restart recovery."""
proc = subprocess.run(
util.ssh_base_argv(private_key, guest_ip)
+ [f"umask 077; mkdir -p /run/bot-bottle; cat > {_ENV_VAR_SECRET_PATH}"],
input=secret, capture_output=True, text=True, check=False,
)
if proc.returncode != 0:
raise ConsolidatedLaunchError(
f"failed to persist {ENV_VAR_SECRET_NAME} in agent VM: "
f"{proc.stderr.strip() or '<no stderr>'}"
)
def _reprovision_running_bottles(client: OrchestratorClient) -> None:
"""Read keys from live agent VMs and restore the restarted gateway."""
try:
secrets_by_ip: dict[str, str] = {}
for run_dir in cleanup.live_run_dirs():
guest_ip = _guest_ip_from_config(run_dir / "config.json")
private_key = run_dir / "bottle_id_ed25519"
if not guest_ip or not private_key.is_file():
continue
proc = subprocess.run(
util.ssh_base_argv(private_key, guest_ip)
+ [f"cat {_ENV_VAR_SECRET_PATH}"],
capture_output=True, text=True, check=False,
)
if proc.returncode == 0 and proc.stdout.strip():
secrets_by_ip[guest_ip] = proc.stdout.strip()
count = reprovision_bottles(client, secrets_by_ip)
if count:
info(f"reprovisioned egress tokens for {count} Firecracker bottle(s)")
except (OSError, OrchestratorClientError) as exc:
info(f"egress token reprovision skipped: {exc}")
def launch_consolidated( def launch_consolidated(
egress_plan: EgressPlan, egress_plan: EgressPlan,
git_gate_plan: GitGatePlan, git_gate_plan: GitGatePlan,
@@ -126,7 +67,6 @@ def launch_consolidated(
infra = infra_vm.ensure_running() infra = infra_vm.ensure_running()
url = infra.control_plane_url url = infra.control_plane_url
client = OrchestratorClient(url) client = OrchestratorClient(url)
_reprovision_running_bottles(client)
transport = infra_vm.gateway_transport() transport = infra_vm.gateway_transport()
reg = provision_bottle( reg = provision_bottle(
-11
View File
@@ -26,7 +26,6 @@ from __future__ import annotations
import dataclasses import dataclasses
import os import os
import shutil
from contextlib import ExitStack, contextmanager from contextlib import ExitStack, contextmanager
from pathlib import Path from pathlib import Path
from typing import Callable, Generator from typing import Callable, Generator
@@ -55,10 +54,8 @@ from . import firecracker_vm, image_builder, isolation_probe, netpool, util
from .bottle import FirecrackerBottle from .bottle import FirecrackerBottle
from .bottle_plan import FirecrackerBottlePlan from .bottle_plan import FirecrackerBottlePlan
from ...orchestrator.config_store import resolve_teardown_timeout from ...orchestrator.config_store import resolve_teardown_timeout
from ...orchestrator.secret_store import ENV_VAR_SECRET_NAME
from .consolidated_launch import ( from .consolidated_launch import (
launch_consolidated, launch_consolidated,
persist_env_var_secret,
teardown_consolidated, teardown_consolidated,
) )
@@ -155,7 +152,6 @@ def launch(
git_gate_plan=git_gate_plan, git_gate_plan=git_gate_plan,
egress_plan=egress_plan, egress_plan=egress_plan,
identity_token=ctx.identity_token, identity_token=ctx.identity_token,
env_var_secret=ctx.env_var_secret,
# Deliver the identity token as egress proxy credentials — clients # Deliver the identity token as egress proxy credentials — clients
# honor `HTTPS_PROXY=http://id:token@gw` without app changes; the # honor `HTTPS_PROXY=http://id:token@gw` without app changes; the
# gateway reads Proxy-Authorization, validates the (source_ip, # gateway reads Proxy-Authorization, validates the (source_ip,
@@ -171,10 +167,6 @@ def launch(
# Step 6: build the per-bottle rootfs + SSH key, then boot. # Step 6: build the per-bottle rootfs + SSH key, then boot.
run_dir = util.cache_dir() / "run" / plan.slug run_dir = util.cache_dir() / "run" / plan.slug
run_dir.mkdir(parents=True, exist_ok=True) run_dir.mkdir(parents=True, exist_ok=True)
# Remove the run dir on teardown so the per-bottle rootfs.ext4 (~1G)
# doesn't leak. Registered before vm.terminate below so it runs *after*
# it (ExitStack is LIFO): the VM is gone before we rm its rootfs.
stack.callback(lambda: shutil.rmtree(run_dir, ignore_errors=True))
rootfs = run_dir / "rootfs.ext4" rootfs = run_dir / "rootfs.ext4"
util.build_rootfs_ext4(agent_base, rootfs) util.build_rootfs_ext4(agent_base, rootfs)
private_key, pubkey = util.generate_keypair(run_dir) private_key, pubkey = util.generate_keypair(run_dir)
@@ -190,7 +182,6 @@ def launch(
) )
stack.callback(vm.terminate) stack.callback(vm.terminate)
firecracker_vm.wait_for_ssh(vm, private_key) firecracker_vm.wait_for_ssh(vm, private_key)
persist_env_var_secret(private_key, slot.guest_ip, ctx.env_var_secret)
# Authoritative fail-closed egress-boundary check, before the agent # Authoritative fail-closed egress-boundary check, before the agent
# runs: prove the VM cannot reach the host directly. # runs: prove the VM cannot reach the host directly.
@@ -285,8 +276,6 @@ def _agent_guest_env(plan: FirecrackerBottlePlan, host_ip: str) -> dict[str, str
env["GIT_GATE_URL"] = plan.agent_git_gate_url env["GIT_GATE_URL"] = plan.agent_git_gate_url
if plan.agent_supervise_url: if plan.agent_supervise_url:
env["MCP_SUPERVISE_URL"] = plan.agent_supervise_url env["MCP_SUPERVISE_URL"] = plan.agent_supervise_url
if plan.env_var_secret:
env[ENV_VAR_SECRET_NAME] = plan.env_var_secret
for entry in egress_agent_env_entries(plan.egress_plan): for entry in egress_agent_env_entries(plan.egress_plan):
key, _, value = entry.partition("=") key, _, value = entry.partition("=")
env[key] = value env[key] = value
-3
View File
@@ -399,9 +399,6 @@ fi
chown -R 0:0 /root 2>/dev/null || true chown -R 0:0 /root 2>/dev/null || true
mkdir -p /etc/dropbear /run mkdir -p /etc/dropbear /run
# Keep restart-recovery key material memory-backed, separate from both the
# agent rootfs and the infra VM's persistent registry volume.
mount -t tmpfs -o mode=0755 tmpfs /run 2>/dev/null || true
# -R: generate host keys on demand. -E: log auth failures to stderr, # -R: generate host keys on demand. -E: log auth failures to stderr,
# captured in the host-side console.log for debugging. # captured in the host-side console.log for debugging.
/bb-dropbear -R -E -p 22 & /bb-dropbear -R -E -p 22 &
@@ -20,9 +20,6 @@ class MacosContainerBottlePlan(BottlePlan):
# bottle is registered. See launch.py's stamp for why it lives here and not # bottle is registered. See launch.py's stamp for why it lives here and not
# only in the exec-time proxy env. # only in the exec-time proxy env.
identity_token: str = "" identity_token: str = ""
# Generated before `container run` so it becomes part of the container's
# configured environment and can be read back after an infra restart.
env_var_secret: str = ""
@property @property
def container_name(self) -> str: def container_name(self) -> str:
@@ -38,12 +38,7 @@ from ...egress import EgressPlan
from ...git_gate import GitGatePlan from ...git_gate import GitGatePlan
from ...log import info from ...log import info
from ...orchestrator.client import OrchestratorClient, OrchestratorClientError from ...orchestrator.client import OrchestratorClient, OrchestratorClientError
from ...orchestrator.reprovision import reprovision_bottles from ..consolidated_util import provision_bottle, teardown_consolidated as _teardown_util
from ...orchestrator.secret_store import ENV_VAR_SECRET_NAME
from ..consolidated_util import (
provision_bottle,
teardown_consolidated as _teardown_util,
)
from . import util as container_mod from . import util as container_mod
from .enumerate import CONTAINER_NAME_PREFIX, EnumerationError, enumerate_active from .enumerate import CONTAINER_NAME_PREFIX, EnumerationError, enumerate_active
from .gateway import GATEWAY_NETWORK from .gateway import GATEWAY_NETWORK
@@ -89,35 +84,12 @@ def ensure_gateway(
needs `gateway_ip` at run time.""" needs `gateway_ip` at run time."""
service = service or MacosInfraService() service = service or MacosInfraService()
infra = service.ensure_running() infra = service.ensure_running()
endpoint = GatewayEndpoint( return GatewayEndpoint(
orchestrator_url=infra.control_plane_url, orchestrator_url=infra.control_plane_url,
gateway_ip=infra.gateway_ip, gateway_ip=infra.gateway_ip,
gateway_ca_pem=service.ca_cert_pem(), gateway_ca_pem=service.ca_cert_pem(),
network=service.network, network=service.network,
) )
_reprovision_running_bottles(endpoint)
return endpoint
def _reprovision_running_bottles(endpoint: GatewayEndpoint) -> None:
"""Recover keys from live Apple containers and restore gateway tokens."""
try:
secrets_by_ip: dict[str, str] = {}
for agent in enumerate_active():
name = f"{CONTAINER_NAME_PREFIX}{agent.slug}"
source_ip = container_mod.inspect_container_network_ip(name, endpoint.network)
if not source_ip:
continue
secret = container_mod.read_container_env(name, ENV_VAR_SECRET_NAME)
if secret:
secrets_by_ip[source_ip] = secret
count = reprovision_bottles(
OrchestratorClient(endpoint.orchestrator_url), secrets_by_ip,
)
if count:
info(f"reprovisioned egress tokens for {count} macOS bottle(s)")
except (OrchestratorClientError, EnumerationError, OSError) as exc:
info(f"egress token reprovision skipped: {exc}")
def live_source_ips(network: str) -> list[str]: def live_source_ips(network: str) -> list[str]:
@@ -154,7 +126,6 @@ def register_agent(
endpoint: GatewayEndpoint, endpoint: GatewayEndpoint,
image_ref: str = "", image_ref: str = "",
tokens: dict[str, str] | None = None, tokens: dict[str, str] | None = None,
env_var_secret: str | None = None,
) -> LaunchContext: ) -> LaunchContext:
"""Register the (already running) agent by its address and provision its """Register the (already running) agent by its address and provision its
git-gate state into the gateway. `source_ip` must be read from the live git-gate state into the gateway. `source_ip` must be read from the live
@@ -174,7 +145,6 @@ def register_agent(
reg = provision_bottle( reg = provision_bottle(
client, source_ip, egress_plan, git_gate_plan, AppleGatewayTransport(), client, source_ip, egress_plan, git_gate_plan, AppleGatewayTransport(),
image_ref=image_ref, tokens=tokens, image_ref=image_ref, tokens=tokens,
env_var_secret=env_var_secret,
) )
return LaunchContext( return LaunchContext(
bottle_id=reg.bottle_id, bottle_id=reg.bottle_id,
@@ -68,7 +68,6 @@ from .gateway_hosts import (
) )
from .bottle_plan import MacosContainerBottlePlan from .bottle_plan import MacosContainerBottlePlan
from ...orchestrator.config_store import resolve_teardown_timeout from ...orchestrator.config_store import resolve_teardown_timeout
from ...orchestrator.secret_store import ENV_VAR_SECRET_NAME, new_env_var_secret
from .consolidated_launch import ( from .consolidated_launch import (
GatewayEndpoint, GatewayEndpoint,
ensure_gateway, ensure_gateway,
@@ -143,7 +142,6 @@ def launch(
plan = _provision_git_gate_keys(plan) plan = _provision_git_gate_keys(plan)
plan = _install_gateway_ca(plan, endpoint) plan = _install_gateway_ca(plan, endpoint)
plan = _stamp_agent_urls(plan, endpoint) plan = _stamp_agent_urls(plan, endpoint)
plan = dataclasses.replace(plan, env_var_secret=new_env_var_secret())
# Step 3: run the agent. It has no identity token yet — registration # Step 3: run the agent. It has no identity token yet — registration
# needs the address this run assigns. # needs the address this run assigns.
@@ -178,7 +176,6 @@ def launch(
endpoint=endpoint, endpoint=endpoint,
image_ref=plan.image, image_ref=plan.image,
tokens=token_values, tokens=token_values,
env_var_secret=plan.env_var_secret,
) )
stack.callback( stack.callback(
teardown_consolidated, ctx.bottle_id, teardown_consolidated, ctx.bottle_id,
@@ -409,8 +406,6 @@ def _agent_env_entries(
env.append(f"GIT_GATE_URL={plan.agent_git_gate_url}") env.append(f"GIT_GATE_URL={plan.agent_git_gate_url}")
if plan.agent_supervise_url: if plan.agent_supervise_url:
env.append(f"MCP_SUPERVISE_URL={plan.agent_supervise_url}") env.append(f"MCP_SUPERVISE_URL={plan.agent_supervise_url}")
if getattr(plan, "env_var_secret", ""):
env.append(f"{ENV_VAR_SECRET_NAME}={plan.env_var_secret}")
for name, value in sorted(plan.agent_provision.guest_env.items()): for name, value in sorted(plan.agent_provision.guest_env.items()):
env.append(f"{name}={value}") env.append(f"{name}={value}")
# Forwarded vars: bare name → inherits from the `container run` process env # Forwarded vars: bare name → inherits from the `container run` process env
@@ -361,12 +361,6 @@ def exec_container(name: str, argv: list[str]) -> None:
) )
def read_container_env(name: str, env_name: str) -> str:
"""Read one configured env value from a running container, or ``""``."""
result = _run_container_op([_CONTAINER, "exec", name, "printenv", env_name])
return result.stdout.strip() if result.returncode == 0 else ""
def exec_container_as_root(name: str, argv: list[str]) -> None: def exec_container_as_root(name: str, argv: list[str]) -> None:
"""`exec_container`, but as uid 0 inside the container. """`exec_container`, but as uid 0 inside the container.
-35
View File
@@ -1,35 +0,0 @@
"""Shared host-side join for backend-discovered bottle encryption keys."""
from __future__ import annotations
from .client import OrchestratorClient, OrchestratorClientError
def reprovision_bottles(
client: OrchestratorClient,
secrets_by_source_ip: dict[str, str],
) -> int:
"""Restore tokens for registered bottles whose backend exposes a key.
Backends own discovery because containers and microVMs have different
enumeration primitives. This helper owns the shared registry join and
intentionally tolerates one bad/missing key without blocking a launch.
"""
restored = 0
for bottle in client.list_bottles():
bottle_id = bottle.get("bottle_id")
source_ip = bottle.get("source_ip")
if not isinstance(bottle_id, str) or not isinstance(source_ip, str):
continue
secret = secrets_by_source_ip.get(source_ip, "").strip()
if not secret:
continue
try:
if client.reprovision_gateway(bottle_id, secret):
restored += 1
except OrchestratorClientError:
continue
return restored
__all__ = ["reprovision_bottles"]
@@ -1,131 +0,0 @@
# PRD prd-new: Encrypted at-rest egress secrets (SecretProvider, interim slice)
- **Status:** Draft
- **Author:** didericis
- **Created:** 2026-07-21
- **Issue:** #355
## Summary
An interim step toward the generic `SecretProvider` (#355) that stops short
of per-request minting. Today the orchestrator holds each bottle's egress
auth tokens **in process memory only**, so any infra-container recreation
silently strips every already-running bottle of its upstream credentials.
This PRD makes those secrets survive a gateway restart by persisting them
**encrypted**, under a key that is not itself sitting next to the
ciphertext.
The end state in #355 — short-lived, scoped credentials minted per request
— removes the need to store anything durable at all. That is a larger
change gated on per-upstream minting support. This slice buys back
restart-survivability now without regressing to plaintext secrets at rest.
## Problem
`Orchestrator._tokens` (`bot_bottle/orchestrator/service.py:74-79`) is a
plain in-memory dict, deliberately never written to the registry DB:
> Held **in memory only** — never written to the registry DB — so the
> gateway can inject each bottle's upstream credential without secrets at
> rest. Lost on restart (re-launch re-registers them); the future
> SecretProvider (#355) replaces this with per-request minting.
The registry itself *is* durable (SQLite on a container-only volume), and
so is the gateway CA since #450 / `2cd44cf7`. The tokens are now the only
piece of gateway state that does not survive a restart, which makes the
failure mode both silent and confusing.
### Observed failure
Checking out a branch that touches `bot_bottle/**/*.py` changes
`source_hash()` (`bot_bottle/orchestrator/lifecycle.py:88-99`).
`MacosInfraService._source_current()`
(`bot_bottle/backend/macos_container/infra.py:159-169`) sees the mismatch
and `ensure_running()` force-removes and recreates the infra container
(`infra.py:198-208`). The registry rows survive on the DB volume; the CA
survives on its host bind-mount; `_tokens` comes back empty.
Every already-running bottle then fails closed, mid-session, on its next
outbound request:
- `/resolve` succeeds — the bottle is still `active` in
`orchestrator_bottles` and its policy blob is served intact, including
`- host: "api.anthropic.com"` with `auth_scheme: Bearer` /
`token_env: EGRESS_TOKEN_0`.
- `tokens_for()` returns `{}`, so the resolved env overlay has no
`EGRESS_TOKEN_0`.
- `decide()` (`bot_bottle/egress_addon_core.py:644-652`) blocks with
`egress: route for 'api.anthropic.com' declared auth but env var
'EGRESS_TOKEN_0' is unset` — an 89-byte `403` on every request.
Confirmed live on the macOS backend on 2026-07-21: two bottles running
since 20:29/20:30 were still registered `active` with valid policy after
the 23:05 infra recreation, and both took 89-byte `403`s from then on,
while a bottle launched *after* the recreation egressed normally. The
recovery today is to relaunch every affected bottle.
Note this is a re-attachment blocker distinct from #443/#445 and from #450
— the CA and the gateway address were both fine. It is specifically the
credential wipe.
## Goals / Success criteria
1. A bottle's egress auth tokens survive infra-container recreation: an
already-running bottle keeps egressing across a gateway restart with no
relaunch and no operator action.
2. Secrets are **never** at rest in plaintext, and never at rest next to a
key that trivially decrypts them.
3. Compromise of the registry DB file alone does not yield usable
upstream credentials.
4. The stored form is revocable and rotatable without relaunching bottles
that are not affected.
5. When the retained bottle record reaches the lifecycle status `removed`,
its stored secrets are destroyed. Status/event persistence and the removal
transition land separately; this interim slice intentionally retains the
ciphertext across today's teardown/reconcile calls until that lifecycle is
available.
6. Migration is transparent: existing bottles keep working, no manifest
changes required.
## Non-goals
- **Per-request minting** of short-lived scoped credentials. That is the
#355 end state; this PRD is explicitly the interim slice and should not
foreclose it.
- Generalizing `DeployKeyProvisioner` into the full `SecretProvider` ABC,
or the manifest-level `{ provider: <name> }` reference surface.
- User-extensible provider discovery (`~/.bot-bottle/contrib/<name>/`).
- Changing the `/resolve` contract's shape (it already carries `tokens`).
- Fixing the *trigger*`source_hash` churn on branch switch. Recreating
infra is legitimate; it just must not cost running bottles their
credentials. A separate guard that refuses recreation while bottles are
active is complementary and out of scope here.
## Design
> **TODO (didericis):** the encryption flow goes here — key custody, where
> the key material lives relative to the ciphertext, the wrap/unwrap path
> at register and at `/resolve`, and what an attacker who holds only the
> DB (or only the host, or only the infra container) can recover.
Constraints the design has to satisfy, for reference while drafting:
- The gateway's `PolicyResolver` needs the cleartext at request time, on
the data-plane path, so unwrap has to be cheap enough to sit in a
per-flow `/resolve` (or be cached in memory after first unwrap).
- The infra container is recreated routinely and unattended. Anything
requiring an interactive unlock on every recreation defeats the goal.
- The DB lives on a container-only volume that the host does not mount, so
host-side and guest-side components see different filesystems — that
asymmetry is available as a place to split custody.
- The agent must never be able to reach the key material. It is a separate
container with no control-plane token, which is the existing boundary.
## Open questions
- Where does the unwrap key live, and what recreates/re-derives it when the
infra container is rebuilt?
- Is the cleartext cached in memory after first unwrap, or unwrapped per
request? (Latency vs. exposure window.)
- What is the rotation story — re-wrap in place, or force re-registration?
- Does this land behind a flag, or replace `_tokens` outright?
@@ -1,178 +0,0 @@
# Firecracker Image Remote Store
**Date:** 2026-07-22
**Context:** PR #459 (run-dir leak fix) surfaced that committed Firecracker snapshots
currently live only on the host machine. Once a host is wiped or a run-dir is
evicted, a user's preserved bottle is gone. This note investigates a secure remote
store so committed images survive host turnover and can be restored without the
user having to pre-flag which sessions to keep.
## Verdict
Backblaze B2 + Cloudflare CDN is the cost-optimal choice for most deployments.
Cloudflare R2 is the simpler zero-config option at slightly higher storage cost.
Self-hosted MinIO is the right call for air-gapped or on-premises installs.
On the image-size front, zstd-compressing the committed tar before upload
produces roughly a 6070% reduction with negligible impact on restore latency.
OverlayFS (for in-flight working rootfs, not for the committed artifact) cuts
per-instance disk use to ~1050 MB per extra bottle sharing the same base.
---
## What Gets Stored
The Firecracker backend produces two artifact types:
| Artifact | Created by | Size | Lifetime |
|----------|-----------|------|---------|
| `rootfs/agent-<digest>/` (dir) | `image_builder.py``mke2fs -d` | ~1 GB as ext4 | Cached per Dockerfile hash; evictable |
| `committed/<slug>/rootfs.tar` | `FirecrackerFreezer._freeze` via SSH tar | 500 MB1 GB | User-preserved; must survive host wipe |
The committed artifact is a tar of the guest's live filesystem streamed out over
SSH (`freezer.py:5890`). At resume time `launch.py` calls `mke2fs -d` to
rebuild a fresh ext4 from this tar. The tar — not the ext4 — is what needs to be
pushed to remote storage and pulled back at restore time.
The base image cache (`rootfs/agent-<digest>/`) is derivable from the Dockerfile
and can be rebuilt on demand; it is lower priority for remote storage.
---
## Storage Candidates
### Object storage
| Provider | Storage | Egress | Notes |
|----------|---------|--------|-------|
| **Backblaze B2** | $0.006/GB | Free (via Cloudflare Bandwidth Alliance) | Cheapest storage; pairs with Cloudflare CDN to eliminate egress |
| **Cloudflare R2** | $0.015/GB | $0 always | Zero-config egress; no lifecycle transitions (limitation) |
| **Wasabi** | $0.0069/GB | Free (1:1 ratio) | 90-day minimum retention; good for archival; lifecycle evaluated daily |
| **AWS S3** | $0.023/GB | $0.09/GB | Richest lifecycle support; expensive at scale; avoid unless already in AWS |
| **MinIO** (self-hosted) | Host cost only | None | S3-compatible; best for private/on-prem deployments |
**B2 + Cloudflare CDN** is effectively $0.006/GB with zero egress — about 18×
cheaper than S3 for restore-heavy workloads. **R2** is the zero-config choice
($0 egress by default, no Bandwidth Alliance pairing needed) at a slightly
higher storage rate.
**Cloudflare R2's missing lifecycle support** is the main caveat: auto-eviction
rules (evict images older than N days) cannot currently be expressed natively in
R2. Wasabi and S3 both support declarative lifecycle policies.
### Retention policy recommendation
The comment proposes:
- Retain images for ~1 week by default
- Warn when approaching a capacity threshold
- Auto-evict oldest images once threshold is exceeded
This maps cleanly to an application-level policy (not a provider lifecycle rule),
which avoids the R2 limitation and works consistently across providers:
1. On `commit`: upload tar, record `(slug, size_bytes, uploaded_at)` in a local
or remote manifest file.
2. On startup / on `list`: scan the manifest, warn if total stored size exceeds
e.g. 80% of the configured threshold.
3. On eviction run (CLI or cron): delete objects older than `retention_days`
(default 7) that push total over `max_capacity`; oldest-first.
This keeps the policy logic in bot-bottle and the storage provider as a dumb
object store — no vendor-specific lifecycle API required.
---
## Image Size Reduction
### Current artifact sizes
A typical committed tar for a Claude Code agent image is 500 MB1 GB uncompressed.
The per-run ext4 (copy of the base, written at `start`) adds another ~1 GB of
local disk. The leak fix in this PR addresses the ext4 copies; the remote store
addresses the committed tars.
### Compression
zstd compression of the committed tar before upload is the highest-leverage
single change:
| Codec | Typical size (1 GB rootfs) | Compress speed | Decompress speed |
|-------|---------------------------|---------------|-----------------|
| gzip | 350430 MB | ~100 MB/s | ~500 MB/s |
| **zstd (default)** | **330360 MB** | **~400 MB/s** | **~2 GB/s** |
| xz | 290320 MB | ~20 MB/s | ~200 MB/s |
**zstd is the best trade-off**: 6567% size reduction, near-instantaneous
decompression. The `tar` call in `freezer.py` could pipe through `zstd` before
writing to disk and to the remote; `resume` decompresses on the way back. A
`.tar.zst` suffix marks compressed artifacts so old tars remain restorable
without the codec.
### SquashFS for the base image cache
The `rootfs/agent-<digest>/` directory (the buildah-exported tree) is rebuilt by
`image_builder.py` and turned into per-run ext4 by `mke2fs -d`. Storing the
base as a SquashFS image instead of a flat directory tree would reduce it from
~1 GB to ~330360 MB and make the cache remote-friendly. Firecracker does not
directly boot SquashFS, but the existing `mke2fs -d` path reads a directory tree
— a SquashFS mount could serve as the source. This is a larger change and lower
priority than tar compression.
### OverlayFS for per-run rootfs
Multiple simultaneous bottles sharing the same agent image today each get a full
`mke2fs -d` copy (~1 GB). OverlayFS (read-only base + writable sparse overlay)
would reduce this to ~1050 MB per instance beyond the first:
- Mount the base image directory as read-only lower layer
- Attach a sparse ext4 or tmpfs writable layer per bottle
- Pass the merged overlay to Firecracker as the block device
E2B's public write-up on Firecracker + OverlayFS confirms this approach works
at scale. The `launch.py` changes would be non-trivial (device mapper or
`fuse-overlayfs` plumbing), so this is a follow-up rather than a prerequisite
for the remote store.
---
## Recommended Approach
**Phase 1 — remote store with zstd (tight scope, actionable now)**
1. Add `--zstd` to the `tar` call in `FirecrackerFreezer._freeze`; name the
artifact `rootfs.tar.zst`. Keep uncompressed restore path for legacy tars.
2. Add a `bb firecracker upload <slug>` / `bb firecracker pull <slug>` pair that
pushes/fetches the compressed tar to the configured object store (S3-compatible
API, so B2, R2, MinIO, and Wasabi all work with the same client).
3. Store a `manifest.json` in the bucket (or a local mirror) tracking slug →
`{size, uploaded_at}`. Use it for threshold warnings and eviction.
4. Default retention: 7 days, configurable via `firecracker.image_retention_days`
in `~/.config/bot-bottle/config.toml` (or equivalent).
5. Warn at 80% of `max_capacity` (default e.g. 50 GB); evict oldest on commit
once at 100%.
**Storage recommendation:** Cloudflare R2 for hosted deployments (zero egress,
zero config), MinIO for private/on-premises.
**Phase 2 — base image cache compression**
Compress the `agent-<digest>` cache dir as a `.tar.zst` to save ~65% on repeated
image uploads. Low urgency since the base image is rebuildable.
**Phase 3 — OverlayFS per-run disk**
Replace the full per-run ext4 copy with an OverlayFS sparse layer. Largest disk
impact (~9095% savings per concurrent bottle) but highest implementation
complexity. Track as a separate PRD.
---
## Open Questions
- Does the host have a configured object-store credential path, or should the
remote store be an opt-in with an explicit `bb config set image-store.url ...`?
- Should `commit` automatically upload, or should upload be an explicit step to
avoid surprise egress?
- What is the acceptable cold-start latency for a restore from remote? A 330 MB
zstd tar at 100 Mbit/s takes ~26 s; at 1 Gbit/s, ~2.6 s. This bounds the
retention strategy (evict from local after successful upload vs keep local copy).
@@ -1,160 +0,0 @@
"""Backend-agnostic and backend-specific encrypted-secret recovery tests."""
from __future__ import annotations
import json
import subprocess
import tempfile
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import Mock, patch
from bot_bottle.orchestrator.reprovision import reprovision_bottles
from bot_bottle.backend.firecracker import consolidated_launch as fc
from bot_bottle.backend.macos_container import consolidated_launch as mac
from bot_bottle.backend.docker import consolidated_launch as docker
from bot_bottle.orchestrator.client import OrchestratorClientError
def _proc(returncode: int = 0, stdout: str = "", stderr: str = ""):
return subprocess.CompletedProcess([], returncode, stdout=stdout, stderr=stderr)
class TestSharedReprovision(unittest.TestCase):
def test_joins_registry_records_by_source_ip(self) -> None:
client = Mock()
client.list_bottles.return_value = [
{"bottle_id": "b1", "source_ip": "10.0.0.1"},
{"bottle_id": "b2", "source_ip": "10.0.0.2"},
{"bottle_id": 3, "source_ip": "10.0.0.3"},
]
client.reprovision_gateway.side_effect = [True, False]
count = reprovision_bottles(
client, {"10.0.0.1": " key-1\n", "10.0.0.2": "key-2"},
)
self.assertEqual(1, count)
self.assertEqual(
[("b1", "key-1"), ("b2", "key-2")],
[call.args for call in client.reprovision_gateway.call_args_list],
)
def test_one_failure_does_not_block_other_bottles(self) -> None:
client = Mock()
client.list_bottles.return_value = [
{"bottle_id": "b1", "source_ip": "10.0.0.1"},
{"bottle_id": "b2", "source_ip": "10.0.0.2"},
]
client.reprovision_gateway.side_effect = [
OrchestratorClientError("bad key"), True,
]
self.assertEqual(
1,
reprovision_bottles(
client, {"10.0.0.1": "key-1", "10.0.0.2": "key-2"},
),
)
class TestMacosReprovision(unittest.TestCase):
def test_reads_configured_container_env_and_reprovisions(self) -> None:
endpoint = mac.GatewayEndpoint("http://orch", "10.0.0.9", "PEM", "net")
agent = SimpleNamespace(slug="demo")
client = Mock()
with patch.object(mac, "enumerate_active", return_value=[agent]), \
patch.object(mac.container_mod, "inspect_container_network_ip",
return_value="10.0.0.1"), \
patch.object(mac.container_mod, "read_container_env", return_value="key"), \
patch.object(mac, "OrchestratorClient", return_value=client), \
patch.object(mac, "reprovision_bottles", return_value=1) as restore, \
patch.object(mac, "info"):
mac._reprovision_running_bottles(endpoint)
restore.assert_called_once_with(client, {"10.0.0.1": "key"})
def test_enumeration_failure_is_best_effort(self) -> None:
endpoint = mac.GatewayEndpoint("http://orch", "10.0.0.9", "PEM", "net")
with patch.object(mac, "enumerate_active",
side_effect=mac.EnumerationError("failed")), \
patch.object(mac, "info") as info:
mac._reprovision_running_bottles(endpoint)
self.assertIn("skipped", info.call_args.args[0])
class TestDockerReprovision(unittest.TestCase):
def test_maps_network_containers_to_keys(self) -> None:
inspect = _proc(stdout=(
"bot-bottle-infra 172.18.0.2/16\n"
"bot-bottle-a 172.18.0.3/16\n"
"malformed\n"
))
key = _proc(stdout="secret\n")
client = Mock()
with patch.object(docker, "OrchestratorClient", return_value=client), \
patch.object(docker, "run_docker", side_effect=[inspect, key]), \
patch.object(docker, "reprovision_bottles", return_value=1) as restore, \
patch.object(docker.log, "info"):
docker._reprovision_running_bottles("http://orch")
restore.assert_called_once_with(client, {"172.18.0.3": "secret"})
def test_missing_docker_is_best_effort(self) -> None:
with patch.object(docker, "run_docker", side_effect=FileNotFoundError("docker")), \
patch.object(docker.log, "info") as info:
docker._reprovision_running_bottles("http://orch")
self.assertIn("skipped", info.call_args.args[0])
class TestFirecrackerReprovision(unittest.TestCase):
def _run_dir(self, root: Path, ip: str = "10.243.0.3") -> Path:
run_dir = root / "demo"
run_dir.mkdir()
(run_dir / "bottle_id_ed25519").write_text("key")
(run_dir / "config.json").write_text(json.dumps({
"boot-source": {"boot_args": f"root=/dev/vda ip={ip}::gw:mask::eth0:off"}
}))
return run_dir
def test_extracts_guest_ip_from_config(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
run_dir = self._run_dir(Path(tmp))
self.assertEqual("10.243.0.3", fc._guest_ip_from_config(run_dir / "config.json"))
self.assertEqual("", fc._guest_ip_from_config(run_dir / "missing.json"))
def test_persists_key_over_stdin_not_argv(self) -> None:
with patch.object(fc.util, "ssh_base_argv", return_value=["ssh", "guest"]), \
patch.object(fc.subprocess, "run", return_value=_proc()) as run:
fc.persist_env_var_secret(Path("/key"), "10.0.0.1", "super-secret")
self.assertEqual("super-secret", run.call_args.kwargs["input"])
self.assertNotIn("super-secret", " ".join(run.call_args.args[0]))
def test_persist_failure_is_fatal_to_launch(self) -> None:
with patch.object(fc.util, "ssh_base_argv", return_value=["ssh", "guest"]), \
patch.object(fc.subprocess, "run", return_value=_proc(1, stderr="denied")):
with self.assertRaisesRegex(fc.ConsolidatedLaunchError, "denied"):
fc.persist_env_var_secret(Path("/key"), "10.0.0.1", "secret")
def test_reads_live_vm_key_and_reprovisions(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
run_dir = self._run_dir(Path(tmp))
client = Mock()
with patch.object(fc.cleanup, "live_run_dirs", return_value=(run_dir,)), \
patch.object(fc.util, "ssh_base_argv", return_value=["ssh", "guest"]), \
patch.object(fc.subprocess, "run", return_value=_proc(stdout="secret\n")), \
patch.object(fc, "reprovision_bottles", return_value=1) as restore, \
patch.object(fc, "info"):
fc._reprovision_running_bottles(client)
restore.assert_called_once_with(client, {"10.243.0.3": "secret"})
def test_unreadable_vm_is_skipped(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
run_dir = self._run_dir(Path(tmp))
client = Mock()
with patch.object(fc.cleanup, "live_run_dirs", return_value=(run_dir,)), \
patch.object(fc.util, "ssh_base_argv", return_value=["ssh", "guest"]), \
patch.object(fc.subprocess, "run", return_value=_proc(1)), \
patch.object(fc, "reprovision_bottles", return_value=0) as restore:
fc._reprovision_running_bottles(client)
restore.assert_called_once_with(client, {})
if __name__ == "__main__":
unittest.main()
-10
View File
@@ -2,7 +2,6 @@
from __future__ import annotations from __future__ import annotations
import dataclasses
import unittest import unittest
from bot_bottle.backend.docker.consolidated_compose import consolidated_agent_compose from bot_bottle.backend.docker.consolidated_compose import consolidated_agent_compose
@@ -47,15 +46,6 @@ class TestConsolidatedAgentCompose(unittest.TestCase):
# forwarded secrets are bare names (value inherited from process env). # forwarded secrets are bare names (value inherited from process env).
self.assertIn("CLAUDE_CODE_OAUTH_TOKEN", env) self.assertIn("CLAUDE_CODE_OAUTH_TOKEN", env)
def test_env_var_secret_stays_a_bare_name(self) -> None:
plan = _plan(with_egress=True, supervise=True, with_git=True)
plan = dataclasses.replace(plan, env_var_secret="secret-value")
env = consolidated_agent_compose(
plan, gateway_ip=_GW, source_ip=_IP, network=_NET,
)["services"]["agent"]["environment"]
self.assertIn("ENV_VAR_SECRET", env)
self.assertNotIn("ENV_VAR_SECRET=secret-value", env)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+1 -5
View File
@@ -91,7 +91,6 @@ class TestTeardownWarning(unittest.TestCase):
bottle_id="b1", identity_token="t", source_ip="172.20.0.4", bottle_id="b1", identity_token="t", source_ip="172.20.0.4",
network="bot-bottle-gateway", gateway_ip="172.20.0.2", network="bot-bottle-gateway", gateway_ip="172.20.0.2",
orchestrator_url="http://orch:8099", orchestrator_url="http://orch:8099",
env_var_secret="encryption-key",
) )
images = BottleImages(agent="bot-bottle-claude:latest", sidecar="bot-bottle-sidecars:latest") images = BottleImages(agent="bot-bottle-claude:latest", sidecar="bot-bottle-sidecars:latest")
@@ -108,7 +107,7 @@ class TestTeardownWarning(unittest.TestCase):
mock.patch.object( mock.patch.object(
launch_mod, "write_compose_file", return_value=Path("/tmp/compose.yml"), launch_mod, "write_compose_file", return_value=Path("/tmp/compose.yml"),
), \ ), \
mock.patch.object(launch_mod, "compose_up") as compose_up, \ mock.patch.object(launch_mod, "compose_up"), \
mock.patch.object(launch_mod, "compose_dump_logs"), \ mock.patch.object(launch_mod, "compose_dump_logs"), \
mock.patch.object( mock.patch.object(
launch_mod, "compose_down", launch_mod, "compose_down",
@@ -123,9 +122,6 @@ class TestTeardownWarning(unittest.TestCase):
self.assertIn("bot-bottle: warning:", output) self.assertIn("bot-bottle: warning:", output)
self.assertIn("bot-bottle-test-teardown-abc", output) self.assertIn("bot-bottle-test-teardown-abc", output)
self.assertIn("compose-down", output) self.assertIn("compose-down", output)
self.assertEqual(
"encryption-key", compose_up.call_args.kwargs["env"]["ENV_VAR_SECRET"],
)
if __name__ == "__main__": if __name__ == "__main__":
-1
View File
@@ -66,7 +66,6 @@ class TestNetpoolRenderers(unittest.TestCase):
self.assertIn("chown node:node /home/node", util._GUEST_INIT) self.assertIn("chown node:node /home/node", util._GUEST_INIT)
self.assertIn("chmod 755 /home/node", util._GUEST_INIT) self.assertIn("chmod 755 /home/node", util._GUEST_INIT)
self.assertIn("mount -t tmpfs -o mode=0755 tmpfs /run", util._GUEST_INIT)
def test_nixos_module_is_non_invasive(self): def test_nixos_module_is_non_invasive(self):
# The NixOS module must NOT flip the host firewall backend or # The NixOS module must NOT flip the host firewall backend or
+21 -68
View File
@@ -10,9 +10,7 @@ classmethods forward to their module.
from __future__ import annotations from __future__ import annotations
import subprocess import subprocess
import tempfile
import unittest import unittest
from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
from bot_bottle.backend.firecracker import cleanup as fc_cleanup from bot_bottle.backend.firecracker import cleanup as fc_cleanup
@@ -25,77 +23,32 @@ def _proc(stdout: str = "", returncode: int = 0) -> "subprocess.CompletedProcess
return subprocess.CompletedProcess([], returncode, stdout=stdout, stderr="") return subprocess.CompletedProcess([], returncode, stdout=stdout, stderr="")
class TestProcessScan(unittest.TestCase): class TestOrphanEnumeration(unittest.TestCase):
def test_run_dir_of_matches_only_direct_children(self): def test_orphan_vm_pids_filters_by_run_dir(self):
run_root = Path("/cache/run") run_root = str(fc_cleanup._run_root())
self.assertEqual( out = (
Path("/cache/run/dev-a"), f"111 firecracker --config-file {run_root}/dev-a/config.json\n"
fc_cleanup._run_dir_of( "222 firecracker --config-file /somewhere/else/config.json\n"
f"firecracker --config-file {run_root}/dev-a/config.json", run_root "notanint firecracker --config-file " + run_root + "/x\n"
),
)
# infra/builder VMs elsewhere, or nested paths, are not ours.
self.assertIsNone(
fc_cleanup._run_dir_of("firecracker --config-file /elsewhere/config.json", run_root)
)
self.assertIsNone(
fc_cleanup._run_dir_of("firecracker --no-config", run_root)
) )
with patch.object(fc_cleanup.subprocess, "run", return_value=_proc(out)):
self.assertEqual([111], fc_cleanup._orphan_vm_pids())
def test_scan_splits_live_dirs_from_orphan_pids(self): def test_orphan_vm_pids_empty_when_pgrep_fails(self):
with tempfile.TemporaryDirectory() as tmp:
run_root = Path(tmp)
(run_root / "live-a").mkdir() # dir present -> live VM, protected
# "gone-b" dir intentionally absent -> lingering VMM, orphan pid
out = (
f"111 firecracker --config-file {run_root}/live-a/config.json\n"
f"222 firecracker --config-file {run_root}/gone-b/config.json\n"
"333 firecracker --config-file /elsewhere/config.json\n"
"notanint firecracker --config-file x\n"
)
with patch.object(fc_cleanup.subprocess, "run", return_value=_proc(out)):
live, orphan_pids = fc_cleanup._scan_processes(run_root)
self.assertEqual({str(run_root / "live-a")}, live)
self.assertEqual([222], orphan_pids)
def test_scan_empty_when_pgrep_fails(self):
with patch.object(fc_cleanup.subprocess, "run", return_value=_proc(returncode=1)): with patch.object(fc_cleanup.subprocess, "run", return_value=_proc(returncode=1)):
self.assertEqual((set(), []), fc_cleanup._scan_processes(Path("/x"))) self.assertEqual([], fc_cleanup._orphan_vm_pids())
def test_live_run_dirs_returns_paths_in_stable_order(self): def test_run_dirs_empty_when_absent(self):
with patch.object(fc_cleanup, "_run_root", return_value=Path("/run")), \ with patch.object(fc_cleanup.util, "cache_dir") as cache:
patch.object(fc_cleanup, "_scan_processes", cache.return_value.__truediv__.return_value.is_dir.return_value = False
return_value=({"/run/b", "/run/a"}, [])): self.assertEqual([], fc_cleanup._run_dirs())
self.assertEqual(
(Path("/run/a"), Path("/run/b")), fc_cleanup.live_run_dirs(),
)
def test_orphan_run_dirs_excludes_live_and_missing_root(self): def test_prepare_cleanup_assembles_plan(self):
with tempfile.TemporaryDirectory() as tmp: with patch.object(fc_cleanup, "_orphan_vm_pids", return_value=[7]), \
run_root = Path(tmp) patch.object(fc_cleanup, "_run_dirs", return_value=["/run/x"]):
(run_root / "live-a").mkdir() plan = fc_cleanup.prepare_cleanup()
(run_root / "dead-b").mkdir() self.assertEqual((7,), plan.vm_pids)
live = {str(run_root / "live-a")} self.assertEqual(("/run/x",), plan.run_dirs)
self.assertEqual(
[str(run_root / "dead-b")],
fc_cleanup._orphan_run_dirs(run_root, live),
)
# absent run root -> nothing to reap
self.assertEqual([], fc_cleanup._orphan_run_dirs(Path("/nope/run"), set()))
def test_prepare_cleanup_reaps_orphans_only(self):
"""The live VM's dir is never in the plan; the dead one is."""
with tempfile.TemporaryDirectory() as tmp:
run_root = Path(tmp)
(run_root / "live-a").mkdir()
(run_root / "dead-b").mkdir()
out = f"111 firecracker --config-file {run_root}/live-a/config.json\n"
with patch.object(fc_cleanup, "_run_root", return_value=run_root), \
patch.object(fc_cleanup.subprocess, "run", return_value=_proc(out)):
plan = fc_cleanup.prepare_cleanup()
self.assertEqual((), plan.vm_pids)
self.assertEqual((str(run_root / "dead-b"),), plan.run_dirs)
self.assertNotIn(str(run_root / "live-a"), plan.run_dirs)
class TestCleanupRemoval(unittest.TestCase): class TestCleanupRemoval(unittest.TestCase):
@@ -49,7 +49,6 @@ def _plan(
agent_git_gate_url: str = "", agent_git_gate_url: str = "",
agent_supervise_url: str = "", agent_supervise_url: str = "",
image_policy: str = "fresh", image_policy: str = "fresh",
env_var_secret: str = "",
) -> MacosContainerBottlePlan: ) -> MacosContainerBottlePlan:
routes_path = stage_dir / "routes.yaml" routes_path = stage_dir / "routes.yaml"
routes_path.write_text("routes: []\n", encoding="utf-8") routes_path.write_text("routes: []\n", encoding="utf-8")
@@ -81,7 +80,6 @@ def _plan(
), ),
agent_git_gate_url=agent_git_gate_url, agent_git_gate_url=agent_git_gate_url,
agent_supervise_url=agent_supervise_url, agent_supervise_url=agent_supervise_url,
env_var_secret=env_var_secret,
)) ))
@@ -187,12 +185,6 @@ class TestAgentRunArgv(unittest.TestCase):
"bot-bottle-mac-gateway", self.argv[self.argv.index("--network") + 1], "bot-bottle-mac-gateway", self.argv[self.argv.index("--network") + 1],
) )
def test_env_var_secret_is_in_configured_container_environment(self) -> None:
argv = _agent_run_argv(
_plan(Path(self._tmp.name), env_var_secret="key-material"), _endpoint(),
)
self.assertIn("ENV_VAR_SECRET=key-material", argv)
def test_never_pins_an_ip(self) -> None: def test_never_pins_an_ip(self) -> None:
"""Apple Container 1.0.0 has no --ip: the address is DHCP-assigned and """Apple Container 1.0.0 has no --ip: the address is DHCP-assigned and
read back after start.""" read back after start."""
-15
View File
@@ -28,21 +28,6 @@ class TestMacosContainerAvailability(unittest.TestCase):
class TestMacosContainerCommands(unittest.TestCase): class TestMacosContainerCommands(unittest.TestCase):
def test_read_container_env(self):
completed = util.subprocess.CompletedProcess(
args=[], returncode=0, stdout="secret\n", stderr="",
)
with patch.object(util, "_run_container_op", return_value=completed) as run:
self.assertEqual("secret", util.read_container_env("bottle", "KEY"))
run.assert_called_once_with(["container", "exec", "bottle", "printenv", "KEY"])
def test_read_container_env_returns_empty_on_failure(self):
completed = util.subprocess.CompletedProcess(
args=[], returncode=1, stdout="", stderr="missing",
)
with patch.object(util, "_run_container_op", return_value=completed):
self.assertEqual("", util.read_container_env("bottle", "KEY"))
def test_dns_server_prefers_direct_host_ipv4_resolver(self): def test_dns_server_prefers_direct_host_ipv4_resolver(self):
scutil = util.subprocess.CompletedProcess( scutil = util.subprocess.CompletedProcess(
args=[], args=[],
-21
View File
@@ -76,27 +76,6 @@ class TestTeardown(unittest.TestCase):
self.assertEqual("DELETE", m.call_args.args[0].get_method()) self.assertEqual("DELETE", m.call_args.args[0].get_method())
class TestReprovisionGateway(unittest.TestCase):
def setUp(self) -> None:
self.c = OrchestratorClient("http://orch:8080")
def test_success_posts_key(self) -> None:
with patch(_URLOPEN, return_value=_resp(200, {"reprovisioned": True})) as opened:
self.assertTrue(self.c.reprovision_gateway("b1", "key"))
request = opened.call_args.args[0]
self.assertEqual("POST", request.get_method())
self.assertEqual({"env_var_secret": "key"}, json.loads(request.data))
def test_missing_stored_secret_is_false(self) -> None:
with patch(_URLOPEN, side_effect=_http_error(404)):
self.assertFalse(self.c.reprovision_gateway("b1", "key"))
def test_other_status_raises(self) -> None:
with patch(_URLOPEN, side_effect=_http_error(400)):
with self.assertRaises(OrchestratorClientError):
self.c.reprovision_gateway("b1", "key")
class TestHealthAndPolicy(unittest.TestCase): class TestHealthAndPolicy(unittest.TestCase):
def setUp(self) -> None: def setUp(self) -> None:
self.c = OrchestratorClient("http://orch:8080") self.c = OrchestratorClient("http://orch:8080")
@@ -6,7 +6,6 @@ server tests), plus one real-socket round-trip to prove the handler wiring.
from __future__ import annotations from __future__ import annotations
import base64
import json import json
import secrets import secrets
import sqlite3 import sqlite3
@@ -64,43 +63,6 @@ class TestDispatch(unittest.TestCase):
self.assertTrue(payload["bottle_id"]) self.assertTrue(payload["bottle_id"])
self.assertTrue(payload["identity_token"]) self.assertTrue(payload["identity_token"])
def test_register_and_reprovision_encrypted_tokens(self) -> None:
key = base64.urlsafe_b64encode(b"unit-test-key").rstrip(b"=").decode()
status, payload = dispatch(
self.orch, "POST", "/bottles", _body({
"source_ip": "10.243.0.11",
"tokens": {"EGRESS_TOKEN_0": "upstream-secret"},
"env_var_secret": key,
}),
)
self.assertEqual(201, status)
bottle_id = payload["bottle_id"]
assert isinstance(bottle_id, str)
self.orch._tokens.clear()
status, response = dispatch(
self.orch, "POST", f"/bottles/{bottle_id}/reprovision_gateway",
_body({"env_var_secret": key}),
)
self.assertEqual((200, {"reprovisioned": True}), (status, response))
self.assertEqual(
{"EGRESS_TOKEN_0": "upstream-secret"}, self.orch.tokens_for(bottle_id),
)
def test_reprovision_validates_request_and_missing_rows(self) -> None:
status, _ = dispatch(
self.orch, "POST", "/bottles/b1/reprovision_gateway", b"not-json",
)
self.assertEqual(400, status)
status, _ = dispatch(
self.orch, "POST", "/bottles/b1/reprovision_gateway", _body({}),
)
self.assertEqual(400, status)
status, _ = dispatch(
self.orch, "POST", "/bottles/b1/reprovision_gateway",
_body({"env_var_secret": "key"}),
)
self.assertEqual(404, status)
def test_register_requires_source_ip(self) -> None: def test_register_requires_source_ip(self) -> None:
status, _ = dispatch(self.orch, "POST", "/bottles", _body({})) status, _ = dispatch(self.orch, "POST", "/bottles", _body({}))
self.assertEqual(400, status) self.assertEqual(400, status)
-52
View File
@@ -173,58 +173,6 @@ if __name__ == "__main__":
unittest.main() unittest.main()
class TestAgentSecrets(unittest.TestCase):
"""store/get/delete for the bottled_agent_secrets table."""
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory()
self.db = Path(self._tmp.name) / "registry.db"
self.store = RegistryStore(self.db)
self.store.migrate()
def tearDown(self) -> None:
self._tmp.cleanup()
def test_store_and_get_roundtrip(self) -> None:
self.store.store_agent_secrets("bottle-1", {"EGRESS_TOKEN_1": "enc-val-a"})
got = self.store.get_agent_secrets("bottle-1")
self.assertEqual({"EGRESS_TOKEN_1": "enc-val-a"}, got)
def test_get_returns_empty_when_none_stored(self) -> None:
self.assertEqual({}, self.store.get_agent_secrets("no-such-bottle"))
def test_store_replaces_existing_rows(self) -> None:
self.store.store_agent_secrets("bottle-1", {"K": "old"})
self.store.store_agent_secrets("bottle-1", {"K": "new", "K2": "v2"})
got = self.store.get_agent_secrets("bottle-1")
self.assertEqual({"K": "new", "K2": "v2"}, got)
def test_delete_removes_secrets(self) -> None:
self.store.store_agent_secrets("bottle-1", {"K": "v"})
self.store.delete_agent_secrets("bottle-1")
self.assertEqual({}, self.store.get_agent_secrets("bottle-1"))
def test_delete_is_idempotent_on_missing(self) -> None:
self.store.delete_agent_secrets("no-such-bottle") # must not raise
def test_secrets_are_isolated_by_bottle_id(self) -> None:
self.store.store_agent_secrets("bottle-1", {"K": "for-1"})
self.store.store_agent_secrets("bottle-2", {"K": "for-2"})
self.assertEqual({"K": "for-1"}, self.store.get_agent_secrets("bottle-1"))
self.assertEqual({"K": "for-2"}, self.store.get_agent_secrets("bottle-2"))
def test_secrets_isolated_by_type(self) -> None:
self.store.store_agent_secrets("bottle-1", {"K": "injected"}, secret_type="injected_env_var")
self.store.store_agent_secrets("bottle-1", {"K": "other"}, secret_type="other_type")
self.assertEqual({"K": "injected"}, self.store.get_agent_secrets("bottle-1"))
self.assertEqual({"K": "other"}, self.store.get_agent_secrets("bottle-1", secret_type="other_type"))
def test_secrets_persist_across_reopen(self) -> None:
self.store.store_agent_secrets("bottle-1", {"K": "v"})
reopened = RegistryStore(self.db)
self.assertEqual({"K": "v"}, reopened.get_agent_secrets("bottle-1"))
class TestReapAbsent(unittest.TestCase): class TestReapAbsent(unittest.TestCase):
"""`reap_absent` — the self-heal for rows whose bottle is gone. """`reap_absent` — the self-heal for rows whose bottle is gone.
@@ -1,96 +0,0 @@
"""Unit tests for per-bottle egress secret encryption (PRD prd-new-secret-provider)."""
from __future__ import annotations
import unittest
from bot_bottle.orchestrator.secret_store import (
ENV_VAR_SECRET_NAME,
decrypt_value,
encrypt_value,
new_env_var_secret,
)
class TestNewEnvVarSecret(unittest.TestCase):
def test_returns_non_empty_string(self) -> None:
s = new_env_var_secret()
self.assertIsInstance(s, str)
self.assertTrue(len(s) > 0)
def test_secrets_are_unique(self) -> None:
keys = {new_env_var_secret() for _ in range(50)}
self.assertEqual(50, len(keys))
def test_no_padding_characters(self) -> None:
# URL-safe base64, padding stripped — should round-trip cleanly
for _ in range(20):
self.assertNotIn("=", new_env_var_secret())
class TestEncryptDecryptRoundtrip(unittest.TestCase):
def setUp(self) -> None:
self.secret = new_env_var_secret()
def _rt(self, plaintext: str) -> str:
return decrypt_value(self.secret, encrypt_value(self.secret, plaintext))
def test_roundtrip_short_value(self) -> None:
self.assertEqual("sk-abc123", self._rt("sk-abc123"))
def test_roundtrip_empty_string(self) -> None:
self.assertEqual("", self._rt(""))
def test_roundtrip_long_value_crosses_block_boundary(self) -> None:
# 32 bytes is exactly one HMAC-SHA256 block; 65 bytes crosses two.
plaintext = "x" * 65
self.assertEqual(plaintext, self._rt(plaintext))
def test_roundtrip_unicode(self) -> None:
self.assertEqual("héllo wörld", self._rt("héllo wörld"))
def test_encrypt_produces_different_ciphertexts_each_call(self) -> None:
ct1 = encrypt_value(self.secret, "same")
ct2 = encrypt_value(self.secret, "same")
self.assertNotEqual(ct1, ct2) # fresh nonce each call
def test_ciphertext_is_url_safe_base64(self) -> None:
ct = encrypt_value(self.secret, "hello")
# no '+', '/', '=' — URL-safe and padding-stripped
for ch in ("+", "/", "="):
self.assertNotIn(ch, ct)
class TestDecryptErrors(unittest.TestCase):
def setUp(self) -> None:
self.secret = new_env_var_secret()
def test_wrong_key_raises_value_error(self) -> None:
ct = encrypt_value(self.secret, "secret-token")
other_key = new_env_var_secret()
# Wrong key produces garbage bytes; decrypt_value raises ValueError
# when the result is non-UTF-8 (which is very likely for 12-char data).
# We allow it to succeed only if garbage happens to be valid UTF-8, but
# the plaintext must not match.
try:
result = decrypt_value(other_key, ct)
self.assertNotEqual("secret-token", result)
except ValueError:
pass
def test_truncated_blob_raises_value_error(self) -> None:
with self.assertRaises(ValueError):
decrypt_value(self.secret, "dG9vc2hvcnQ") # "tooshort" — under 16 nonce bytes
def test_invalid_base64_raises_value_error(self) -> None:
with self.assertRaises(ValueError):
decrypt_value(self.secret, "!!not-base64!!")
class TestConstant(unittest.TestCase):
def test_env_var_secret_name(self) -> None:
self.assertEqual("ENV_VAR_SECRET", ENV_VAR_SECRET_NAME)
if __name__ == "__main__":
unittest.main()
-20
View File
@@ -15,7 +15,6 @@ from bot_bottle.orchestrator.broker import LaunchBroker, LaunchRequest, StubBrok
from bot_bottle.orchestrator.registry import RegistryStore from bot_bottle.orchestrator.registry import RegistryStore
from bot_bottle.orchestrator.service import Orchestrator from bot_bottle.orchestrator.service import Orchestrator
from bot_bottle.orchestrator.gateway import Gateway from bot_bottle.orchestrator.gateway import Gateway
from bot_bottle.orchestrator.secret_store import new_env_var_secret
from bot_bottle.store_manager import StoreManager from bot_bottle.store_manager import StoreManager
from bot_bottle.supervise import ( from bot_bottle.supervise import (
Proposal, Proposal,
@@ -118,25 +117,6 @@ class TestOrchestrator(unittest.TestCase):
rec = self.orch.launch_bottle("10.243.0.6") rec = self.orch.launch_bottle("10.243.0.6")
self.assertEqual({}, self.orch.tokens_for(rec.bottle_id)) self.assertEqual({}, self.orch.tokens_for(rec.bottle_id))
def test_encrypted_tokens_can_be_reprovisioned_after_memory_loss(self) -> None:
key = new_env_var_secret()
rec = self.orch.launch_bottle(
"10.243.0.12", tokens={"EGRESS_TOKEN_0": "secret"},
env_var_secret=key,
)
self.assertNotEqual({}, self.store.get_agent_secrets(rec.bottle_id))
self.orch._tokens.clear()
self.assertTrue(self.orch.reprovision_from_secret(rec.bottle_id, key))
self.assertEqual({"EGRESS_TOKEN_0": "secret"}, self.orch.tokens_for(rec.bottle_id))
def test_reprovision_rejects_missing_rows_and_wrong_key(self) -> None:
self.assertFalse(self.orch.reprovision_from_secret("missing", new_env_var_secret()))
rec = self.orch.launch_bottle(
"10.243.0.13", tokens={"K": "value"},
env_var_secret=new_env_var_secret(),
)
self.assertFalse(self.orch.reprovision_from_secret(rec.bottle_id, new_env_var_secret()))
def test_set_policy_live_reload(self) -> None: def test_set_policy_live_reload(self) -> None:
rec = self.orch.launch_bottle("10.243.0.3") rec = self.orch.launch_bottle("10.243.0.3")
self.assertTrue(self.orch.set_policy(rec.bottle_id, '{"x":1}')) self.assertTrue(self.orch.set_policy(rec.bottle_id, '{"x":1}'))