Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 389a2c10ce | |||
| 9896986810 | |||
| 20e83258bf | |||
| c5ba33bf0a | |||
| 72286f188d | |||
| 77aaabae63 | |||
| d0b35b5506 | |||
| ea0c070cfe |
@@ -0,0 +1,145 @@
|
||||
"""Consolidated bottle launch sequence for the docker backend (PRD 0070).
|
||||
|
||||
Composes the orchestrator primitives into the register/teardown sequence that
|
||||
replaces the per-bottle sidecar bundle:
|
||||
|
||||
1. ensure the orchestrator control plane + shared gateway are up;
|
||||
2. allocate the bottle a pinned source IP on the gateway network (the
|
||||
attribution key), skipping the gateway's own address + live bottles;
|
||||
3. register it (egress policy blob + slug metadata) → bottle id + identity
|
||||
token;
|
||||
4. provision its git-gate repos/creds into the running gateway.
|
||||
|
||||
It returns a `LaunchContext` with everything the agent container needs to
|
||||
attach — network, pinned IP, the gateway's address (its proxy target), the
|
||||
orchestrator URL, and the identity token. The agent `docker run` itself is
|
||||
the backend's job (it owns provider provisioning); this owns the
|
||||
orchestrator-facing wiring so that sequence stays testable in isolation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from ...docker_cmd import run_docker
|
||||
from ...egress import EgressPlan
|
||||
from ...git_gate import GitGatePlan
|
||||
from ...orchestrator.client import OrchestratorClient
|
||||
from ...orchestrator.gateway import GATEWAY_NAME, GATEWAY_NETWORK
|
||||
from ...orchestrator.lifecycle import OrchestratorProcess
|
||||
from ...orchestrator.registration import registration_inputs
|
||||
from .gateway_net import next_free_ip
|
||||
from .gateway_provision import deprovision_git_gate, provision_git_gate
|
||||
|
||||
|
||||
class ConsolidatedLaunchError(RuntimeError):
|
||||
"""The consolidated register/provision sequence could not complete."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LaunchContext:
|
||||
"""What the agent container needs to join the shared gateway."""
|
||||
|
||||
bottle_id: str
|
||||
identity_token: str
|
||||
source_ip: str # the agent's pinned address (attribution key)
|
||||
network: str # the shared gateway network to attach to
|
||||
gateway_ip: str # the gateway's address — the agent's proxy target
|
||||
orchestrator_url: str
|
||||
|
||||
|
||||
def _network_cidr(network: str) -> str:
|
||||
"""The gateway network's IPv4 subnet, or raise."""
|
||||
proc = run_docker([
|
||||
"docker", "network", "inspect",
|
||||
"--format", "{{range .IPAM.Config}}{{.Subnet}}{{end}}", network,
|
||||
])
|
||||
cidr = proc.stdout.strip()
|
||||
if proc.returncode != 0 or not cidr:
|
||||
raise ConsolidatedLaunchError(
|
||||
f"gateway network {network} has no subnet: {proc.stderr.strip()}"
|
||||
)
|
||||
return cidr
|
||||
|
||||
|
||||
def _container_ip(name: str, network: str) -> str:
|
||||
"""A container's IPv4 address on `network`, or raise."""
|
||||
proc = run_docker([
|
||||
"docker", "inspect", "--format",
|
||||
f'{{{{(index .NetworkSettings.Networks "{network}").IPAddress}}}}', name,
|
||||
])
|
||||
ip = proc.stdout.strip()
|
||||
if proc.returncode != 0 or not ip:
|
||||
raise ConsolidatedLaunchError(
|
||||
f"gateway {name} has no address on {network}: {proc.stderr.strip()}"
|
||||
)
|
||||
return ip
|
||||
|
||||
|
||||
def _taken_ips(client: OrchestratorClient, gateway_ip: str) -> list[str]:
|
||||
"""Every address already in use on the gateway network: the gateway
|
||||
container plus every live bottle the registry knows."""
|
||||
taken = [gateway_ip]
|
||||
for rec in client.list_bottles():
|
||||
src = rec.get("source_ip")
|
||||
if isinstance(src, str) and src:
|
||||
taken.append(src)
|
||||
return taken
|
||||
|
||||
|
||||
def launch_consolidated(
|
||||
egress_plan: EgressPlan,
|
||||
git_gate_plan: GitGatePlan,
|
||||
*,
|
||||
image_ref: str = "",
|
||||
process: OrchestratorProcess | None = None,
|
||||
gateway_name: str = GATEWAY_NAME,
|
||||
network: str = GATEWAY_NETWORK,
|
||||
) -> LaunchContext:
|
||||
"""Ensure the orchestrator + gateway are up, allocate + register the
|
||||
bottle, and provision its git-gate state. Returns the agent's attach
|
||||
context. Raises `ConsolidatedLaunchError` (or the primitives' own errors)
|
||||
if any step fails — the caller tears down on failure."""
|
||||
process = process or OrchestratorProcess()
|
||||
url = process.ensure_running()
|
||||
client = OrchestratorClient(url)
|
||||
|
||||
cidr = _network_cidr(network)
|
||||
gateway_ip = _container_ip(gateway_name, network)
|
||||
source_ip = next_free_ip(cidr, _taken_ips(client, gateway_ip))
|
||||
|
||||
inputs = registration_inputs(egress_plan)
|
||||
reg = client.register_bottle(
|
||||
source_ip, image_ref=image_ref, policy=inputs.policy, metadata=inputs.metadata,
|
||||
)
|
||||
try:
|
||||
provision_git_gate(gateway_name, reg.bottle_id, git_gate_plan)
|
||||
except Exception:
|
||||
# Roll the registration back so a provisioning failure leaves no orphan.
|
||||
client.teardown_bottle(reg.bottle_id)
|
||||
raise
|
||||
return LaunchContext(
|
||||
bottle_id=reg.bottle_id,
|
||||
identity_token=reg.identity_token,
|
||||
source_ip=source_ip,
|
||||
network=network,
|
||||
gateway_ip=gateway_ip,
|
||||
orchestrator_url=url,
|
||||
)
|
||||
|
||||
|
||||
def teardown_consolidated(
|
||||
bottle_id: str, *, orchestrator_url: str, gateway_name: str = GATEWAY_NAME,
|
||||
) -> None:
|
||||
"""Deregister the bottle and remove its git-gate state from the gateway.
|
||||
Both steps are idempotent so this is safe from a cleanup trap."""
|
||||
OrchestratorClient(orchestrator_url).teardown_bottle(bottle_id)
|
||||
deprovision_git_gate(gateway_name, bottle_id)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"LaunchContext",
|
||||
"launch_consolidated",
|
||||
"teardown_consolidated",
|
||||
"ConsolidatedLaunchError",
|
||||
]
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Shared-gateway source-IP allocation for the consolidated docker backend
|
||||
(PRD 0070).
|
||||
|
||||
In the consolidated model one gateway container serves every bottle over a
|
||||
single shared docker network, and each agent bottle attaches with a pinned,
|
||||
deterministic address that the gateway uses as its **attribution key**. This
|
||||
allocates those addresses from the network's subnet, skipping the reserved
|
||||
ones — the network address and broadcast (excluded by `hosts()`), docker's
|
||||
router `.1`, and everything already in use (`taken`: the gateway container
|
||||
plus every live bottle, which the caller reads from the registry).
|
||||
|
||||
Pure `ipaddress` logic — the docker-specific bits (the subnet CIDR, the
|
||||
gateway container's own address) are gathered by the caller and passed in, so
|
||||
this stays testable without docker.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
from collections.abc import Iterable
|
||||
|
||||
|
||||
class NoFreeAddressError(RuntimeError):
|
||||
"""The shared gateway network's subnet is exhausted — every host address
|
||||
is reserved or already assigned to a bottle."""
|
||||
|
||||
|
||||
def next_free_ip(cidr: str, taken: Iterable[str]) -> str:
|
||||
"""The lowest host address in `cidr` not in `taken` and not docker's
|
||||
router (`.1`). `taken` must include the gateway container's own address
|
||||
and every live bottle's. Raises `NoFreeAddressError` if the subnet is
|
||||
full."""
|
||||
net = ipaddress.ip_network(cidr, strict=False)
|
||||
reserved = {str(a) for a in taken}
|
||||
# Docker assigns the network's first host (.1) to the bridge router; a
|
||||
# bottle must never be handed that address.
|
||||
reserved.add(str(net.network_address + 1))
|
||||
for host in net.hosts(): # hosts() already excludes network + broadcast
|
||||
candidate = str(host)
|
||||
if candidate not in reserved:
|
||||
return candidate
|
||||
raise NoFreeAddressError(
|
||||
f"no free address in {cidr} ({len(reserved)} reserved/assigned)"
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["next_free_ip", "NoFreeAddressError"]
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Provision one bottle's git-gate state into the running shared gateway
|
||||
(PRD 0070, docker slice).
|
||||
|
||||
The consolidated gateway serves every bottle's repos under `/git/<bottle_id>/`
|
||||
with per-repo credentials under `/git-gate/creds/<bottle_id>/`. When a bottle
|
||||
is registered the launcher must place *its* deploy keys + known_hosts into
|
||||
that per-bottle creds dir and init its bare repos there — so this copies the
|
||||
credential files into the live gateway container and runs the (namespaced,
|
||||
init-only) provisioning script produced by `git_gate_render_provision`.
|
||||
|
||||
Isolating each bottle's creds dir + repo root by id is what keeps one
|
||||
bottle's push credentials out of another's repos on the shared gateway.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from ...docker_cmd import run_docker
|
||||
from ...git_gate import GitGatePlan, git_gate_render_provision
|
||||
|
||||
# bottle ids index the gateway's per-bottle repo + creds dirs; they land in
|
||||
# `docker cp`/`rm` path arguments, so validate before any path is built (a
|
||||
# traversal id like "../etc" must never reach the container). Registry ids are
|
||||
# token_hex — this is defense in depth at the docker boundary.
|
||||
_SAFE_BOTTLE_ID = re.compile(r"[A-Za-z0-9_-]+")
|
||||
|
||||
|
||||
class GatewayProvisionError(RuntimeError):
|
||||
"""A git-gate provisioning step against the running gateway failed."""
|
||||
|
||||
|
||||
def _require_safe(bottle_id: str) -> None:
|
||||
if not _SAFE_BOTTLE_ID.fullmatch(bottle_id):
|
||||
raise GatewayProvisionError(f"unsafe bottle id {bottle_id!r}")
|
||||
|
||||
|
||||
def _creds_dir(bottle_id: str) -> str:
|
||||
return f"/git-gate/creds/{bottle_id}"
|
||||
|
||||
|
||||
def _exec(gateway: str, argv: list[str]) -> None:
|
||||
"""`docker exec` a command in the gateway, raising on non-zero exit."""
|
||||
proc = run_docker(["docker", "exec", gateway, *argv])
|
||||
if proc.returncode != 0:
|
||||
raise GatewayProvisionError(
|
||||
f"gateway exec {argv!r} failed: {proc.stderr.strip()}"
|
||||
)
|
||||
|
||||
|
||||
def _cp_into(gateway: str, src: str, dest: str) -> None:
|
||||
"""`docker cp` a host file into the gateway, raising on non-zero exit."""
|
||||
proc = run_docker(["docker", "cp", src, f"{gateway}:{dest}"])
|
||||
if proc.returncode != 0:
|
||||
raise GatewayProvisionError(
|
||||
f"gateway cp {src} -> {dest} failed: {proc.stderr.strip()}"
|
||||
)
|
||||
|
||||
|
||||
def provision_git_gate(gateway: str, bottle_id: str, plan: GitGatePlan) -> None:
|
||||
"""Place `bottle_id`'s git-gate credentials into the running `gateway`
|
||||
container and init its bare repos under `/git/<bottle_id>/`.
|
||||
|
||||
Copies each upstream's identity key (and known_hosts, when present) into
|
||||
`/git-gate/creds/<bottle_id>/`, then runs the namespaced provisioning
|
||||
script. No-op for a bottle with no git upstreams."""
|
||||
_require_safe(bottle_id)
|
||||
if not plan.upstreams:
|
||||
return
|
||||
creds = _creds_dir(bottle_id)
|
||||
_exec(gateway, ["mkdir", "-p", creds])
|
||||
for u in plan.upstreams:
|
||||
if u.identity_file:
|
||||
_cp_into(gateway, u.identity_file, f"{creds}/{u.name}-key")
|
||||
known_hosts = str(u.known_hosts_file)
|
||||
if known_hosts and known_hosts != ".":
|
||||
_cp_into(gateway, known_hosts, f"{creds}/{u.name}-known_hosts")
|
||||
# Init the bare repos + per-repo credential config for this namespace.
|
||||
script = git_gate_render_provision(bottle_id, plan.upstreams)
|
||||
_exec(gateway, ["sh", "-c", script])
|
||||
|
||||
|
||||
def deprovision_git_gate(gateway: str, bottle_id: str) -> None:
|
||||
"""Remove a bottle's repos + creds from the gateway on teardown. Idempotent
|
||||
— an already-absent namespace is a clean no-op (best effort; a stray dir
|
||||
can't leak, since attribution is by source IP and the bottle is gone)."""
|
||||
_require_safe(bottle_id)
|
||||
run_docker([
|
||||
"docker", "exec", gateway, "rm", "-rf",
|
||||
f"/git/{bottle_id}", _creds_dir(bottle_id),
|
||||
])
|
||||
|
||||
|
||||
__all__ = ["provision_git_gate", "deprovision_git_gate", "GatewayProvisionError"]
|
||||
@@ -46,6 +46,7 @@ from .git_gate_render import (
|
||||
git_gate_known_hosts_line,
|
||||
git_gate_render_access_hook,
|
||||
git_gate_render_entrypoint,
|
||||
git_gate_render_provision,
|
||||
git_gate_render_gitconfig,
|
||||
git_gate_render_hook,
|
||||
git_gate_upstreams_for_bottle,
|
||||
@@ -155,6 +156,7 @@ __all__ = [
|
||||
"git_gate_render_gitconfig",
|
||||
"git_gate_known_hosts_line",
|
||||
"git_gate_render_entrypoint",
|
||||
"git_gate_render_provision",
|
||||
"git_gate_render_hook",
|
||||
"git_gate_render_access_hook",
|
||||
"provision_git_gate_dynamic_keys",
|
||||
|
||||
@@ -9,6 +9,7 @@ own; `git_gate` re-exports these names for API stability."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import shlex
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
@@ -125,22 +126,18 @@ def git_gate_known_hosts_line(host: str, port: str, key: str) -> str:
|
||||
return f"{target} {key}\n"
|
||||
|
||||
|
||||
def git_gate_render_entrypoint(upstreams: tuple[GitGateUpstream, ...]) -> str:
|
||||
"""Posix-sh entrypoint. One `init_repo` call per upstream, then
|
||||
`exec git daemon`. The function reads
|
||||
`/git-gate/creds/<name>-{key,known_hosts}` (bind-mounted into
|
||||
the bundle by the renderer) and wires them into each bare repo's
|
||||
config; the access-hook + pre-receive hook pick those paths up
|
||||
at fetch / push time."""
|
||||
lines = [
|
||||
"#!/bin/sh",
|
||||
"set -eu",
|
||||
"",
|
||||
def _git_gate_init_repo_fn(repo_root: str, creds_dir: str) -> list[str]:
|
||||
"""The `init_repo` shell function, parameterized by the bare-repo root
|
||||
and the per-bottle creds dir. Single source of the credential-wiring
|
||||
logic, shared by the single-tenant daemon entrypoint (`/git`,
|
||||
`/git-gate/creds`) and the consolidated per-bottle provisioning
|
||||
(`/git/<bottle_id>`, `/git-gate/creds/<bottle_id>`)."""
|
||||
return [
|
||||
"init_repo() {",
|
||||
" name=$1",
|
||||
" upstream_url=$2",
|
||||
" keyfile=/git-gate/creds/${name}-key",
|
||||
" hostsfile=/git-gate/creds/${name}-known_hosts",
|
||||
f" keyfile={creds_dir}/${{name}}-key",
|
||||
f" hostsfile={creds_dir}/${{name}}-known_hosts",
|
||||
"",
|
||||
# `|| true`: PRD 0018 chunk 3+ bind-mounts these RO from the
|
||||
# host, so chmod-syscalls fail with EROFS. The files already
|
||||
@@ -153,14 +150,14 @@ def git_gate_render_entrypoint(upstreams: tuple[GitGateUpstream, ...]) -> str:
|
||||
" chmod 600 \"$hostsfile\" 2>/dev/null || true",
|
||||
" fi",
|
||||
"",
|
||||
" repo=/git/${name}.git",
|
||||
f" repo={repo_root}/${{name}}.git",
|
||||
" if [ ! -d \"$repo\" ]; then",
|
||||
" git init --bare \"$repo\" >/dev/null",
|
||||
# --mirror=fetch sets remote.origin.fetch = +refs/*:refs/* so",
|
||||
# a later `git fetch origin` mirrors the upstream's full ref",
|
||||
# graph (heads, tags, notes) into the bare repo at canonical",
|
||||
# paths. It does NOT set remote.origin.mirror=true, so an",
|
||||
# explicit `git push origin <ref>:<ref>` still pushes one ref.",
|
||||
# --mirror=fetch sets remote.origin.fetch = +refs/*:refs/* so a later
|
||||
# `git fetch origin` mirrors the upstream's full ref graph (heads,
|
||||
# tags, notes) into the bare repo at canonical paths. It does NOT set
|
||||
# remote.origin.mirror=true, so an explicit `git push origin
|
||||
# <ref>:<ref>` still pushes one ref.
|
||||
" git -C \"$repo\" remote add --mirror=fetch origin \"$upstream_url\"",
|
||||
" fi",
|
||||
" git -C \"$repo\" config git-gate.identityFile \"$keyfile\"",
|
||||
@@ -170,9 +167,19 @@ def git_gate_render_entrypoint(upstreams: tuple[GitGateUpstream, ...]) -> str:
|
||||
" git -C \"$repo\" config http.receivepack true",
|
||||
" install -m 755 /etc/git-gate/pre-receive \"$repo/hooks/pre-receive\"",
|
||||
"}",
|
||||
"",
|
||||
"mkdir -p /git",
|
||||
]
|
||||
|
||||
|
||||
def git_gate_render_entrypoint(upstreams: tuple[GitGateUpstream, ...]) -> str:
|
||||
"""Posix-sh entrypoint. One `init_repo` call per upstream, then
|
||||
`exec git daemon`. The function reads
|
||||
`/git-gate/creds/<name>-{key,known_hosts}` (bind-mounted into
|
||||
the bundle by the renderer) and wires them into each bare repo's
|
||||
config; the access-hook + pre-receive hook pick those paths up
|
||||
at fetch / push time."""
|
||||
lines = ["#!/bin/sh", "set -eu", ""]
|
||||
lines += _git_gate_init_repo_fn("/git", "/git-gate/creds")
|
||||
lines += ["", "mkdir -p /git"]
|
||||
for u in upstreams:
|
||||
lines.append(f"init_repo {shlex.quote(u.name)} {shlex.quote(u.upstream_url)}")
|
||||
lines.extend([
|
||||
@@ -190,6 +197,35 @@ def git_gate_render_entrypoint(upstreams: tuple[GitGateUpstream, ...]) -> str:
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
# A bottle id namespaces the consolidated gateway's repo + creds dirs; it is
|
||||
# embedded unquoted in the provisioning script, so restrict it to a shell- and
|
||||
# path-safe alphabet (registry ids are token_hex — this is defense in depth).
|
||||
_SAFE_BOTTLE_ID = re.compile(r"[A-Za-z0-9_-]+")
|
||||
|
||||
|
||||
def git_gate_render_provision(
|
||||
bottle_id: str, upstreams: tuple[GitGateUpstream, ...],
|
||||
) -> str:
|
||||
"""Posix-sh script that provisions ONE bottle's bare repos into the
|
||||
consolidated gateway (PRD 0070), under `/git/<bottle_id>/` with creds
|
||||
read from `/git-gate/creds/<bottle_id>/`. Init-only — no `git daemon`,
|
||||
since the shared gateway already serves every bottle; run inside the
|
||||
running gateway when the bottle is registered.
|
||||
|
||||
Isolating each bottle's repo root and creds dir by id is what keeps one
|
||||
bottle's push credentials out of another's repos on the shared gateway."""
|
||||
if not _SAFE_BOTTLE_ID.fullmatch(bottle_id):
|
||||
raise ValueError(f"git-gate: unsafe bottle id {bottle_id!r}")
|
||||
repo_root = f"/git/{bottle_id}"
|
||||
creds_dir = f"/git-gate/creds/{bottle_id}"
|
||||
lines = ["#!/bin/sh", "set -eu", ""]
|
||||
lines += _git_gate_init_repo_fn(repo_root, creds_dir)
|
||||
lines += ["", f"mkdir -p {shlex.quote(repo_root)}"]
|
||||
for u in upstreams:
|
||||
lines.append(f"init_repo {shlex.quote(u.name)} {shlex.quote(u.upstream_url)}")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def git_gate_render_hook() -> str:
|
||||
"""The shared pre-receive hook: gitleaks-scan all incoming refs,
|
||||
then forward each accepted ref to the real upstream (`origin`)
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Host-side control-plane client (PRD 0070).
|
||||
|
||||
The launch path talks to the orchestrator over its HTTP control plane to
|
||||
register, re-policy, and tear down bottles — the counterpart to the
|
||||
gateway-side `PolicyResolver` (which only reads `/resolve`). Where
|
||||
`PolicyResolver` is fail-closed and lives in the untrusted data plane, this
|
||||
is the trusted control-plane caller: a non-success response is an error the
|
||||
launch path must surface, not silently swallow.
|
||||
|
||||
Stdlib-only, so the CLI can drive the orchestrator without any dependency.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
|
||||
DEFAULT_TIMEOUT_SECONDS = 5.0
|
||||
|
||||
|
||||
class OrchestratorClientError(RuntimeError):
|
||||
"""A control-plane call failed (unreachable, or an unexpected status)."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RegisteredBottle:
|
||||
"""What `POST /bottles` returns: the minted bottle id and the per-bottle
|
||||
identity token the agent presents for app-layer attribution."""
|
||||
|
||||
bottle_id: str
|
||||
identity_token: str
|
||||
|
||||
|
||||
class OrchestratorClient:
|
||||
"""Trusted host-side client for the orchestrator control plane."""
|
||||
|
||||
def __init__(self, base_url: str, *, timeout: float = DEFAULT_TIMEOUT_SECONDS) -> None:
|
||||
self._base = base_url.rstrip("/")
|
||||
self._timeout = timeout
|
||||
|
||||
def _request(
|
||||
self, method: str, path: str, body: dict[str, object] | None = None,
|
||||
) -> tuple[int, dict[str, object]]:
|
||||
"""Send one request; return `(status, payload)`. Raises
|
||||
`OrchestratorClientError` only when the orchestrator can't be reached
|
||||
or returns malformed data — HTTP *status* codes are returned so
|
||||
callers can treat 404 as a meaningful "no such bottle"."""
|
||||
data = json.dumps(body).encode() if body is not None else None
|
||||
headers = {"Content-Type": "application/json"} if data is not None else {}
|
||||
req = urllib.request.Request(
|
||||
f"{self._base}{path}", data=data, method=method, headers=headers,
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=self._timeout) as resp:
|
||||
raw = resp.read()
|
||||
payload = json.loads(raw) if raw else {}
|
||||
return resp.status, payload if isinstance(payload, dict) else {}
|
||||
except urllib.error.HTTPError as e:
|
||||
# A structured error response still carries a usable status.
|
||||
try:
|
||||
payload = json.loads(e.read() or b"{}")
|
||||
except (ValueError, OSError):
|
||||
payload = {}
|
||||
return e.code, payload if isinstance(payload, dict) else {}
|
||||
except (urllib.error.URLError, TimeoutError, OSError, ValueError) as e:
|
||||
raise OrchestratorClientError(f"{method} {path}: {e}") from e
|
||||
|
||||
def _ok(self, method: str, path: str, body: dict[str, object] | None = None) -> dict[str, object]:
|
||||
"""`_request` that requires a 2xx, raising otherwise."""
|
||||
status, payload = self._request(method, path, body)
|
||||
if not 200 <= status < 300:
|
||||
detail = payload.get("error", "")
|
||||
raise OrchestratorClientError(f"{method} {path}: HTTP {status} {detail}".rstrip())
|
||||
return payload
|
||||
|
||||
def health(self) -> bool:
|
||||
"""True iff the control plane answers `GET /health` with 200."""
|
||||
try:
|
||||
status, _ = self._request("GET", "/health")
|
||||
except OrchestratorClientError:
|
||||
return False
|
||||
return status == 200
|
||||
|
||||
def register_bottle(
|
||||
self,
|
||||
source_ip: str,
|
||||
*,
|
||||
image_ref: str = "",
|
||||
metadata: str = "",
|
||||
policy: str = "",
|
||||
) -> RegisteredBottle:
|
||||
"""Register a bottle and broker its launch (`POST /bottles`). Returns
|
||||
its minted id + identity token."""
|
||||
payload = self._ok("POST", "/bottles", {
|
||||
"source_ip": source_ip,
|
||||
"image_ref": image_ref,
|
||||
"metadata": metadata,
|
||||
"policy": policy,
|
||||
})
|
||||
bottle_id = payload.get("bottle_id")
|
||||
token = payload.get("identity_token")
|
||||
if not isinstance(bottle_id, str) or not isinstance(token, str):
|
||||
raise OrchestratorClientError("register: response missing bottle_id/identity_token")
|
||||
return RegisteredBottle(bottle_id=bottle_id, identity_token=token)
|
||||
|
||||
def teardown_bottle(self, bottle_id: str) -> bool:
|
||||
"""Tear a bottle down (`DELETE /bottles/<id>`). False if the
|
||||
orchestrator didn't know it (404) — idempotent for cleanup paths."""
|
||||
status, _ = self._request("DELETE", f"/bottles/{bottle_id}")
|
||||
if status == 404:
|
||||
return False
|
||||
if not 200 <= status < 300:
|
||||
raise OrchestratorClientError(f"teardown {bottle_id}: HTTP {status}")
|
||||
return True
|
||||
|
||||
def set_policy(self, bottle_id: str, policy: str) -> bool:
|
||||
"""Live-reload a bottle's policy (`PUT /bottles/<id>/policy`). False on
|
||||
404 (unknown bottle)."""
|
||||
status, _ = self._request("PUT", f"/bottles/{bottle_id}/policy", {"policy": policy})
|
||||
if status == 404:
|
||||
return False
|
||||
if not 200 <= status < 300:
|
||||
raise OrchestratorClientError(f"set_policy {bottle_id}: HTTP {status}")
|
||||
return True
|
||||
|
||||
def list_bottles(self) -> list[dict[str, object]]:
|
||||
"""Every registered bottle's redacted record (`GET /bottles`)."""
|
||||
payload = self._ok("GET", "/bottles")
|
||||
bottles = payload.get("bottles")
|
||||
return bottles if isinstance(bottles, list) else []
|
||||
|
||||
|
||||
__all__ = [
|
||||
"OrchestratorClient",
|
||||
"OrchestratorClientError",
|
||||
"RegisteredBottle",
|
||||
"DEFAULT_TIMEOUT_SECONDS",
|
||||
]
|
||||
@@ -25,6 +25,19 @@ from ..docker_cmd import run_docker
|
||||
|
||||
GATEWAY_NAME = "bot-bottle-orch-gateway"
|
||||
GATEWAY_LABEL = "bot-bottle-orch-gateway=1"
|
||||
# The single user-defined network the gateway and every agent bottle share.
|
||||
# Agents attach here with a pinned IP and reach the gateway's egress /
|
||||
# git-http / supervise ports by its address — no host port publishing, and
|
||||
# the source IP the gateway attributes by is the address on this network.
|
||||
GATEWAY_NETWORK = "bot-bottle-gateway"
|
||||
|
||||
# mitmproxy's CA dir in the bundle. A persistent named volume here keeps the
|
||||
# gateway's self-generated CA STABLE across container recreation — every agent
|
||||
# installs this one CA to trust the shared gateway's TLS interception, so it
|
||||
# must not rotate when the gateway restarts.
|
||||
MITMPROXY_HOME = "/home/mitmproxy/.mitmproxy"
|
||||
GATEWAY_CA_VOLUME = "bot-bottle-gateway-mitmproxy"
|
||||
GATEWAY_CA_CERT = f"{MITMPROXY_HOME}/mitmproxy-ca-cert.pem"
|
||||
|
||||
# The real sidecar-bundle image + its Dockerfile. Kept as a local constant
|
||||
# rather than imported from backend.docker.sidecar_bundle, which would drag
|
||||
@@ -77,11 +90,13 @@ class DockerGateway(Gateway):
|
||||
image_ref: str = GATEWAY_IMAGE,
|
||||
*,
|
||||
name: str = GATEWAY_NAME,
|
||||
network: str = GATEWAY_NETWORK,
|
||||
build_context: Path | None = None,
|
||||
dockerfile: str | None = GATEWAY_DOCKERFILE,
|
||||
) -> None:
|
||||
self.image_ref = image_ref
|
||||
self.name = name
|
||||
self.network = network
|
||||
self._build_context = build_context or _REPO_ROOT
|
||||
self._dockerfile = dockerfile
|
||||
|
||||
@@ -112,9 +127,22 @@ class DockerGateway(Gateway):
|
||||
])
|
||||
return self.name in proc.stdout.split()
|
||||
|
||||
def _ensure_network(self) -> None:
|
||||
"""Create the shared gateway network if it doesn't exist. Idempotent —
|
||||
a concurrent create loses harmlessly (the loser sees 'already exists').
|
||||
Docker picks the subnet; the launcher reads it back to allocate IPs."""
|
||||
if run_docker(["docker", "network", "inspect", self.network]).returncode == 0:
|
||||
return
|
||||
proc = run_docker(["docker", "network", "create", self.network])
|
||||
if proc.returncode != 0 and "already exists" not in proc.stderr:
|
||||
raise GatewayError(
|
||||
f"gateway network {self.network} failed to create: {proc.stderr.strip()}"
|
||||
)
|
||||
|
||||
def ensure_running(self) -> None:
|
||||
if self.is_running():
|
||||
return
|
||||
self._ensure_network()
|
||||
# Clear any stale (stopped) container holding the fixed name, then
|
||||
# start fresh. `rm --force` on an absent name is a tolerated no-op.
|
||||
run_docker(["docker", "rm", "--force", self.name])
|
||||
@@ -122,11 +150,27 @@ class DockerGateway(Gateway):
|
||||
"docker", "run", "--detach",
|
||||
"--name", self.name,
|
||||
"--label", GATEWAY_LABEL,
|
||||
"--network", self.network,
|
||||
# Persist the self-generated CA so it survives restarts (agents
|
||||
# trust it) — see GATEWAY_CA_VOLUME.
|
||||
"--volume", f"{GATEWAY_CA_VOLUME}:{MITMPROXY_HOME}",
|
||||
self.image_ref,
|
||||
])
|
||||
if proc.returncode != 0:
|
||||
raise GatewayError(f"gateway failed to start: {proc.stderr.strip()}")
|
||||
|
||||
def ca_cert_pem(self) -> str:
|
||||
"""The gateway's CA certificate (PEM) that agents install to trust its
|
||||
TLS interception. Read from the running container; raises if the
|
||||
gateway hasn't generated it yet (it's created on first mitmproxy
|
||||
start)."""
|
||||
proc = run_docker(["docker", "exec", self.name, "cat", GATEWAY_CA_CERT])
|
||||
if proc.returncode != 0 or not proc.stdout.strip():
|
||||
raise GatewayError(
|
||||
f"gateway CA cert not available: {proc.stderr.strip() or 'empty'}"
|
||||
)
|
||||
return proc.stdout
|
||||
|
||||
def stop(self) -> None:
|
||||
proc = run_docker(["docker", "rm", "--force", self.name])
|
||||
if proc.returncode != 0 and "No such container" not in proc.stderr:
|
||||
@@ -135,5 +179,6 @@ class DockerGateway(Gateway):
|
||||
|
||||
__all__ = [
|
||||
"Gateway", "DockerGateway", "GatewayError",
|
||||
"GATEWAY_NAME", "GATEWAY_LABEL", "GATEWAY_IMAGE",
|
||||
"GATEWAY_NAME", "GATEWAY_LABEL", "GATEWAY_IMAGE", "GATEWAY_NETWORK",
|
||||
"GATEWAY_CA_VOLUME", "GATEWAY_CA_CERT",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Consolidated registration inputs (PRD 0070, docker slice).
|
||||
|
||||
Bridges the existing per-bottle `prepare` output to the consolidated
|
||||
registry: turns a prepared bottle's egress plan into the backend-neutral
|
||||
inputs `Orchestrator.launch_bottle` takes — the egress **policy** blob and
|
||||
launch **metadata**.
|
||||
|
||||
The policy blob is the exact routes YAML the per-bottle egress sidecar used
|
||||
to read from a file; in the consolidated model the multi-tenant gateway's
|
||||
`PolicyResolver` fetches it from the registry per request (keyed by source
|
||||
IP) instead. Same render, so consolidated and single-tenant egress apply
|
||||
byte-identical policy — a bottle's allow-list doesn't change when it moves
|
||||
onto the shared gateway.
|
||||
|
||||
Host-side glue (imports `bot_bottle.egress`), used by the launch path — not
|
||||
by the lean orchestrator control-plane process itself.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
|
||||
from ..egress import EgressPlan, egress_render_routes
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RegistrationInputs:
|
||||
"""What `Orchestrator.launch_bottle` needs to register a bottle, derived
|
||||
from its prepared plan. `policy` is served verbatim by the gateway's
|
||||
`/resolve`; `metadata` is opaque forward-compat state — it carries the
|
||||
human slug so the console / supervise can show a name, not just the
|
||||
minted bottle id."""
|
||||
|
||||
policy: str
|
||||
metadata: str
|
||||
|
||||
|
||||
def egress_policy(plan: EgressPlan) -> str:
|
||||
"""The bottle's egress policy blob: the routes YAML the gateway serves
|
||||
and the addon parses with `load_config`. Identical to the per-bottle
|
||||
`routes.yaml` render, so the consolidated path applies the same
|
||||
allow-list."""
|
||||
return egress_render_routes(plan.routes, log=plan.log)
|
||||
|
||||
|
||||
def registration_inputs(plan: EgressPlan) -> RegistrationInputs:
|
||||
"""Assemble the orchestrator registration inputs from a prepared egress
|
||||
plan. `metadata` records the slug so the shared registry can map a minted
|
||||
bottle id back to its human name."""
|
||||
return RegistrationInputs(
|
||||
policy=egress_policy(plan),
|
||||
metadata=json.dumps({"slug": plan.slug}),
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["RegistrationInputs", "egress_policy", "registration_inputs"]
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Unit: consolidated launch sequence — compose the orchestrator primitives (PRD 0070)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
from bot_bottle.backend.docker.consolidated_launch import (
|
||||
launch_consolidated,
|
||||
teardown_consolidated,
|
||||
)
|
||||
from bot_bottle.egress import EgressPlan, EgressRoute
|
||||
from bot_bottle.git_gate import GitGatePlan
|
||||
from bot_bottle.orchestrator.client import RegisteredBottle
|
||||
|
||||
_MOD = "bot_bottle.backend.docker.consolidated_launch"
|
||||
|
||||
|
||||
def _egress_plan() -> EgressPlan:
|
||||
return EgressPlan(
|
||||
slug="demo", routes_path=Path("/x"), routes=(EgressRoute(host="api.example.com"),),
|
||||
token_env_map={},
|
||||
)
|
||||
|
||||
|
||||
def _git_plan() -> GitGatePlan:
|
||||
return GitGatePlan(
|
||||
slug="demo", entrypoint_script=Path(), hook_script=Path(),
|
||||
access_hook_script=Path(), upstreams=(),
|
||||
)
|
||||
|
||||
|
||||
def _client(*, bottles: list[dict[str, object]] | None = None) -> Mock:
|
||||
c = Mock()
|
||||
c.list_bottles.return_value = bottles or []
|
||||
c.register_bottle.return_value = RegisteredBottle("b1", "tok")
|
||||
return c
|
||||
|
||||
|
||||
class TestLaunchConsolidated(unittest.TestCase):
|
||||
def _run(self, client: Mock, provision: Mock | None = None):
|
||||
process = MagicMock()
|
||||
process.ensure_running.return_value = "http://orch:8080"
|
||||
with patch(f"{_MOD}._network_cidr", return_value="172.18.0.0/16"), \
|
||||
patch(f"{_MOD}._container_ip", return_value="172.18.0.2"), \
|
||||
patch(f"{_MOD}.OrchestratorClient", return_value=client), \
|
||||
patch(f"{_MOD}.provision_git_gate", provision or Mock()):
|
||||
return launch_consolidated(_egress_plan(), _git_plan(), process=process)
|
||||
|
||||
def test_allocates_ip_registers_and_provisions(self) -> None:
|
||||
client = _client()
|
||||
provision = Mock()
|
||||
ctx = self._run(client, provision)
|
||||
# .1 is the router, .2 is the gateway → first bottle gets .3.
|
||||
self.assertEqual("172.18.0.3", ctx.source_ip)
|
||||
self.assertEqual("172.18.0.2", ctx.gateway_ip)
|
||||
self.assertEqual("b1", ctx.bottle_id)
|
||||
self.assertEqual("tok", ctx.identity_token)
|
||||
self.assertEqual("http://orch:8080", ctx.orchestrator_url)
|
||||
# Registered with the source IP + the egress policy blob.
|
||||
kwargs = client.register_bottle.call_args
|
||||
self.assertEqual("172.18.0.3", kwargs.args[0])
|
||||
self.assertIn("api.example.com", kwargs.kwargs["policy"])
|
||||
provision.assert_called_once()
|
||||
|
||||
def test_skips_gateway_and_live_bottle_addresses(self) -> None:
|
||||
client = _client(bottles=[{"source_ip": "172.18.0.3"}])
|
||||
ctx = self._run(client)
|
||||
self.assertEqual("172.18.0.4", ctx.source_ip) # .2 gw, .3 taken → .4
|
||||
|
||||
def test_provision_failure_rolls_back_registration(self) -> None:
|
||||
client = _client()
|
||||
provision = Mock(side_effect=RuntimeError("provision boom"))
|
||||
with self.assertRaises(RuntimeError):
|
||||
self._run(client, provision)
|
||||
client.teardown_bottle.assert_called_once_with("b1") # no orphan left
|
||||
|
||||
|
||||
class TestTeardownConsolidated(unittest.TestCase):
|
||||
def test_deregisters_and_deprovisions(self) -> None:
|
||||
client = Mock()
|
||||
with patch(f"{_MOD}.OrchestratorClient", return_value=client), \
|
||||
patch(f"{_MOD}.deprovision_git_gate") as deprov:
|
||||
teardown_consolidated("b1", orchestrator_url="http://orch:8080")
|
||||
client.teardown_bottle.assert_called_once_with("b1")
|
||||
deprov.assert_called_once()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Unit: shared-gateway source-IP allocation (PRD 0070)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from bot_bottle.backend.docker.gateway_net import NoFreeAddressError, next_free_ip
|
||||
|
||||
|
||||
class TestNextFreeIp(unittest.TestCase):
|
||||
def test_skips_router_and_returns_first_host(self) -> None:
|
||||
# .1 is docker's router; the first assignable address is .2.
|
||||
self.assertEqual("172.18.0.2", next_free_ip("172.18.0.0/16", []))
|
||||
|
||||
def test_skips_taken_addresses(self) -> None:
|
||||
# Gateway container holds .2, a live bottle holds .3 -> next is .4.
|
||||
self.assertEqual(
|
||||
"172.18.0.4", next_free_ip("172.18.0.0/16", ["172.18.0.2", "172.18.0.3"]),
|
||||
)
|
||||
|
||||
def test_taken_order_does_not_matter(self) -> None:
|
||||
self.assertEqual(
|
||||
"172.18.0.2", next_free_ip("172.18.0.0/16", ["172.18.0.3", "172.18.0.5"]),
|
||||
)
|
||||
|
||||
def test_allocation_is_deterministic(self) -> None:
|
||||
taken = ["172.18.0.2"]
|
||||
self.assertEqual(next_free_ip("172.18.0.0/16", taken),
|
||||
next_free_ip("172.18.0.0/16", taken))
|
||||
|
||||
def test_raises_when_subnet_exhausted(self) -> None:
|
||||
# /30: hosts are .1 (router, reserved) and .2; taking .2 leaves none.
|
||||
with self.assertRaises(NoFreeAddressError):
|
||||
next_free_ip("10.9.9.0/30", ["10.9.9.2"])
|
||||
|
||||
def test_accepts_host_bits_set_cidr(self) -> None:
|
||||
# A container's inspected address arrives as e.g. 172.18.0.2/16.
|
||||
self.assertEqual("172.18.0.2", next_free_ip("172.18.0.5/16", []))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Unit: git-gate provisioning into the running shared gateway (PRD 0070)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from bot_bottle.backend.docker.gateway_provision import (
|
||||
GatewayProvisionError,
|
||||
deprovision_git_gate,
|
||||
provision_git_gate,
|
||||
)
|
||||
from bot_bottle.git_gate import GitGatePlan, GitGateUpstream
|
||||
|
||||
_RUN = "bot_bottle.backend.docker.gateway_provision.run_docker"
|
||||
|
||||
|
||||
def _proc(returncode: int = 0, stderr: str = "") -> Mock:
|
||||
return Mock(returncode=returncode, stderr=stderr)
|
||||
|
||||
|
||||
def _recorder(calls: list[list[str]]):
|
||||
"""A run_docker side_effect that records argv and returns success."""
|
||||
def fake(argv: list[str]) -> Mock:
|
||||
calls.append(argv)
|
||||
return _proc()
|
||||
return fake
|
||||
|
||||
|
||||
def _plan(*upstreams: GitGateUpstream) -> GitGatePlan:
|
||||
return GitGatePlan(
|
||||
slug="demo",
|
||||
entrypoint_script=Path(),
|
||||
hook_script=Path(),
|
||||
access_hook_script=Path(),
|
||||
upstreams=tuple(upstreams),
|
||||
)
|
||||
|
||||
|
||||
def _up(name: str, *, key: str = "/host/keys/id", known_hosts: str = "") -> GitGateUpstream:
|
||||
return GitGateUpstream(
|
||||
name=name,
|
||||
upstream_url=f"ssh://git@github.com/x/{name}.git",
|
||||
upstream_host="github.com",
|
||||
upstream_port="22",
|
||||
identity_file=key,
|
||||
known_host_key="",
|
||||
known_hosts_file=Path(known_hosts) if known_hosts else Path(),
|
||||
)
|
||||
|
||||
|
||||
class TestProvisionGitGate(unittest.TestCase):
|
||||
def test_copies_creds_and_runs_namespaced_init(self) -> None:
|
||||
calls: list[list[str]] = []
|
||||
with patch(_RUN, side_effect=_recorder(calls)):
|
||||
provision_git_gate("gw", "bottle1", _plan(_up("foo", known_hosts="/host/kh")))
|
||||
|
||||
cps = [c for c in calls if c[:2] == ["docker", "cp"]]
|
||||
self.assertIn(["docker", "cp", "/host/keys/id", "gw:/git-gate/creds/bottle1/foo-key"], cps)
|
||||
self.assertIn(
|
||||
["docker", "cp", "/host/kh", "gw:/git-gate/creds/bottle1/foo-known_hosts"], cps,
|
||||
)
|
||||
# The init script runs in the gateway, namespaced under the bottle id.
|
||||
exec_scripts = [c for c in calls if c[:3] == ["docker", "exec", "gw"] and c[3] == "sh"]
|
||||
self.assertEqual(1, len(exec_scripts))
|
||||
self.assertIn("repo=/git/bottle1/${name}.git", exec_scripts[0][-1])
|
||||
|
||||
def test_omits_known_hosts_copy_when_absent(self) -> None:
|
||||
calls: list[list[str]] = []
|
||||
with patch(_RUN, side_effect=_recorder(calls)):
|
||||
provision_git_gate("gw", "b1", _plan(_up("foo"))) # no known_hosts
|
||||
cps = [c for c in calls if c[:2] == ["docker", "cp"]]
|
||||
self.assertEqual(1, len(cps)) # only the key, not known_hosts
|
||||
self.assertTrue(cps[0][3].endswith("/foo-key"))
|
||||
|
||||
def test_no_upstreams_is_noop(self) -> None:
|
||||
with patch(_RUN) as m:
|
||||
provision_git_gate("gw", "b1", _plan())
|
||||
m.assert_not_called()
|
||||
|
||||
def test_raises_on_docker_failure(self) -> None:
|
||||
with patch(_RUN, return_value=_proc(returncode=1, stderr="boom")):
|
||||
with self.assertRaises(GatewayProvisionError):
|
||||
provision_git_gate("gw", "b1", _plan(_up("foo")))
|
||||
|
||||
def test_rejects_unsafe_bottle_id_before_any_docker(self) -> None:
|
||||
with patch(_RUN) as m:
|
||||
with self.assertRaises(GatewayProvisionError):
|
||||
provision_git_gate("gw", "../etc", _plan(_up("foo")))
|
||||
m.assert_not_called() # rejected before a single docker call
|
||||
|
||||
|
||||
class TestDeprovision(unittest.TestCase):
|
||||
def test_removes_repo_and_creds(self) -> None:
|
||||
with patch(_RUN, return_value=_proc()) as m:
|
||||
deprovision_git_gate("gw", "b1")
|
||||
argv = m.call_args.args[0]
|
||||
self.assertEqual(["docker", "exec", "gw", "rm", "-rf"], argv[:5])
|
||||
self.assertIn("/git/b1", argv)
|
||||
self.assertIn("/git-gate/creds/b1", argv)
|
||||
|
||||
def test_rejects_unsafe_bottle_id(self) -> None:
|
||||
with patch(_RUN) as m:
|
||||
with self.assertRaises(GatewayProvisionError):
|
||||
deprovision_git_gate("gw", "a/b")
|
||||
m.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Unit: consolidated per-bottle git-gate provisioning render (PRD 0070)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from bot_bottle.git_gate_render import (
|
||||
GitGateUpstream,
|
||||
git_gate_render_entrypoint,
|
||||
git_gate_render_provision,
|
||||
)
|
||||
|
||||
|
||||
def _ups(*names: str) -> tuple[GitGateUpstream, ...]:
|
||||
return tuple(
|
||||
GitGateUpstream(
|
||||
name=n,
|
||||
upstream_url=f"ssh://git@github.com/x/{n}.git",
|
||||
upstream_host="github.com",
|
||||
upstream_port="22",
|
||||
identity_file="",
|
||||
known_host_key="",
|
||||
)
|
||||
for n in names
|
||||
)
|
||||
|
||||
|
||||
class TestProvisionRender(unittest.TestCase):
|
||||
def test_namespaces_repos_and_creds_by_bottle_id(self) -> None:
|
||||
script = git_gate_render_provision("bottleab12", _ups("foo"))
|
||||
self.assertIn("repo=/git/bottleab12/${name}.git", script)
|
||||
self.assertIn("keyfile=/git-gate/creds/bottleab12/${name}-key", script)
|
||||
self.assertIn("mkdir -p /git/bottleab12", script)
|
||||
|
||||
def test_one_init_repo_call_per_upstream(self) -> None:
|
||||
script = git_gate_render_provision("b1", _ups("foo", "bar"))
|
||||
calls = [l for l in script.splitlines() if l.startswith("init_repo ")]
|
||||
self.assertEqual(2, len(calls))
|
||||
|
||||
def test_provision_does_not_start_the_daemon(self) -> None:
|
||||
# The shared gateway already serves; provisioning is init-only.
|
||||
self.assertNotIn("git daemon", git_gate_render_provision("b1", _ups("foo")))
|
||||
|
||||
def test_installs_pre_receive_hook(self) -> None:
|
||||
script = git_gate_render_provision("b1", _ups("foo"))
|
||||
self.assertIn("install -m 755 /etc/git-gate/pre-receive", script)
|
||||
|
||||
def test_rejects_unsafe_bottle_id(self) -> None:
|
||||
for bad in ("../etc", "a/b", "a b", "a;rm", ""):
|
||||
with self.assertRaises(ValueError):
|
||||
git_gate_render_provision(bad, _ups("foo"))
|
||||
|
||||
|
||||
class TestEntrypointUnchanged(unittest.TestCase):
|
||||
"""The shared `_git_gate_init_repo_fn` refactor must not alter the
|
||||
single-tenant daemon entrypoint's output."""
|
||||
|
||||
def test_entrypoint_still_single_tenant_flat(self) -> None:
|
||||
script = git_gate_render_entrypoint(_ups("foo"))
|
||||
self.assertIn("repo=/git/${name}.git", script) # flat, not namespaced
|
||||
self.assertIn("keyfile=/git-gate/creds/${name}-key", script)
|
||||
self.assertIn("--base-path=/git", script)
|
||||
self.assertIn("exec git daemon", script)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Unit: host-side orchestrator control-plane client (PRD 0070). HTTP mocked."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import unittest
|
||||
import urllib.error
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from bot_bottle.orchestrator.client import (
|
||||
OrchestratorClient,
|
||||
OrchestratorClientError,
|
||||
RegisteredBottle,
|
||||
)
|
||||
|
||||
_URLOPEN = "bot_bottle.orchestrator.client.urllib.request.urlopen"
|
||||
|
||||
|
||||
def _resp(status: int, payload: object) -> MagicMock:
|
||||
m = MagicMock()
|
||||
inner = m.__enter__.return_value
|
||||
inner.status = status
|
||||
inner.read.return_value = json.dumps(payload).encode()
|
||||
return m
|
||||
|
||||
|
||||
def _http_error(code: int, payload: object = None) -> urllib.error.HTTPError:
|
||||
del payload # the client tolerates an empty error body; keep the signature
|
||||
return urllib.error.HTTPError("http://x", code, "err", {}, None) # type: ignore[arg-type]
|
||||
|
||||
|
||||
class TestRegister(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.c = OrchestratorClient("http://orch:8080")
|
||||
|
||||
def test_register_returns_id_and_token(self) -> None:
|
||||
with patch(_URLOPEN, return_value=_resp(201, {"bottle_id": "b1", "identity_token": "tok"})):
|
||||
got = self.c.register_bottle("10.0.0.2", policy="routes: []\n", metadata="{}")
|
||||
self.assertEqual(RegisteredBottle("b1", "tok"), got)
|
||||
|
||||
def test_register_posts_source_ip_and_policy(self) -> None:
|
||||
with patch(_URLOPEN, return_value=_resp(201, {"bottle_id": "b", "identity_token": "t"})) as m:
|
||||
self.c.register_bottle("10.0.0.9", policy="P", metadata="M", image_ref="img")
|
||||
sent = json.loads(m.call_args.args[0].data)
|
||||
self.assertEqual("10.0.0.9", sent["source_ip"])
|
||||
self.assertEqual("P", sent["policy"])
|
||||
self.assertEqual("img", sent["image_ref"])
|
||||
|
||||
def test_register_missing_fields_raises(self) -> None:
|
||||
with patch(_URLOPEN, return_value=_resp(201, {"bottle_id": "b1"})):
|
||||
with self.assertRaises(OrchestratorClientError):
|
||||
self.c.register_bottle("10.0.0.2")
|
||||
|
||||
def test_register_non_2xx_raises(self) -> None:
|
||||
with patch(_URLOPEN, side_effect=_http_error(400, {"error": "bad"})):
|
||||
with self.assertRaises(OrchestratorClientError):
|
||||
self.c.register_bottle("")
|
||||
|
||||
|
||||
class TestTeardown(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.c = OrchestratorClient("http://orch:8080")
|
||||
|
||||
def test_teardown_true_on_success(self) -> None:
|
||||
with patch(_URLOPEN, return_value=_resp(200, {"torn_down": True})):
|
||||
self.assertTrue(self.c.teardown_bottle("b1"))
|
||||
|
||||
def test_teardown_false_on_404(self) -> None:
|
||||
# Idempotent: an already-gone bottle is a clean no-op.
|
||||
with patch(_URLOPEN, side_effect=_http_error(404)):
|
||||
self.assertFalse(self.c.teardown_bottle("gone"))
|
||||
|
||||
def test_teardown_uses_delete(self) -> None:
|
||||
with patch(_URLOPEN, return_value=_resp(200, {"torn_down": True})) as m:
|
||||
self.c.teardown_bottle("b1")
|
||||
self.assertEqual("DELETE", m.call_args.args[0].get_method())
|
||||
|
||||
|
||||
class TestHealthAndPolicy(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.c = OrchestratorClient("http://orch:8080")
|
||||
|
||||
def test_health_true_on_200(self) -> None:
|
||||
with patch(_URLOPEN, return_value=_resp(200, {"status": "ok"})):
|
||||
self.assertTrue(self.c.health())
|
||||
|
||||
def test_health_false_when_unreachable(self) -> None:
|
||||
with patch(_URLOPEN, side_effect=urllib.error.URLError("refused")):
|
||||
self.assertFalse(self.c.health())
|
||||
|
||||
def test_set_policy_false_on_404(self) -> None:
|
||||
with patch(_URLOPEN, side_effect=_http_error(404)):
|
||||
self.assertFalse(self.c.set_policy("gone", "P"))
|
||||
|
||||
def test_set_policy_true_on_success(self) -> None:
|
||||
with patch(_URLOPEN, return_value=_resp(200, {"updated": True})):
|
||||
self.assertTrue(self.c.set_policy("b1", "P"))
|
||||
|
||||
def test_unreachable_raises(self) -> None:
|
||||
with patch(_URLOPEN, side_effect=urllib.error.URLError("refused")):
|
||||
with self.assertRaises(OrchestratorClientError):
|
||||
self.c.register_bottle("10.0.0.2")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -6,11 +6,15 @@ import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from bot_bottle.orchestrator.gateway import (
|
||||
GATEWAY_CA_CERT,
|
||||
GATEWAY_NAME,
|
||||
DockerGateway,
|
||||
GatewayError,
|
||||
)
|
||||
|
||||
|
||||
_CA_PEM = "-----BEGIN CERTIFICATE-----\nMII...\n-----END CERTIFICATE-----\n"
|
||||
|
||||
_RUN_DOCKER = "bot_bottle.orchestrator.gateway.run_docker"
|
||||
|
||||
|
||||
@@ -51,6 +55,46 @@ class TestDockerGateway(unittest.TestCase):
|
||||
self.assertEqual(1, len(runs))
|
||||
self.assertIn(self.sc.name, runs[0])
|
||||
self.assertIn("bot-bottle-sidecars:latest", runs[0])
|
||||
# Runs on the shared gateway network so agents can reach it by IP.
|
||||
self.assertEqual(self.sc.network, runs[0][runs[0].index("--network") + 1])
|
||||
# Persists its CA on a named volume so agents keep trusting it.
|
||||
self.assertTrue(any("mitmproxy" in a for a in runs[0]))
|
||||
|
||||
def test_ensure_running_creates_network_when_missing(self) -> None:
|
||||
calls: list[list[str]] = []
|
||||
|
||||
def fake(argv: list[str]) -> Mock:
|
||||
calls.append(argv)
|
||||
if argv[:3] == ["docker", "network", "inspect"]:
|
||||
return _proc(returncode=1, stderr="No such network")
|
||||
return _proc(stdout="") if argv[:2] == ["docker", "ps"] else _proc()
|
||||
|
||||
with patch(_RUN_DOCKER, side_effect=fake):
|
||||
self.sc.ensure_running()
|
||||
creates = [c for c in calls if c[:3] == ["docker", "network", "create"]]
|
||||
self.assertEqual([["docker", "network", "create", self.sc.network]], creates)
|
||||
|
||||
def test_ca_cert_pem_reads_from_container(self) -> None:
|
||||
with patch(_RUN_DOCKER, return_value=_proc(stdout=_CA_PEM)) as m:
|
||||
self.assertEqual(_CA_PEM, self.sc.ca_cert_pem())
|
||||
argv = m.call_args.args[0]
|
||||
self.assertEqual(["docker", "exec", self.sc.name, "cat", GATEWAY_CA_CERT], argv)
|
||||
|
||||
def test_ca_cert_pem_raises_when_absent(self) -> None:
|
||||
with patch(_RUN_DOCKER, return_value=_proc(returncode=1, stderr="No such file")):
|
||||
with self.assertRaises(GatewayError):
|
||||
self.sc.ca_cert_pem()
|
||||
|
||||
def test_ensure_running_reuses_existing_network(self) -> None:
|
||||
calls: list[list[str]] = []
|
||||
|
||||
def fake(argv: list[str]) -> Mock:
|
||||
calls.append(argv)
|
||||
return _proc(stdout="") if argv[:2] == ["docker", "ps"] else _proc()
|
||||
|
||||
with patch(_RUN_DOCKER, side_effect=fake):
|
||||
self.sc.ensure_running() # network inspect returns 0 → exists
|
||||
self.assertEqual([], [c for c in calls if c[:3] == ["docker", "network", "create"]])
|
||||
|
||||
def test_ensure_running_raises_on_docker_failure(self) -> None:
|
||||
def fake(argv: list[str]) -> Mock:
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Unit: consolidated registration inputs — egress policy round-trip (PRD 0070)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from bot_bottle.egress import EgressPlan, EgressRoute
|
||||
from bot_bottle.egress_addon_core import LOG_BLOCKS, load_config
|
||||
from bot_bottle.orchestrator.registration import (
|
||||
RegistrationInputs,
|
||||
egress_policy,
|
||||
registration_inputs,
|
||||
)
|
||||
|
||||
|
||||
def _plan(routes: tuple[EgressRoute, ...], *, slug: str = "demo", log: int = 0) -> EgressPlan:
|
||||
return EgressPlan(
|
||||
slug=slug,
|
||||
routes_path=Path("/unused/routes.yaml"),
|
||||
routes=routes,
|
||||
token_env_map={},
|
||||
log=log,
|
||||
)
|
||||
|
||||
|
||||
class TestEgressPolicy(unittest.TestCase):
|
||||
def test_policy_round_trips_through_load_config(self) -> None:
|
||||
# The policy the gateway serves must parse back to the same allow-list
|
||||
# the per-bottle sidecar applied — moving onto the shared gateway must
|
||||
# not change a bottle's egress.
|
||||
routes = (EgressRoute(host="api.example.com"), EgressRoute(host="pypi.org"))
|
||||
cfg = load_config(egress_policy(_plan(routes)))
|
||||
self.assertEqual(("api.example.com", "pypi.org"), tuple(r.host for r in cfg.routes))
|
||||
|
||||
def test_policy_preserves_log_level(self) -> None:
|
||||
plan = _plan((EgressRoute(host="x.example.com"),), log=LOG_BLOCKS)
|
||||
self.assertEqual(LOG_BLOCKS, load_config(egress_policy(plan)).log)
|
||||
|
||||
def test_empty_routes_yield_deny_all(self) -> None:
|
||||
cfg = load_config(egress_policy(_plan(())))
|
||||
self.assertEqual((), cfg.routes) # no routes → default-deny
|
||||
|
||||
|
||||
class TestRegistrationInputs(unittest.TestCase):
|
||||
def test_bundles_policy_and_slug_metadata(self) -> None:
|
||||
plan = _plan((EgressRoute(host="api.example.com"),), slug="my-bot")
|
||||
inputs = registration_inputs(plan)
|
||||
self.assertIsInstance(inputs, RegistrationInputs)
|
||||
self.assertEqual("my-bot", json.loads(inputs.metadata)["slug"])
|
||||
self.assertEqual(egress_policy(plan), inputs.policy) # same blob egress_policy renders
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user