Compare commits
10 Commits
df249b6fef
...
654fe13afc
| Author | SHA1 | Date | |
|---|---|---|---|
| 654fe13afc | |||
| 5e01c28016 | |||
| 2f8539c2c7 | |||
| ad100b8a84 | |||
| c7375051fd | |||
| d9e685e860 | |||
| b4b73a8acc | |||
| b1ebc6f1b8 | |||
| 8b5b5730ae | |||
| 44479f328e |
@@ -45,7 +45,8 @@ from typing import TYPE_CHECKING, Any, Generic, Sequence, TypeVar
|
||||
from ..agent_provider import AgentProvisionPlan, get_provider, build_agent_provision_plan
|
||||
from ..egress import EgressPlan
|
||||
from ..git_gate import GitGatePlan
|
||||
from ..log import die, info
|
||||
from ..log import die, info, warn
|
||||
from ..util import read_tty_line
|
||||
from ..manifest import Manifest, ManifestIndex
|
||||
from ..supervise import SupervisePlan
|
||||
from ..util import expand_tilde
|
||||
@@ -648,19 +649,26 @@ def __getattr__(name: str) -> Any:
|
||||
|
||||
def get_bottle_backend(
|
||||
name: str | None = None,
|
||||
*,
|
||||
prompt: bool = True,
|
||||
) -> BottleBackend[Any, Any]:
|
||||
"""Resolve the bottle backend.
|
||||
|
||||
`name` precedence:
|
||||
1. explicit arg (CLI `--backend=<name>` passes through here)
|
||||
1. explicit arg (e.g. resume passes the recorded backend name)
|
||||
2. BOT_BOTTLE_BACKEND env var
|
||||
3. `macos-container` on compatible macOS hosts
|
||||
4. `firecracker` on KVM-capable Linux hosts
|
||||
5. default `docker`
|
||||
3. auto-selection: VM backend first, docker fallback with prompt
|
||||
|
||||
`prompt` controls whether auto-selection may block on an interactive
|
||||
[i/d/q] prompt when falling back to docker. Pass `prompt=False` in
|
||||
non-interactive contexts (headless launches, CI) so the call dies
|
||||
with an actionable message instead of hanging.
|
||||
|
||||
Dies with a pointer at the known backends if the chosen name
|
||||
isn't implemented."""
|
||||
resolved = name or os.environ.get("BOT_BOTTLE_BACKEND") or _default_backend_name()
|
||||
resolved = name or os.environ.get("BOT_BOTTLE_BACKEND")
|
||||
if resolved is None:
|
||||
resolved = _auto_select_backend(prompt=prompt)
|
||||
backends = _get_backends()
|
||||
if resolved not in backends:
|
||||
known = ", ".join(sorted(backends))
|
||||
@@ -668,7 +676,35 @@ def get_bottle_backend(
|
||||
return backends[resolved]
|
||||
|
||||
|
||||
def _default_backend_name() -> str:
|
||||
def _platform_vm_suggestion() -> str:
|
||||
"""Platform-appropriate VM backend name for install suggestions."""
|
||||
return "macos-container" if sys.platform == "darwin" else "firecracker"
|
||||
|
||||
|
||||
def _print_vm_install_instructions() -> None:
|
||||
"""Print platform-appropriate VM backend install instructions to stderr."""
|
||||
vm = _platform_vm_suggestion()
|
||||
if vm == "macos-container":
|
||||
info("Install Apple Container: https://github.com/apple/container/releases")
|
||||
info("Then start the service: container system start")
|
||||
else:
|
||||
info("Install Firecracker: https://github.com/firecracker-microvm/firecracker/releases")
|
||||
info("Configure the host: ./cli.py backend setup")
|
||||
|
||||
|
||||
def _auto_select_backend(prompt: bool = True) -> str:
|
||||
"""Tier-1 / tier-2 backend auto-selection.
|
||||
|
||||
Tier 1: VM backend — macos-container on macOS when Apple Container is
|
||||
installed; firecracker on KVM-capable Linux even before the binary is
|
||||
present (its preflight prints an install pointer).
|
||||
|
||||
Tier 2: docker, with a security warning and an interactive prompt.
|
||||
When `prompt=False` (headless / CI), dies with an actionable message
|
||||
instead of blocking on a TTY read. When docker is also absent, prints
|
||||
VM install instructions and exits.
|
||||
"""
|
||||
# --- Tier 1: VM backend -----------------------------------------
|
||||
if has_backend("macos-container"):
|
||||
return "macos-container"
|
||||
# A KVM-capable Linux host defaults to firecracker even when the
|
||||
@@ -678,7 +714,37 @@ def _default_backend_name() -> str:
|
||||
from .firecracker import FirecrackerBottleBackend
|
||||
if FirecrackerBottleBackend.is_host_capable():
|
||||
return "firecracker"
|
||||
return "docker"
|
||||
|
||||
# --- Tier 2: docker fallback ------------------------------------
|
||||
if not has_backend("docker"):
|
||||
info("No backend available on this host.")
|
||||
_print_vm_install_instructions()
|
||||
die("no backend available; install a VM backend and re-run")
|
||||
|
||||
vm = _platform_vm_suggestion()
|
||||
warn(
|
||||
"docker is less secure than VM backends — "
|
||||
"containers share the host kernel."
|
||||
)
|
||||
if not prompt:
|
||||
die(
|
||||
f"no VM backend available; set BOT_BOTTLE_BACKEND=docker to proceed "
|
||||
f"with docker, or install the {vm!r} backend."
|
||||
)
|
||||
sys.stderr.write(
|
||||
f"bot-bottle: For better isolation, install the {vm!r} backend.\n"
|
||||
f" [i] show {vm} install instructions and exit\n"
|
||||
" [d] use docker anyway\n"
|
||||
" [q] quit\n"
|
||||
"bot-bottle: choice [i/d/q]: "
|
||||
)
|
||||
sys.stderr.flush()
|
||||
reply = read_tty_line().strip().lower()
|
||||
if reply == "d":
|
||||
return "docker"
|
||||
if reply == "i":
|
||||
_print_vm_install_instructions()
|
||||
die("not proceeding with docker; install a VM backend or set BOT_BOTTLE_BACKEND=docker")
|
||||
|
||||
|
||||
def known_backend_names() -> tuple[str, ...]:
|
||||
|
||||
@@ -88,7 +88,7 @@ def require_firecracker() -> None:
|
||||
booting a VM without it."""
|
||||
if not is_linux():
|
||||
die("firecracker backend is only supported on Linux (KVM). "
|
||||
"On macOS use --backend=macos-container.")
|
||||
"On macOS use the macos-container backend.")
|
||||
if shutil.which("firecracker") is None:
|
||||
info("Firecracker is required but was not found on PATH.")
|
||||
info("Install: https://github.com/firecracker-microvm/firecracker/releases")
|
||||
|
||||
@@ -68,9 +68,14 @@ class MacosContainerBottle(Bottle):
|
||||
# reaches the agent (PRD 0070): registration mints it *after* the
|
||||
# container exists — its source IP is the registration key and Apple
|
||||
# Container assigns that by DHCP — so it cannot be in the run-time env
|
||||
# the way docker's compose spec does it. `container exec --env` wins
|
||||
# over the run-time value, so the token-bearing proxy URL set here
|
||||
# supersedes the token-less one baked in at launch.
|
||||
# the way docker's compose spec does it.
|
||||
#
|
||||
# `container exec --env` does NOT override a run-time value — it
|
||||
# appends, leaving duplicate entries in the agent's `environ` whose
|
||||
# resolution is runtime-specific (Node last-wins, Rust first-wins). So
|
||||
# nothing here may rely on superseding: the proxy vars are supplied
|
||||
# *only* at exec time and are deliberately absent from the run-time
|
||||
# env. See `launch._agent_env_entries`.
|
||||
self._exec_env = dict(exec_env or {})
|
||||
self._closed = False
|
||||
|
||||
|
||||
@@ -8,7 +8,11 @@ from ...bottle_state import read_metadata
|
||||
from .. import ActiveAgent
|
||||
from .infra import INFRA_NAME
|
||||
|
||||
_PREFIX = "bot-bottle-"
|
||||
# The name every agent container carries: `bot-bottle-<slug>`. Exported
|
||||
# because callers that act on a running bottle (gateway-host rewrites,
|
||||
# registry reconciliation) have to map an enumerated slug back to a
|
||||
# container name.
|
||||
CONTAINER_NAME_PREFIX = "bot-bottle-"
|
||||
# The shared per-host infra container carries the same prefix as agent
|
||||
# containers but is infrastructure, not a bottle — one control plane + gateway
|
||||
# serves every agent, so listing it as an agent would invent one per host.
|
||||
@@ -26,9 +30,9 @@ def enumerate_active() -> list[ActiveAgent]:
|
||||
return []
|
||||
out: list[ActiveAgent] = []
|
||||
for name in sorted(line.strip() for line in result.stdout.splitlines()):
|
||||
if not name.startswith(_PREFIX) or name in _INFRA_NAMES:
|
||||
if not name.startswith(CONTAINER_NAME_PREFIX) or name in _INFRA_NAMES:
|
||||
continue
|
||||
slug = name[len(_PREFIX):]
|
||||
slug = name[len(CONTAINER_NAME_PREFIX):]
|
||||
metadata = read_metadata(slug)
|
||||
out.append(ActiveAgent(
|
||||
backend_name="macos-container",
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Stable gateway name for macOS agents, via each bottle's `/etc/hosts`.
|
||||
|
||||
The shared gateway's address is assigned by vmnet's DHCP and changes whenever
|
||||
the infra container is recreated — a source-hash bump, an image upgrade, a
|
||||
crash. Every agent-facing URL (egress proxy, git-http, supervise) embeds that
|
||||
address, and the proxy URL reaches the agent as **process environment** at
|
||||
`container exec` time. A running process's `environ` cannot be rewritten from
|
||||
outside, so a moved gateway used to strand every running bottle permanently:
|
||||
not degraded, unreachable, until the bottle was relaunched and its session
|
||||
thrown away.
|
||||
|
||||
So the agent never learns the address. It is given a stable *name*
|
||||
(`GATEWAY_HOSTNAME`) in every URL, resolved through its own `/etc/hosts`.
|
||||
Unlike `environ`, that is a file — it can be rewritten inside a container that
|
||||
is already running, so a gateway that comes back at a new address is picked up
|
||||
by live bottles instead of orphaning them.
|
||||
|
||||
Apple Container 1.0 offers no container-name DNS on a user network (the only
|
||||
nameserver an agent sees is vmnet's, which does not know container names) and
|
||||
`container run` has no `--add-host`, so the entry is written by exec after the
|
||||
container starts.
|
||||
|
||||
Writing it needs root, and the agent runs as `node`: the agent therefore
|
||||
cannot repoint its own gateway name, while the host (which drives `container
|
||||
exec --user root`) can. That asymmetry is deliberate — keep it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ...log import warn
|
||||
from . import util as container_mod
|
||||
from .enumerate import CONTAINER_NAME_PREFIX, enumerate_active
|
||||
|
||||
# The name every agent-facing gateway URL uses. Must not collide with a real
|
||||
# DNS name the agent might resolve; it is bottle-local by construction.
|
||||
GATEWAY_HOSTNAME = "bot-bottle-gateway"
|
||||
|
||||
# Marker so the rewrite is idempotent and only ever touches our own line —
|
||||
# the rest of /etc/hosts (localhost, the container's own name) is preserved.
|
||||
_MARKER = "# bot-bottle gateway"
|
||||
|
||||
|
||||
def _rewrite_script(gateway_ip: str) -> str:
|
||||
"""A shell one-liner that replaces our managed line in `/etc/hosts`.
|
||||
|
||||
Rewrites in place via a temp file + `cat` rather than `mv`, so the file
|
||||
keeps its original inode, ownership, and mode — a bind-mounted or
|
||||
pre-created `/etc/hosts` must not be replaced by a root-owned 0644 copy
|
||||
that the runtime then refuses to update.
|
||||
"""
|
||||
return (
|
||||
"set -e; "
|
||||
f"grep -v '{_MARKER}' /etc/hosts > /tmp/.bb-hosts || true; "
|
||||
f"printf '%s %s %s\\n' '{gateway_ip}' '{GATEWAY_HOSTNAME}' "
|
||||
f"'{_MARKER}' >> /tmp/.bb-hosts; "
|
||||
"cat /tmp/.bb-hosts > /etc/hosts; "
|
||||
"rm -f /tmp/.bb-hosts"
|
||||
)
|
||||
|
||||
|
||||
def set_gateway_host(container_name: str, gateway_ip: str) -> None:
|
||||
"""Point `GATEWAY_HOSTNAME` at `gateway_ip` inside one running container.
|
||||
|
||||
Must run before the agent is exec'd: the agent's proxy URL names the
|
||||
gateway, so the entry has to exist for its first connection. Idempotent —
|
||||
re-running with the same address is a no-op in effect.
|
||||
"""
|
||||
container_mod.exec_container_as_root(
|
||||
container_name, ["sh", "-c", _rewrite_script(gateway_ip)],
|
||||
)
|
||||
|
||||
|
||||
def refresh_gateway_host(gateway_ip: str) -> list[str]:
|
||||
"""Re-point every running bottle at the current gateway address.
|
||||
|
||||
Called once the shared gateway is known to be up, so a bottle stranded by
|
||||
an earlier gateway restart re-attaches instead of needing a relaunch.
|
||||
Returns the containers updated.
|
||||
|
||||
Best-effort per bottle: one container that refuses the write (already
|
||||
exiting, say) must not stop the others from being repaired, and must not
|
||||
fail the launch that triggered the sweep.
|
||||
"""
|
||||
updated: list[str] = []
|
||||
for agent in enumerate_active():
|
||||
name = f"{CONTAINER_NAME_PREFIX}{agent.slug}"
|
||||
try:
|
||||
set_gateway_host(name, gateway_ip)
|
||||
updated.append(name)
|
||||
# One bad bottle must not stop the sweep, so this is deliberately broad.
|
||||
except Exception as e: # noqa: BLE001 # pylint: disable=broad-exception-caught
|
||||
warn(f"could not re-point {name} at the gateway: {e}")
|
||||
return updated
|
||||
|
||||
|
||||
__all__ = ["GATEWAY_HOSTNAME", "set_gateway_host", "refresh_gateway_host"]
|
||||
@@ -101,7 +101,7 @@ def _init_script(port: int) -> str:
|
||||
# Gateway data plane, multi-tenant against the local control plane.
|
||||
f"( cd /app && BOT_BOTTLE_GATEWAY_DAEMONS={_GATEWAY_DAEMONS} "
|
||||
f"BOT_BOTTLE_ORCHESTRATOR_URL=http://127.0.0.1:{port} "
|
||||
f"SUPERVISE_DB_PATH={_DB_PATH_IN_CONTAINER} python3 /app/gateway_init.py ) &\n"
|
||||
f"SUPERVISE_DB_PATH={_DB_PATH_IN_CONTAINER} python3 -m bot_bottle.gateway_init ) &\n"
|
||||
"while : ; do wait ; done\n"
|
||||
)
|
||||
|
||||
|
||||
@@ -59,6 +59,11 @@ from ..docker.egress import EGRESS_PORT
|
||||
from ..util import AGENT_CA_BUNDLE, AGENT_CA_PATH
|
||||
from . import util as container_mod
|
||||
from .bottle import MacosContainerBottle
|
||||
from .gateway_hosts import (
|
||||
GATEWAY_HOSTNAME,
|
||||
refresh_gateway_host,
|
||||
set_gateway_host,
|
||||
)
|
||||
from .bottle_plan import MacosContainerBottlePlan
|
||||
from .consolidated_launch import (
|
||||
GatewayEndpoint,
|
||||
@@ -100,6 +105,11 @@ def launch(
|
||||
# Step 1: the per-host singletons. Must precede the agent run — its
|
||||
# proxy env needs the gateway's address at `container run` time.
|
||||
endpoint = ensure_gateway()
|
||||
# The gateway's address may have changed since these bottles launched
|
||||
# (any infra recreate re-runs DHCP). They name the gateway rather than
|
||||
# address it, so re-pointing /etc/hosts re-attaches them in place
|
||||
# instead of leaving them stranded until relaunch.
|
||||
refresh_gateway_host(endpoint.gateway_ip)
|
||||
|
||||
# Step 2: mint this bottle's deploy keys, then point it at the SHARED
|
||||
# gateway's CA + git-http/supervise ports.
|
||||
@@ -117,6 +127,9 @@ def launch(
|
||||
# attribution key; `--cap-drop CAP_NET_RAW` at run is what makes it
|
||||
# unforgeable. Poll: `container run --detach` can return before vmnet's
|
||||
# DHCP has assigned the address.
|
||||
# Resolve the gateway name before anything execs: every agent-facing
|
||||
# URL uses it, so the entry must exist for the first connection.
|
||||
set_gateway_host(plan.container_name, endpoint.gateway_ip)
|
||||
source_ip = container_mod.wait_container_ipv4_on_network(
|
||||
plan.container_name, endpoint.network,
|
||||
)
|
||||
@@ -231,13 +244,20 @@ def _stamp_agent_urls(
|
||||
) -> MacosContainerBottlePlan:
|
||||
"""Point the agent's git-gate insteadOf rewrites + supervise MCP at the
|
||||
shared gateway's ports. Both bypass the egress proxy (NO_PROXY covers the
|
||||
gateway address)."""
|
||||
gateway name).
|
||||
|
||||
Addressed by `GATEWAY_HOSTNAME`, never by IP: these URLs are baked into
|
||||
the agent's gitconfig and MCP config at provision time, so an address here
|
||||
would strand the bottle the moment the gateway moved. The name is resolved
|
||||
per connection through `/etc/hosts`, which stays rewritable while the
|
||||
bottle runs."""
|
||||
del endpoint # addressed by name; the address reaches the bottle via /etc/hosts
|
||||
git_gate_url = (
|
||||
f"http://{endpoint.gateway_ip}:{_GIT_HTTP_PORT}"
|
||||
f"http://{GATEWAY_HOSTNAME}:{_GIT_HTTP_PORT}"
|
||||
if plan.git_gate_plan.upstreams else ""
|
||||
)
|
||||
supervise_url = (
|
||||
f"http://{endpoint.gateway_ip}:{SUPERVISE_PORT}/"
|
||||
f"http://{GATEWAY_HOSTNAME}:{SUPERVISE_PORT}/"
|
||||
if plan.supervise_plan is not None else ""
|
||||
)
|
||||
return dataclasses.replace(
|
||||
@@ -247,30 +267,43 @@ def _stamp_agent_urls(
|
||||
)
|
||||
|
||||
|
||||
def _proxy_url(gateway_ip: str, identity_token: str = "") -> str:
|
||||
def _proxy_url(identity_token: str = "") -> str:
|
||||
"""The agent's egress proxy URL. The identity token rides as proxy
|
||||
credentials — the gateway reads Proxy-Authorization, resolves the
|
||||
(source_ip, token) pair against the control plane, and strips it before
|
||||
upstream. Without a valid pair `/resolve` denies the request (#366)."""
|
||||
upstream. Without a valid pair `/resolve` denies the request (#366).
|
||||
|
||||
Names the gateway rather than addressing it: this URL reaches the agent as
|
||||
process environment, which cannot be rewritten once the agent is running,
|
||||
so an address baked here is unfixable if the gateway moves."""
|
||||
cred = f"bottle:{identity_token}@" if identity_token else ""
|
||||
return f"http://{cred}{gateway_ip}:{EGRESS_PORT}"
|
||||
return f"http://{cred}{GATEWAY_HOSTNAME}:{EGRESS_PORT}"
|
||||
|
||||
|
||||
def _no_proxy(gateway_ip: str) -> str:
|
||||
def _no_proxy() -> str:
|
||||
# git-http + supervise live on the gateway and must NOT go through the
|
||||
# egress proxy — the agent reaches them directly by its address.
|
||||
return f"localhost,127.0.0.1,{gateway_ip}"
|
||||
# egress proxy — the agent reaches them directly by name. Deliberately
|
||||
# address-free: NO_PROXY is baked into the run-time env and is therefore
|
||||
# just as unfixable as the proxy URL if the gateway moves.
|
||||
return f"localhost,127.0.0.1,{GATEWAY_HOSTNAME}"
|
||||
|
||||
|
||||
def _identity_proxy_env(
|
||||
endpoint: GatewayEndpoint, identity_token: str,
|
||||
) -> dict[str, str]:
|
||||
"""The token-bearing proxy env applied at `container exec`. It supersedes
|
||||
the token-less run-time value (exec `--env` wins), which is the only way to
|
||||
get the token in: it does not exist until after the container runs."""
|
||||
"""The token-bearing proxy env applied at `container exec` — the only way
|
||||
to get the token in, since it does not exist until after the container
|
||||
runs (registration keys on the DHCP-assigned address).
|
||||
|
||||
This is the *sole* source of `*_PROXY` for the agent. It deliberately does
|
||||
not rely on overriding a run-time value: `container exec --env` appends
|
||||
rather than replaces, so a run-time `HTTPS_PROXY` would survive alongside
|
||||
this one and first-wins runtimes would read the wrong entry. See
|
||||
`_agent_env_entries`."""
|
||||
if not identity_token:
|
||||
return {}
|
||||
url = _proxy_url(endpoint.gateway_ip, identity_token)
|
||||
del endpoint # the gateway is named, not addressed
|
||||
url = _proxy_url(identity_token)
|
||||
return {
|
||||
"HTTPS_PROXY": url, "HTTP_PROXY": url,
|
||||
"https_proxy": url, "http_proxy": url,
|
||||
@@ -317,16 +350,23 @@ def _agent_run_argv(
|
||||
def _agent_env_entries(
|
||||
plan: MacosContainerBottlePlan, endpoint: GatewayEndpoint,
|
||||
) -> tuple[str, ...]:
|
||||
# Token-less at run time — the token does not exist yet (see
|
||||
# `_identity_proxy_env`). Anything egressing before the exec-time override
|
||||
# is denied by `/resolve`, which is the safe direction.
|
||||
proxy_url = _proxy_url(endpoint.gateway_ip)
|
||||
no_proxy = _no_proxy(endpoint.gateway_ip)
|
||||
# No `*_PROXY` here on purpose. The token-bearing URL is applied at
|
||||
# `container exec` (`_identity_proxy_env`), and Apple's `container exec
|
||||
# --env` **appends** to the run-time environment rather than replacing it:
|
||||
# setting a token-less value here leaves two `HTTPS_PROXY` entries in the
|
||||
# agent's `environ`, token-less first. Which one a runtime reads is then
|
||||
# pure luck — Node takes the last (and worked), Rust's `std::env::var`
|
||||
# takes the first, so Codex proxied without its identity token and
|
||||
# `/resolve` fail-closed on every request.
|
||||
#
|
||||
# A token-less proxy URL has no legitimate consumer anyway: the init
|
||||
# process is `sleep` and everything that egresses arrives via exec. Its
|
||||
# only value was a tidy 403 for unattributed callers, which is not worth
|
||||
# silently dropping attribution for. Without it a process that egresses
|
||||
# before the exec-time env still fails closed — the agent network is
|
||||
# host-only, so there is no route off it except the gateway.
|
||||
no_proxy = _no_proxy()
|
||||
env = [
|
||||
f"HTTPS_PROXY={proxy_url}",
|
||||
f"HTTP_PROXY={proxy_url}",
|
||||
f"https_proxy={proxy_url}",
|
||||
f"http_proxy={proxy_url}",
|
||||
f"NO_PROXY={no_proxy}",
|
||||
f"no_proxy={no_proxy}",
|
||||
f"NODE_EXTRA_CA_CERTS={AGENT_CA_PATH}",
|
||||
|
||||
@@ -360,6 +360,21 @@ def exec_container(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.
|
||||
|
||||
For host-driven maintenance the agent itself must not be able to perform —
|
||||
rewriting `/etc/hosts` to point the gateway name at an address. The agent
|
||||
runs as `node`, so it cannot repoint its own gateway; the host can.
|
||||
"""
|
||||
result = _run_container_op([_CONTAINER, "exec", "--user", "root", name, *argv])
|
||||
if result.returncode != 0:
|
||||
die(
|
||||
f"container exec (root) in {name} failed: "
|
||||
f"{(result.stderr or '').strip() or '<no stderr>'}"
|
||||
)
|
||||
|
||||
|
||||
def _run_container_op(cmd: list[str]) -> subprocess.CompletedProcess[str]:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
|
||||
@@ -19,6 +19,7 @@ from .commit import cmd_commit
|
||||
from .edit import cmd_edit
|
||||
from .info import cmd_info
|
||||
from .init import cmd_init
|
||||
from .login import cmd_login
|
||||
from .resume import cmd_resume
|
||||
from .start import cmd_start
|
||||
from .supervise import cmd_supervise
|
||||
@@ -33,6 +34,7 @@ COMMANDS = {
|
||||
"info": cmd_info,
|
||||
"init": cmd_init,
|
||||
"list": cmd_list,
|
||||
"login": cmd_login,
|
||||
"resume": cmd_resume,
|
||||
"start": cmd_start,
|
||||
"supervise": cmd_supervise,
|
||||
@@ -43,7 +45,7 @@ COMMANDS = {
|
||||
# the host (TAP pool, /dev/kvm, firecracker) and never opens the store, so
|
||||
# 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.
|
||||
NO_MIGRATION_COMMANDS = frozenset({"backend"})
|
||||
NO_MIGRATION_COMMANDS = frozenset({"backend", "login"})
|
||||
|
||||
|
||||
def usage() -> None:
|
||||
@@ -56,6 +58,7 @@ def usage() -> None:
|
||||
sys.stderr.write(" info print env, skills, and prompt details for a named agent\n")
|
||||
sys.stderr.write(" init interactively create a new agent and add it to bot-bottle.json\n")
|
||||
sys.stderr.write(" list list available agents or active containers\n")
|
||||
sys.stderr.write(" login register this host with a bot-bottle console\n")
|
||||
sys.stderr.write(
|
||||
" resume re-launch a bottle by its identity "
|
||||
"(continues state from PRD 0016)\n"
|
||||
|
||||
@@ -3,18 +3,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from ..util import read_tty_line as read_tty_line
|
||||
|
||||
PROG = "cli.py"
|
||||
USER_CWD = os.getcwd()
|
||||
REPO_DIR = str(Path(__file__).resolve().parent.parent.parent)
|
||||
|
||||
|
||||
def read_tty_line() -> str:
|
||||
"""Mirror `IFS= read -r REPLY </dev/tty`. Falls back to stdin."""
|
||||
try:
|
||||
with open("/dev/tty", "r", encoding="utf-8") as tty:
|
||||
return tty.readline().rstrip("\n")
|
||||
except OSError:
|
||||
return sys.stdin.readline().rstrip("\n")
|
||||
|
||||
@@ -21,16 +21,20 @@ from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
from ..backend import get_bottle_backend, known_backend_names
|
||||
from ..backend import get_bottle_backend, has_backend, known_backend_names
|
||||
from ..log import info
|
||||
from ._common import read_tty_line
|
||||
|
||||
|
||||
def cmd_cleanup(_argv: list[str]) -> int:
|
||||
# Order: stable backend iteration so the y/N output is
|
||||
# deterministic across runs.
|
||||
# deterministic across runs. Skip backends whose runtime
|
||||
# isn't available on this host so e.g. macos-container
|
||||
# doesn't error on Linux.
|
||||
plans = [
|
||||
(name, get_bottle_backend(name)) for name in known_backend_names()
|
||||
(name, get_bottle_backend(name))
|
||||
for name in known_backend_names()
|
||||
if has_backend(name)
|
||||
]
|
||||
prepared = [(name, b, b.prepare_cleanup()) for name, b in plans]
|
||||
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
"""bb login — register this host with a bot-bottle console.
|
||||
|
||||
Opens a device-authorization flow against the target console, waits for the
|
||||
operator to approve, then writes access and refresh tokens to
|
||||
~/.bot-bottle/console.json (or $BOT_BOTTLE_ROOT/console.json).
|
||||
|
||||
Usage:
|
||||
bb login [--console-url URL] [--label LABEL]
|
||||
|
||||
Flags:
|
||||
--console-url URL Target console URL (overrides BB_CONSOLE_URL env var)
|
||||
--label LABEL Host label shown in the console (default: hostname)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
from ..paths import bot_bottle_root
|
||||
|
||||
_CONSOLE_URL_ENV = "BB_CONSOLE_URL"
|
||||
_POLL_SLEEP = 2 # seconds between polls; matches console's poll_interval default
|
||||
|
||||
|
||||
def _usage() -> None:
|
||||
sys.stderr.write(
|
||||
"usage: bb login [--console-url URL] [--label LABEL]\n"
|
||||
"\n"
|
||||
"Options:\n"
|
||||
" --console-url URL Console base URL (or BB_CONSOLE_URL env var)\n"
|
||||
" --label LABEL Host label shown in the console (default: hostname)\n"
|
||||
)
|
||||
|
||||
|
||||
def _flag(argv: list[str], name: str) -> str | None:
|
||||
for i, arg in enumerate(argv):
|
||||
if arg == name and i + 1 < len(argv):
|
||||
return argv[i + 1]
|
||||
if arg.startswith(f"{name}="):
|
||||
return arg[len(name) + 1:]
|
||||
return None
|
||||
|
||||
|
||||
def _post(url: str, payload: dict) -> dict:
|
||||
data = json.dumps(payload).encode()
|
||||
req = urllib.request.Request(
|
||||
url, data=data, headers={"Content-Type": "application/json"}
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
return json.loads(resp.read())
|
||||
|
||||
|
||||
def _get(url: str) -> tuple[int, dict]:
|
||||
req = urllib.request.Request(url)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
return resp.status, json.loads(resp.read())
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, {}
|
||||
|
||||
|
||||
def _save_credentials(
|
||||
console_url: str, host_id: str, access_token: str, refresh_token: str
|
||||
) -> Path:
|
||||
path = bot_bottle_root() / "console.json"
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"url": console_url,
|
||||
"host_id": host_id,
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
+ "\n"
|
||||
)
|
||||
path.chmod(0o600)
|
||||
return path
|
||||
|
||||
|
||||
def cmd_login(argv: list[str]) -> int:
|
||||
if "--help" in argv or "-h" in argv:
|
||||
_usage()
|
||||
return 0
|
||||
|
||||
console_url = _flag(argv, "--console-url") or os.environ.get(_CONSOLE_URL_ENV)
|
||||
if not console_url:
|
||||
sys.stderr.write(
|
||||
"bb login: --console-url or BB_CONSOLE_URL is required\n"
|
||||
)
|
||||
return 1
|
||||
console_url = console_url.rstrip("/")
|
||||
|
||||
label = _flag(argv, "--label") or socket.gethostname()
|
||||
|
||||
try:
|
||||
resp = _post(f"{console_url}/api/v1/hosts/authorize", {"label": label})
|
||||
except Exception as exc:
|
||||
sys.stderr.write(f"bb login: failed to start authorization: {exc}\n")
|
||||
return 1
|
||||
|
||||
device_code = resp["device_code"]
|
||||
user_code = resp["user_code"]
|
||||
expires_in = resp.get("expires_in", 300)
|
||||
|
||||
sys.stderr.write(
|
||||
f"\nOpen this URL in your browser to authorize this host:\n\n"
|
||||
f" {console_url}/authorize?code={user_code}\n\n"
|
||||
f"Waiting for approval"
|
||||
)
|
||||
|
||||
deadline = time.monotonic() + expires_in
|
||||
while time.monotonic() < deadline:
|
||||
sys.stderr.write(".")
|
||||
sys.stderr.flush()
|
||||
time.sleep(_POLL_SLEEP)
|
||||
|
||||
try:
|
||||
code, result = _get(
|
||||
f"{console_url}/api/v1/hosts/authorize/{device_code}"
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if code == 410:
|
||||
break
|
||||
|
||||
st = result.get("status")
|
||||
if st == "approved":
|
||||
sys.stderr.write("\n\nApproved.\n")
|
||||
path = _save_credentials(
|
||||
console_url,
|
||||
result["host_id"],
|
||||
result["access_token"],
|
||||
result["refresh_token"],
|
||||
)
|
||||
sys.stderr.write(f"Credentials saved to {path}\n")
|
||||
return 0
|
||||
if st == "denied":
|
||||
sys.stderr.write("\n\nDenied by operator.\n")
|
||||
return 1
|
||||
|
||||
sys.stderr.write("\n\nAuthorization timed out.\n")
|
||||
return 1
|
||||
+7
-18
@@ -27,7 +27,6 @@ from ..backend import (
|
||||
BottleSpec,
|
||||
enumerate_active_agents,
|
||||
get_bottle_backend,
|
||||
known_backend_names,
|
||||
)
|
||||
from ..backend.docker import util as docker_mod
|
||||
from ..backend.docker.bottle_plan import DockerBottlePlan
|
||||
@@ -57,15 +56,6 @@ def cmd_start(argv: list[str]) -> int:
|
||||
"into a cached layer."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--backend",
|
||||
choices=known_backend_names(),
|
||||
default=None,
|
||||
help=(
|
||||
"backend to launch the bottle on (default: $BOT_BOTTLE_BACKEND "
|
||||
"or host auto-selection). Overrides the env var when set."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--headless",
|
||||
action="store_true",
|
||||
@@ -115,11 +105,10 @@ def cmd_start(argv: list[str]) -> int:
|
||||
os.environ["BOT_BOTTLE_NO_CACHE"] = "1"
|
||||
|
||||
manifest = ManifestIndex.resolve(USER_CWD)
|
||||
backend_name: str | None = args.backend
|
||||
|
||||
if args.headless:
|
||||
return _start_headless(
|
||||
manifest, args, dry_run=dry_run, backend_name=backend_name
|
||||
manifest, args, dry_run=dry_run
|
||||
)
|
||||
|
||||
agent_name: str | None = args.name
|
||||
@@ -170,7 +159,6 @@ def cmd_start(argv: list[str]) -> int:
|
||||
return _launch_bottle(
|
||||
spec,
|
||||
dry_run=dry_run,
|
||||
backend_name=backend_name,
|
||||
)
|
||||
|
||||
|
||||
@@ -182,7 +170,6 @@ def _start_headless(
|
||||
args: argparse.Namespace,
|
||||
*,
|
||||
dry_run: bool,
|
||||
backend_name: str | None,
|
||||
) -> int:
|
||||
"""Non-interactive launch path for orchestrators / CI / webhooks.
|
||||
|
||||
@@ -230,7 +217,6 @@ def _start_headless(
|
||||
return _launch_bottle(
|
||||
spec,
|
||||
dry_run=dry_run,
|
||||
backend_name=backend_name,
|
||||
assume_yes=True,
|
||||
headless_prompt_text=prompt,
|
||||
)
|
||||
@@ -268,15 +254,18 @@ def prepare_with_preflight(
|
||||
injected callable, prompt y/N via the injected callable.
|
||||
|
||||
`backend_name` selects which backend prepares the plan
|
||||
(`None` → `$BOT_BOTTLE_BACKEND` → host auto-selection). The CLI
|
||||
passes whatever `--backend` resolved to.
|
||||
(`None` → `$BOT_BOTTLE_BACKEND` → host auto-selection).
|
||||
|
||||
When `spec.headless` is True the docker-fallback prompt is suppressed:
|
||||
auto-selection dies with an actionable message rather than blocking
|
||||
on a TTY read (which would hang CI, webhook dispatch, and orchestrators).
|
||||
|
||||
Returns `(plan, identity)`. `plan` is None on dry-run or
|
||||
operator-N, but `identity` is set as soon as `backend.prepare`
|
||||
returns so callers can reap the prepare-time state dir via
|
||||
`settle_state(identity)` in their finally — exactly the existing
|
||||
semantics."""
|
||||
backend = get_bottle_backend(backend_name)
|
||||
backend = get_bottle_backend(backend_name, prompt=not spec.headless)
|
||||
plan = backend.prepare(spec, stage_dir=stage_dir)
|
||||
identity = _identity_from_plan(plan)
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def is_ip_literal(value: str) -> bool:
|
||||
@@ -17,6 +18,15 @@ def is_ip_literal(value: str) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def read_tty_line() -> str:
|
||||
"""Mirror `IFS= read -r REPLY </dev/tty`. Falls back to stdin."""
|
||||
try:
|
||||
with open("/dev/tty", "r", encoding="utf-8") as tty:
|
||||
return tty.readline().rstrip("\n")
|
||||
except OSError:
|
||||
return sys.stdin.readline().rstrip("\n")
|
||||
|
||||
|
||||
def expand_tilde(path: str) -> str:
|
||||
"""Expand a leading '~' to $HOME. Leaves paths without a leading
|
||||
tilde unchanged. Falls back to the empty string if $HOME is unset
|
||||
|
||||
@@ -57,14 +57,16 @@ class TestGetBottleBackend(unittest.TestCase):
|
||||
return self._available
|
||||
|
||||
# No macOS container and the host can't run firecracker (no
|
||||
# KVM / not Linux) → docker is the last resort.
|
||||
# KVM / not Linux) → docker fallback with a prompt. Simulate
|
||||
# the user picking "d" (use docker anyway).
|
||||
with patch.dict(os.environ, {}, clear=True), \
|
||||
patch.object(backend_mod.FirecrackerBottleBackend,
|
||||
"is_host_capable", classmethod(lambda cls: False)), \
|
||||
patch.object(backend_mod, "_backends", {
|
||||
"macos-container": _FakeBackend("macos-container", False),
|
||||
"docker": _FakeBackend("docker", True),
|
||||
}):
|
||||
}), \
|
||||
patch.object(backend_mod, "read_tty_line", return_value="d"):
|
||||
b = get_bottle_backend()
|
||||
self.assertEqual("docker", b.name)
|
||||
|
||||
@@ -96,6 +98,145 @@ class TestGetBottleBackend(unittest.TestCase):
|
||||
with self.assertRaises(SystemExit):
|
||||
get_bottle_backend("nonexistent")
|
||||
|
||||
def test_no_backend_available_dies(self):
|
||||
# No VM and no docker → print install instructions and die.
|
||||
class _FakeBackend:
|
||||
def __init__(self, name: str, available: bool) -> None:
|
||||
self.name = name
|
||||
self._available = available
|
||||
|
||||
def is_available(self) -> bool:
|
||||
return self._available
|
||||
|
||||
with patch.dict(os.environ, {}, clear=True), \
|
||||
patch.object(backend_mod.FirecrackerBottleBackend,
|
||||
"is_host_capable", classmethod(lambda cls: False)), \
|
||||
patch.object(backend_mod, "_backends", {
|
||||
"macos-container": _FakeBackend("macos-container", False),
|
||||
"docker": _FakeBackend("docker", False),
|
||||
}), \
|
||||
patch.object(backend_mod, "die", side_effect=SystemExit("die")):
|
||||
with self.assertRaises(SystemExit):
|
||||
get_bottle_backend()
|
||||
|
||||
def test_docker_fallback_user_quits(self):
|
||||
# VM unavailable, docker available, user picks "q" → die.
|
||||
class _FakeBackend:
|
||||
def __init__(self, name: str, available: bool) -> None:
|
||||
self.name = name
|
||||
self._available = available
|
||||
|
||||
def is_available(self) -> bool:
|
||||
return self._available
|
||||
|
||||
with patch.dict(os.environ, {}, clear=True), \
|
||||
patch.object(backend_mod.FirecrackerBottleBackend,
|
||||
"is_host_capable", classmethod(lambda cls: False)), \
|
||||
patch.object(backend_mod, "_backends", {
|
||||
"macos-container": _FakeBackend("macos-container", False),
|
||||
"docker": _FakeBackend("docker", True),
|
||||
}), \
|
||||
patch.object(backend_mod, "read_tty_line", return_value="q"), \
|
||||
patch.object(backend_mod, "die", side_effect=SystemExit("die")):
|
||||
with self.assertRaises(SystemExit):
|
||||
get_bottle_backend()
|
||||
|
||||
|
||||
def test_docker_fallback_non_interactive_dies(self):
|
||||
# prompt=False: headless/CI contexts must not block on a TTY read.
|
||||
class _FakeBackend:
|
||||
def __init__(self, name: str, available: bool) -> None:
|
||||
self.name = name
|
||||
self._available = available
|
||||
|
||||
def is_available(self) -> bool:
|
||||
return self._available
|
||||
|
||||
with patch.dict(os.environ, {}, clear=True), \
|
||||
patch.object(backend_mod.FirecrackerBottleBackend,
|
||||
"is_host_capable", classmethod(lambda cls: False)), \
|
||||
patch.object(backend_mod, "_backends", {
|
||||
"macos-container": _FakeBackend("macos-container", False),
|
||||
"docker": _FakeBackend("docker", True),
|
||||
}), \
|
||||
patch.object(backend_mod, "die", side_effect=SystemExit("die")):
|
||||
with self.assertRaises(SystemExit):
|
||||
get_bottle_backend(prompt=False)
|
||||
|
||||
def test_docker_fallback_user_picks_install(self):
|
||||
# User picks [i] → print install instructions then die.
|
||||
class _FakeBackend:
|
||||
def __init__(self, name: str, available: bool) -> None:
|
||||
self.name = name
|
||||
self._available = available
|
||||
|
||||
def is_available(self) -> bool:
|
||||
return self._available
|
||||
|
||||
with patch.dict(os.environ, {}, clear=True), \
|
||||
patch.object(backend_mod.FirecrackerBottleBackend,
|
||||
"is_host_capable", classmethod(lambda cls: False)), \
|
||||
patch.object(backend_mod, "_backends", {
|
||||
"macos-container": _FakeBackend("macos-container", False),
|
||||
"docker": _FakeBackend("docker", True),
|
||||
}), \
|
||||
patch.object(backend_mod, "read_tty_line", return_value="i"), \
|
||||
patch.object(backend_mod, "_print_vm_install_instructions") as mock_inst, \
|
||||
patch.object(backend_mod, "die", side_effect=SystemExit("die")):
|
||||
with self.assertRaises(SystemExit):
|
||||
get_bottle_backend()
|
||||
mock_inst.assert_called_once()
|
||||
|
||||
|
||||
class TestReadTtyLine(unittest.TestCase):
|
||||
"""Unit tests for the shared read_tty_line helper in bot_bottle.util."""
|
||||
|
||||
def test_reads_from_dev_tty(self):
|
||||
from unittest.mock import mock_open
|
||||
from bot_bottle.util import read_tty_line
|
||||
|
||||
m = mock_open(read_data="hello\n")
|
||||
with patch("builtins.open", m):
|
||||
result = read_tty_line()
|
||||
self.assertEqual("hello", result)
|
||||
m.assert_called_once_with("/dev/tty", "r", encoding="utf-8")
|
||||
|
||||
def test_falls_back_to_stdin_on_oserror(self):
|
||||
import io
|
||||
from bot_bottle.util import read_tty_line
|
||||
import sys as _sys
|
||||
|
||||
with patch("builtins.open", side_effect=OSError("no tty")), \
|
||||
patch.object(_sys, "stdin", io.StringIO("world\n")):
|
||||
result = read_tty_line()
|
||||
self.assertEqual("world", result)
|
||||
|
||||
|
||||
class TestPrintVmInstallInstructions(unittest.TestCase):
|
||||
"""Unit tests for _print_vm_install_instructions platform branches."""
|
||||
|
||||
def test_linux_prints_firecracker_instructions(self):
|
||||
from bot_bottle.backend import _print_vm_install_instructions
|
||||
|
||||
with patch.object(backend_mod, "_platform_vm_suggestion",
|
||||
return_value="firecracker"), \
|
||||
patch.object(backend_mod, "info") as mock_info:
|
||||
_print_vm_install_instructions()
|
||||
|
||||
messages = [str(c[0][0]) for c in mock_info.call_args_list]
|
||||
self.assertTrue(any("Firecracker" in m for m in messages))
|
||||
|
||||
def test_macos_prints_apple_container_instructions(self):
|
||||
from bot_bottle.backend import _print_vm_install_instructions
|
||||
|
||||
with patch.object(backend_mod, "_platform_vm_suggestion",
|
||||
return_value="macos-container"), \
|
||||
patch.object(backend_mod, "info") as mock_info:
|
||||
_print_vm_install_instructions()
|
||||
|
||||
messages = [str(c[0][0]) for c in mock_info.call_args_list]
|
||||
self.assertTrue(any("Apple Container" in m for m in messages))
|
||||
|
||||
|
||||
class TestKnownBackendNames(unittest.TestCase):
|
||||
def test_returns_backends_sorted(self):
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"""Unit: `cli.py cleanup` walks every backend (issue follow-up).
|
||||
"""Unit: `cli.py cleanup` walks every available backend.
|
||||
|
||||
Asserts cmd_cleanup queries each backend's `prepare_cleanup`,
|
||||
combines the y/N output, and runs each backend's `cleanup` when
|
||||
the operator confirms. Mocks the backends and stdin."""
|
||||
Asserts cmd_cleanup queries each available backend's `prepare_cleanup`,
|
||||
combines the y/N output, and runs each backend's `cleanup` when the
|
||||
operator confirms. Unavailable backends (e.g. macos-container on Linux)
|
||||
are skipped so that `cleanup` never errors on a platform where a backend
|
||||
is not installed. Mocks the backends and stdin."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -21,7 +23,7 @@ def _make_backend(empty: bool = True):
|
||||
|
||||
|
||||
class TestCmdCleanup(unittest.TestCase):
|
||||
def test_iterates_every_backend(self):
|
||||
def test_iterates_every_available_backend(self):
|
||||
docker, docker_plan = _make_backend(empty=False)
|
||||
fc, fc_plan = _make_backend(empty=False)
|
||||
backends_by_name = {"docker": docker, "firecracker": fc}
|
||||
@@ -32,6 +34,8 @@ class TestCmdCleanup(unittest.TestCase):
|
||||
), patch.object(
|
||||
cmd, "get_bottle_backend",
|
||||
side_effect=lambda name: backends_by_name[name], # type: ignore
|
||||
), patch.object(
|
||||
cmd, "has_backend", return_value=True,
|
||||
), patch.object(
|
||||
cmd, "_prompt_yes", return_value=True,
|
||||
):
|
||||
@@ -42,6 +46,32 @@ class TestCmdCleanup(unittest.TestCase):
|
||||
docker.cleanup.assert_called_once_with(docker_plan)
|
||||
fc.cleanup.assert_called_once_with(fc_plan)
|
||||
|
||||
def test_skips_unavailable_backends(self):
|
||||
# macos-container is not available on Linux — must be silently skipped.
|
||||
docker, docker_plan = _make_backend(empty=False)
|
||||
macos = MagicMock()
|
||||
backends_by_name = {"docker": docker}
|
||||
|
||||
def _has(name: str) -> bool:
|
||||
return name == "docker"
|
||||
|
||||
with patch.object(
|
||||
cmd, "known_backend_names",
|
||||
return_value=("docker", "macos-container"),
|
||||
), patch.object(
|
||||
cmd, "get_bottle_backend",
|
||||
side_effect=lambda name: backends_by_name[name], # type: ignore
|
||||
), patch.object(
|
||||
cmd, "has_backend", side_effect=_has,
|
||||
), patch.object(
|
||||
cmd, "_prompt_yes", return_value=True,
|
||||
):
|
||||
self.assertEqual(0, cmd.cmd_cleanup([]))
|
||||
|
||||
docker.prepare_cleanup.assert_called_once()
|
||||
docker.cleanup.assert_called_once_with(docker_plan)
|
||||
macos.prepare_cleanup.assert_not_called()
|
||||
|
||||
def test_short_circuits_when_all_empty(self):
|
||||
docker, _ = _make_backend(empty=True)
|
||||
fc, _ = _make_backend(empty=True)
|
||||
@@ -53,6 +83,8 @@ class TestCmdCleanup(unittest.TestCase):
|
||||
), patch.object(
|
||||
cmd, "get_bottle_backend",
|
||||
side_effect=lambda name: backends_by_name[name], # type: ignore
|
||||
), patch.object(
|
||||
cmd, "has_backend", return_value=True,
|
||||
), patch.object(
|
||||
cmd, "_prompt_yes",
|
||||
) as prompt:
|
||||
@@ -72,6 +104,8 @@ class TestCmdCleanup(unittest.TestCase):
|
||||
), patch.object(
|
||||
cmd, "get_bottle_backend",
|
||||
side_effect=lambda name: backends_by_name[name], # type: ignore
|
||||
), patch.object(
|
||||
cmd, "has_backend", return_value=True,
|
||||
), patch.object(
|
||||
cmd, "_prompt_yes", return_value=False,
|
||||
):
|
||||
@@ -92,6 +126,8 @@ class TestCmdCleanup(unittest.TestCase):
|
||||
), patch.object(
|
||||
cmd, "get_bottle_backend",
|
||||
side_effect=lambda name: backends_by_name[name], # type: ignore
|
||||
), patch.object(
|
||||
cmd, "has_backend", return_value=True,
|
||||
), patch.object(
|
||||
cmd, "_prompt_yes", return_value=True,
|
||||
):
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
"""Unit tests for bb login command."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
class TestFlagParsing(unittest.TestCase):
|
||||
def test_console_url_flag(self) -> None:
|
||||
from bot_bottle.cli.login import _flag
|
||||
self.assertEqual(_flag(["--console-url", "http://x"], "--console-url"), "http://x")
|
||||
|
||||
def test_console_url_equals_form(self) -> None:
|
||||
from bot_bottle.cli.login import _flag
|
||||
self.assertEqual(_flag(["--console-url=http://x"], "--console-url"), "http://x")
|
||||
|
||||
def test_label_flag(self) -> None:
|
||||
from bot_bottle.cli.login import _flag
|
||||
self.assertEqual(_flag(["--label", "my-mac"], "--label"), "my-mac")
|
||||
|
||||
def test_missing_flag_returns_none(self) -> None:
|
||||
from bot_bottle.cli.login import _flag
|
||||
self.assertIsNone(_flag([], "--console-url"))
|
||||
|
||||
|
||||
class TestSaveCredentials(unittest.TestCase):
|
||||
def test_writes_json_and_sets_perms(self) -> None:
|
||||
from bot_bottle.cli.login import _save_credentials
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
with patch.dict(os.environ, {"BOT_BOTTLE_ROOT": tmp}):
|
||||
path = _save_credentials("http://c", "hid", "at", "rt")
|
||||
self.assertTrue(path.exists())
|
||||
data = json.loads(path.read_text())
|
||||
self.assertEqual(data["url"], "http://c")
|
||||
self.assertEqual(data["host_id"], "hid")
|
||||
self.assertEqual(data["access_token"], "at")
|
||||
self.assertEqual(data["refresh_token"], "rt")
|
||||
self.assertEqual(oct(path.stat().st_mode & 0o777), oct(0o600))
|
||||
|
||||
|
||||
class TestCmdLoginMissingUrl(unittest.TestCase):
|
||||
def test_returns_1_without_url(self) -> None:
|
||||
from bot_bottle.cli.login import cmd_login
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
os.environ.pop("BB_CONSOLE_URL", None)
|
||||
result = cmd_login([])
|
||||
self.assertEqual(result, 1)
|
||||
|
||||
def test_reads_env_var(self) -> None:
|
||||
"""Exits 1 (network error) not because of missing URL when env var is set."""
|
||||
from bot_bottle.cli.login import cmd_login
|
||||
|
||||
def _fail_post(url, payload):
|
||||
raise OSError("connection refused")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
with patch.dict(os.environ, {"BB_CONSOLE_URL": "http://localhost:9999", "BOT_BOTTLE_ROOT": tmp}):
|
||||
with patch("bot_bottle.cli.login._post", side_effect=_fail_post):
|
||||
result = cmd_login([])
|
||||
self.assertEqual(result, 1)
|
||||
|
||||
|
||||
class TestCmdLoginFlow(unittest.TestCase):
|
||||
def _run_with_mocks(self, poll_responses: list[dict], tmp: str) -> int:
|
||||
from bot_bottle.cli.login import cmd_login
|
||||
|
||||
start_resp = {
|
||||
"device_code": "dc123",
|
||||
"user_code": "ABC-DEF",
|
||||
"expires_in": 300,
|
||||
"poll_interval": 0,
|
||||
}
|
||||
|
||||
poll_iter = iter(poll_responses)
|
||||
|
||||
def _fake_post(url, payload):
|
||||
return start_resp
|
||||
|
||||
def _fake_get(url):
|
||||
try:
|
||||
resp = next(poll_iter)
|
||||
except StopIteration:
|
||||
return 200, {"status": "pending"}
|
||||
return 200, resp
|
||||
|
||||
with patch.dict(os.environ, {"BOT_BOTTLE_ROOT": tmp}):
|
||||
with patch("bot_bottle.cli.login._post", side_effect=_fake_post):
|
||||
with patch("bot_bottle.cli.login._get", side_effect=_fake_get):
|
||||
with patch("bot_bottle.cli.login._POLL_SLEEP", 0):
|
||||
return cmd_login(["--console-url", "http://console"])
|
||||
|
||||
def test_approved_flow_returns_0(self) -> None:
|
||||
approved = {
|
||||
"status": "approved",
|
||||
"host_id": "hid",
|
||||
"access_token": "at",
|
||||
"refresh_token": "rt",
|
||||
}
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
result = self._run_with_mocks(
|
||||
[{"status": "pending"}, approved], tmp
|
||||
)
|
||||
self.assertEqual(result, 0)
|
||||
with open(os.path.join(tmp, "console.json")) as f:
|
||||
creds = json.loads(f.read())
|
||||
self.assertEqual(creds["host_id"], "hid")
|
||||
|
||||
def test_denied_flow_returns_1(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
result = self._run_with_mocks([{"status": "denied"}], tmp)
|
||||
self.assertEqual(result, 1)
|
||||
|
||||
def test_timeout_returns_1(self) -> None:
|
||||
from bot_bottle.cli.login import cmd_login
|
||||
|
||||
start_resp = {
|
||||
"device_code": "dc",
|
||||
"user_code": "ZZZ-ZZZ",
|
||||
"expires_in": 0, # already expired
|
||||
"poll_interval": 0,
|
||||
}
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
with patch.dict(os.environ, {"BOT_BOTTLE_ROOT": tmp}):
|
||||
with patch("bot_bottle.cli.login._post", return_value=start_resp):
|
||||
with patch("bot_bottle.cli.login._POLL_SLEEP", 0):
|
||||
result = cmd_login(["--console-url", "http://console"])
|
||||
self.assertEqual(result, 1)
|
||||
|
||||
|
||||
class TestDispatcherRegistration(unittest.TestCase):
|
||||
def test_login_in_commands(self) -> None:
|
||||
from bot_bottle.cli import COMMANDS
|
||||
self.assertIn("login", COMMANDS)
|
||||
|
||||
def test_login_in_no_migration(self) -> None:
|
||||
from bot_bottle.cli import NO_MIGRATION_COMMANDS
|
||||
self.assertIn("login", NO_MIGRATION_COMMANDS)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,61 +1,29 @@
|
||||
"""Unit: `cli.py start --backend=<name>` flag (issue #77).
|
||||
"""Unit: backend resolution priority — explicit name > env var.
|
||||
|
||||
Asserts that the flag wins over the env var, that the env var is
|
||||
the fallback, and that the choices are pulled from the backend
|
||||
registry (so adding a backend lights up in argparse without code
|
||||
edits)."""
|
||||
The `--backend` flag has been removed from `cli.py start`; backend
|
||||
selection is driven by BOT_BOTTLE_BACKEND or auto-selection only.
|
||||
`get_bottle_backend` still accepts an explicit name so that `resume`
|
||||
(which records the backend from a prior session) and the `backend`
|
||||
sub-command can pass one directly."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
from bot_bottle.backend import known_backend_names
|
||||
|
||||
|
||||
class TestStartBackendFlag(unittest.TestCase):
|
||||
"""The flag is wired by `cmd_start`'s argparse and threaded
|
||||
through `prepare_with_preflight(backend_name=...)`. Rather than
|
||||
drive the whole start flow (which builds containers), we test
|
||||
the argparse shape and the resolution function separately."""
|
||||
|
||||
def _build_parser(self):
|
||||
# Mirror the parser definition from `cmd_start` so this
|
||||
# test doesn't have to invoke the full command.
|
||||
parser = argparse.ArgumentParser(prog="cb start")
|
||||
parser.add_argument(
|
||||
"--backend",
|
||||
choices=known_backend_names(),
|
||||
default=None,
|
||||
)
|
||||
parser.add_argument("name")
|
||||
return parser
|
||||
|
||||
def test_flag_recognized(self):
|
||||
args = self._build_parser().parse_args(["--backend=firecracker", "researcher"])
|
||||
self.assertEqual("firecracker", args.backend)
|
||||
self.assertEqual("researcher", args.name)
|
||||
|
||||
def test_flag_default_none_means_env_or_default_backend(self):
|
||||
args = self._build_parser().parse_args(["researcher"])
|
||||
self.assertIsNone(args.backend)
|
||||
|
||||
def test_invalid_backend_rejected_by_argparse(self):
|
||||
parser = self._build_parser()
|
||||
with self.assertRaises(SystemExit):
|
||||
parser.parse_args(["--backend=garbage", "researcher"])
|
||||
|
||||
def test_resolution_priority_explicit_over_env(self):
|
||||
# Independent assertion that get_bottle_backend (where
|
||||
# `--backend` ultimately threads to) prefers the explicit
|
||||
# name over BOT_BOTTLE_BACKEND.
|
||||
class TestBackendResolutionPriority(unittest.TestCase):
|
||||
def test_explicit_name_overrides_env_var(self):
|
||||
from bot_bottle.backend import get_bottle_backend
|
||||
with patch.dict(os.environ, {"BOT_BOTTLE_BACKEND": "firecracker"}):
|
||||
self.assertEqual("docker", get_bottle_backend("docker").name)
|
||||
|
||||
def test_env_var_used_when_no_explicit_name(self):
|
||||
from bot_bottle.backend import get_bottle_backend
|
||||
with patch.dict(os.environ, {"BOT_BOTTLE_BACKEND": "firecracker"}):
|
||||
self.assertEqual("firecracker", get_bottle_backend().name)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -175,14 +175,6 @@ class TestCmdStartHeadless(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual("researcher-2", self._spec().label)
|
||||
|
||||
# -- backend wiring ------------------------------------------------
|
||||
|
||||
def test_backend_flag_forwarded(self):
|
||||
start_mod.cmd_start(
|
||||
["--headless", "--backend=docker", "researcher", "--bottle", "claude",
|
||||
"--prompt", "Do it"]
|
||||
)
|
||||
self.assertEqual("docker", self._launch_mock.call_args[1]["backend_name"])
|
||||
|
||||
|
||||
class TestPrepareWithPreflight(unittest.TestCase):
|
||||
|
||||
@@ -82,7 +82,7 @@ class TestCmdStartSelector(unittest.TestCase):
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_explicit_agent_skips_agent_picker(self):
|
||||
rc = start_mod.cmd_start(["--backend=docker", "researcher"])
|
||||
rc = start_mod.cmd_start(["researcher"])
|
||||
self.assertEqual(0, rc)
|
||||
self._agent_picker_mock.assert_not_called()
|
||||
self._bottle_picker_mock.assert_called_once()
|
||||
@@ -100,7 +100,7 @@ class TestCmdStartSelector(unittest.TestCase):
|
||||
|
||||
def test_agent_absent_shows_agent_picker(self):
|
||||
self._agent_picker_mock.return_value = "researcher"
|
||||
rc = start_mod.cmd_start(["--backend=docker"])
|
||||
rc = start_mod.cmd_start([])
|
||||
self.assertEqual(0, rc)
|
||||
self._agent_picker_mock.assert_called_once()
|
||||
call_kwargs = self._agent_picker_mock.call_args
|
||||
@@ -111,7 +111,7 @@ class TestCmdStartSelector(unittest.TestCase):
|
||||
|
||||
def test_agent_picker_cancel_skips_bottle_picker(self):
|
||||
self._agent_picker_mock.return_value = None
|
||||
rc = start_mod.cmd_start(["--backend=docker"])
|
||||
rc = start_mod.cmd_start([])
|
||||
self.assertEqual(0, rc)
|
||||
self._bottle_picker_mock.assert_not_called()
|
||||
self._launch_mock.assert_not_called()
|
||||
@@ -168,16 +168,6 @@ class TestCmdStartSelector(unittest.TestCase):
|
||||
# Backend wiring
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_explicit_backend_forwarded(self):
|
||||
start_mod.cmd_start(["--backend=docker", "researcher"])
|
||||
_, kwargs = self._launch_mock.call_args
|
||||
self.assertEqual("docker", kwargs["backend_name"])
|
||||
|
||||
def test_absent_backend_uses_default(self):
|
||||
start_mod.cmd_start(["researcher"])
|
||||
_, kwargs = self._launch_mock.call_args
|
||||
self.assertIsNone(kwargs["backend_name"])
|
||||
|
||||
def test_bot_bottle_backend_env_skips_backend_picker(self):
|
||||
os.environ["BOT_BOTTLE_BACKEND"] = "docker"
|
||||
try:
|
||||
|
||||
@@ -17,10 +17,10 @@ from unittest.mock import patch
|
||||
from bot_bottle.backend.macos_container.bottle import MacosContainerBottle
|
||||
from bot_bottle.backend.macos_container.bottle_plan import MacosContainerBottlePlan
|
||||
from bot_bottle.backend.macos_container.consolidated_launch import GatewayEndpoint
|
||||
from bot_bottle.backend.macos_container.gateway_hosts import GATEWAY_HOSTNAME
|
||||
from bot_bottle.backend.macos_container.launch import (
|
||||
_agent_run_argv,
|
||||
_identity_proxy_env,
|
||||
_proxy_url,
|
||||
)
|
||||
from bot_bottle.manifest import ManifestIndex
|
||||
|
||||
@@ -104,19 +104,38 @@ class TestAgentRunArgv(unittest.TestCase):
|
||||
read back after start."""
|
||||
self.assertNotIn("--ip", self.argv)
|
||||
|
||||
def test_run_time_proxy_carries_no_identity_token(self) -> None:
|
||||
"""The token is minted by registration, which happens after this run —
|
||||
so it cannot be here. `/resolve` denies the token-less pair (#366),
|
||||
which is the safe direction; the real value arrives at exec time."""
|
||||
joined = " ".join(self.argv)
|
||||
self.assertIn(f"HTTP_PROXY={_proxy_url('192.168.128.3')}", joined)
|
||||
self.assertNotIn("bottle:", joined)
|
||||
def test_run_time_env_sets_no_proxy_vars_at_all(self) -> None:
|
||||
"""Regression: the proxy vars must exist *only* in the exec-time env.
|
||||
|
||||
`container exec --env` appends rather than replaces, so a token-less
|
||||
`HTTPS_PROXY` here would survive next to the token-bearing one and
|
||||
leave two entries in the agent's `environ`. Resolution is then
|
||||
runtime-specific — Node reads the last, Rust's `std::env::var` reads
|
||||
the first — so Codex proxied without its identity token and every
|
||||
request fail-closed at `/resolve`.
|
||||
"""
|
||||
for entry in self.argv:
|
||||
self.assertFalse(
|
||||
entry.upper().startswith(("HTTP_PROXY=", "HTTPS_PROXY=")),
|
||||
f"run-time env must not set a proxy var, got {entry!r}",
|
||||
)
|
||||
|
||||
def test_run_time_env_carries_no_identity_token(self) -> None:
|
||||
"""The token is minted by registration, which happens after this run,
|
||||
so it cannot be here under any name."""
|
||||
self.assertNotIn("bottle:", " ".join(self.argv))
|
||||
|
||||
def test_gateway_bypasses_the_proxy(self) -> None:
|
||||
"""git-http + supervise live on the gateway and must be reached
|
||||
directly, not through its own egress proxy."""
|
||||
entry = next(a for a in self.argv if a.startswith("NO_PROXY="))
|
||||
self.assertIn("192.168.128.3", entry)
|
||||
self.assertIn(GATEWAY_HOSTNAME, entry)
|
||||
|
||||
def test_no_proxy_names_the_gateway_and_never_addresses_it(self) -> None:
|
||||
"""NO_PROXY is baked into the run-time env, so an address here is as
|
||||
unfixable as the proxy URL if the gateway moves."""
|
||||
entry = next(a for a in self.argv if a.startswith("NO_PROXY="))
|
||||
self.assertNotIn("192.168.128.3", entry)
|
||||
|
||||
def test_forwarded_secrets_stay_off_argv(self) -> None:
|
||||
"""Bare name → inherited from the run process env, so the value never
|
||||
@@ -134,7 +153,7 @@ class TestIdentityTokenDelivery(unittest.TestCase):
|
||||
def test_exec_env_carries_the_token_as_proxy_credentials(self) -> None:
|
||||
env = _identity_proxy_env(_endpoint(), "s3cret")
|
||||
self.assertEqual(
|
||||
"http://bottle:s3cret@192.168.128.3:9099", env["HTTP_PROXY"],
|
||||
f"http://bottle:s3cret@{GATEWAY_HOSTNAME}:9099", env["HTTP_PROXY"],
|
||||
)
|
||||
self.assertEqual(env["HTTP_PROXY"], env["https_proxy"])
|
||||
|
||||
@@ -202,7 +221,7 @@ class TestPlanIdentityToken(unittest.TestCase):
|
||||
self.assertIn("HTTP_PROXY", argv)
|
||||
self.assertNotIn("s3cret", " ".join(argv))
|
||||
self.assertEqual(
|
||||
"http://bottle:s3cret@192.168.128.3:9099", kwargs["env"]["HTTP_PROXY"],
|
||||
f"http://bottle:s3cret@{GATEWAY_HOSTNAME}:9099", kwargs["env"]["HTTP_PROXY"],
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -272,6 +272,30 @@ resolver #2
|
||||
),
|
||||
)
|
||||
|
||||
def test_exec_container_as_root_selects_root_user(self):
|
||||
completed = util.subprocess.CompletedProcess(
|
||||
args=[], returncode=0, stdout="", stderr="",
|
||||
)
|
||||
with patch.object(util, "_run_container_op", return_value=completed) as run:
|
||||
util.exec_container_as_root("bot-bottle-demo", ["true"])
|
||||
|
||||
run.assert_called_once_with([
|
||||
"container", "exec", "--user", "root", "bot-bottle-demo", "true",
|
||||
])
|
||||
|
||||
def test_exec_container_as_root_reports_failure(self):
|
||||
failed = util.subprocess.CompletedProcess(
|
||||
args=[], returncode=1, stdout="", stderr="permission denied\n",
|
||||
)
|
||||
with patch.object(util, "_run_container_op", return_value=failed), \
|
||||
patch.object(util, "die", side_effect=SystemExit("die")) as die:
|
||||
with self.assertRaises(SystemExit):
|
||||
util.exec_container_as_root("bot-bottle-demo", ["true"])
|
||||
|
||||
die.assert_called_once_with(
|
||||
"container exec (root) in bot-bottle-demo failed: permission denied",
|
||||
)
|
||||
|
||||
|
||||
def _completed(stdout: str, returncode: int = 0):
|
||||
return util.subprocess.CompletedProcess(args=[], returncode=returncode, stdout=stdout, stderr="")
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Unit: stable gateway name via each bottle's /etc/hosts (issue #443).
|
||||
|
||||
The gateway's address moves whenever the infra container is recreated. Agents
|
||||
name it instead of addressing it, and the name resolves through `/etc/hosts` —
|
||||
a file, so it stays rewritable while the bottle runs, unlike the `environ` the
|
||||
proxy URL is delivered in.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from bot_bottle.backend.macos_container.gateway_hosts import (
|
||||
GATEWAY_HOSTNAME,
|
||||
refresh_gateway_host,
|
||||
set_gateway_host,
|
||||
)
|
||||
|
||||
_MOD = "bot_bottle.backend.macos_container.gateway_hosts"
|
||||
|
||||
|
||||
class TestSetGatewayHost(unittest.TestCase):
|
||||
def _script(self, exec_root: object) -> str:
|
||||
argv = exec_root.call_args.args[1] # type: ignore[attr-defined]
|
||||
self.assertEqual(["sh", "-c"], argv[:2])
|
||||
return argv[2]
|
||||
|
||||
def test_writes_the_address_against_the_stable_name(self) -> None:
|
||||
with patch(f"{_MOD}.container_mod.exec_container_as_root") as ex:
|
||||
set_gateway_host("bot-bottle-demo", "192.168.128.19")
|
||||
self.assertEqual("bot-bottle-demo", ex.call_args.args[0])
|
||||
script = self._script(ex)
|
||||
self.assertIn("192.168.128.19", script)
|
||||
self.assertIn(GATEWAY_HOSTNAME, script)
|
||||
|
||||
def test_runs_as_root_so_the_agent_cannot_repoint_itself(self) -> None:
|
||||
"""The agent runs as `node`. If it could rewrite /etc/hosts it could
|
||||
aim its own gateway name elsewhere, so the write must go through the
|
||||
root-only helper."""
|
||||
with patch(f"{_MOD}.container_mod.exec_container_as_root") as ex:
|
||||
set_gateway_host("bot-bottle-demo", "10.0.0.1")
|
||||
ex.assert_called_once()
|
||||
|
||||
def test_is_idempotent_by_removing_its_own_line_first(self) -> None:
|
||||
"""Re-pointing must replace the managed entry, not append a second one
|
||||
— two entries for the same name would resolve by luck of ordering."""
|
||||
with patch(f"{_MOD}.container_mod.exec_container_as_root") as ex:
|
||||
set_gateway_host("bot-bottle-demo", "10.0.0.1")
|
||||
self.assertIn("grep -v", self._script(ex))
|
||||
|
||||
def test_preserves_the_rest_of_the_hosts_file(self) -> None:
|
||||
"""localhost and the container's own name must survive the rewrite."""
|
||||
with patch(f"{_MOD}.container_mod.exec_container_as_root") as ex:
|
||||
set_gateway_host("bot-bottle-demo", "10.0.0.1")
|
||||
script = self._script(ex)
|
||||
# Filter-and-append, never a truncating write of just our line.
|
||||
self.assertIn("/etc/hosts >", script)
|
||||
self.assertIn(">> /tmp/.bb-hosts", script)
|
||||
|
||||
def test_keeps_the_original_inode(self) -> None:
|
||||
"""`cat >` rather than `mv`: a pre-created /etc/hosts must keep its
|
||||
ownership and mode, not be replaced by a root-owned copy."""
|
||||
script = None
|
||||
with patch(f"{_MOD}.container_mod.exec_container_as_root") as ex:
|
||||
set_gateway_host("bot-bottle-demo", "10.0.0.1")
|
||||
script = self._script(ex)
|
||||
self.assertIn("cat /tmp/.bb-hosts > /etc/hosts", script)
|
||||
self.assertNotIn("mv ", script)
|
||||
|
||||
|
||||
class TestRefreshGatewayHost(unittest.TestCase):
|
||||
"""The re-attach sweep: bottles stranded by an earlier gateway restart get
|
||||
re-pointed in place instead of needing a relaunch."""
|
||||
|
||||
def _agents(self, *slugs: str) -> list[SimpleNamespace]:
|
||||
return [SimpleNamespace(slug=s) for s in slugs]
|
||||
|
||||
def test_repoints_every_running_bottle(self) -> None:
|
||||
with patch(f"{_MOD}.enumerate_active", return_value=self._agents("a", "b")), \
|
||||
patch(f"{_MOD}.set_gateway_host") as setter:
|
||||
updated = refresh_gateway_host("192.168.128.19")
|
||||
self.assertEqual(["bot-bottle-a", "bot-bottle-b"], updated)
|
||||
self.assertEqual(
|
||||
[("bot-bottle-a", "192.168.128.19"), ("bot-bottle-b", "192.168.128.19")],
|
||||
[c.args for c in setter.call_args_list],
|
||||
)
|
||||
|
||||
def test_one_failing_bottle_does_not_stop_the_sweep(self) -> None:
|
||||
"""A container that is already exiting must not block the repair of
|
||||
its neighbours, nor fail the launch that triggered the sweep."""
|
||||
def _flaky(name: str, _ip: str) -> None:
|
||||
if name == "bot-bottle-a":
|
||||
raise RuntimeError("container is exiting")
|
||||
|
||||
with patch(f"{_MOD}.enumerate_active", return_value=self._agents("a", "b")), \
|
||||
patch(f"{_MOD}.set_gateway_host", side_effect=_flaky), \
|
||||
patch(f"{_MOD}.warn") as warn:
|
||||
updated = refresh_gateway_host("10.0.0.1")
|
||||
self.assertEqual(["bot-bottle-b"], updated)
|
||||
warn.assert_called_once()
|
||||
|
||||
def test_no_running_bottles_is_a_clean_no_op(self) -> None:
|
||||
with patch(f"{_MOD}.enumerate_active", return_value=[]), \
|
||||
patch(f"{_MOD}.set_gateway_host") as setter:
|
||||
self.assertEqual([], refresh_gateway_host("10.0.0.1"))
|
||||
setter.assert_not_called()
|
||||
|
||||
|
||||
|
||||
class TestLaunchWiring(unittest.TestCase):
|
||||
"""Ordering matters: the name must resolve before anything execs, and the
|
||||
stranded-bottle sweep must run once the gateway is known to be up."""
|
||||
|
||||
def test_launch_sets_the_host_entry_before_reading_the_source_ip(self) -> None:
|
||||
"""The agent's every URL names the gateway, so the entry has to exist
|
||||
before the first connection — which means before the agent execs."""
|
||||
import inspect
|
||||
|
||||
from bot_bottle.backend.macos_container import launch
|
||||
|
||||
src = inspect.getsource(launch)
|
||||
set_at = src.index("set_gateway_host(plan.container_name")
|
||||
exec_at = src.index("wait_container_ipv4_on_network")
|
||||
self.assertLess(set_at, exec_at)
|
||||
|
||||
def test_launch_refreshes_stranded_bottles_after_ensure_gateway(self) -> None:
|
||||
import inspect
|
||||
|
||||
from bot_bottle.backend.macos_container import launch
|
||||
|
||||
src = inspect.getsource(launch)
|
||||
ensure_at = src.index("endpoint = ensure_gateway()")
|
||||
refresh_at = src.index("refresh_gateway_host(endpoint.gateway_ip)")
|
||||
self.assertLess(ensure_at, refresh_at)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -46,7 +46,9 @@ class TestInfraRun(unittest.TestCase):
|
||||
argv = self._run_container(MacosInfraService(repo_root=Path("/r")))
|
||||
script = argv[-1]
|
||||
self.assertIn("bot_bottle.orchestrator", script)
|
||||
self.assertIn("gateway_init.py", script)
|
||||
# Gateway launches via the installed package (there is no
|
||||
# /app/gateway_init.py file since the daemons moved into bot_bottle).
|
||||
self.assertIn("bot_bottle.gateway_init", script)
|
||||
self.assertIn("127.0.0.1", script) # they reach each other on loopback
|
||||
|
||||
def test_db_is_a_container_only_volume(self) -> None:
|
||||
|
||||
Reference in New Issue
Block a user