fix(macos): review fixes — token on plan, self-heal, symmetric digest, DHCP poll
Addresses findings from a high-effort review of the PRD 0070 macOS backend. Correctness: - Stamp identity_token onto MacosContainerBottlePlan after registration. git's gitconfig extraHeader and the supervise MCP --header read getattr(plan,"identity_token","") at provision time, and both reach the gateway on NO_PROXY (bypassing the egress proxy that carries the token). The plan never carried it, so /resolve fail-closed and every git fetch/push and supervise call from a macOS bottle would have been denied. Registration precedes provision(), so — unlike the run-time env — the plan can carry it. - Self-heal the orchestrator: recreate when it is not (source-current AND answering /health), not on the source-hash label alone. A container running current code but with a wedged HTTP server was left alone and polled to death, failing every launch until manual deletion. - image_digest and container_image_digest now read the same descriptor.digest field; dropped image_digest's id/tag fallback that could yield a value the container side can't produce — a permanent mismatch would have recreated the shared gateway on every launch (severing every live bottle's egress, since the replacement gets a new DHCP address). - Poll for the agent's and gateway's DHCP address instead of a fatal read right after `container run` (there is no --ip; the address can lag start). Cleanup: - One _inspect_first + _descriptor_digest behind the four inspect readers. - Shared bind_mount_spec (util) and host_db_dir (paths) replace per-module copies; _GIT_HTTP_PORT now imports git_http_backend.DEFAULT_PORT. - Drop the dead _url cache / url property and the write-only agent_proxy_url. Deferred (noted on the PR, not fixed here): the gateway image rebuilding on every launch (needs source-hash-labeled build), SQLite shared across VM guests, and the sh -lc profile-override edge — each is design-level or behavior-risk beyond a review fix. Verified: real Apple Container bring-up is green and idempotent; 1826 unit tests pass with `container` absent (CI parity), pyright clean, pylint 9.86. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,9 +13,13 @@ from .. import BottlePlan
|
|||||||
class MacosContainerBottlePlan(BottlePlan):
|
class MacosContainerBottlePlan(BottlePlan):
|
||||||
slug: str
|
slug: str
|
||||||
forwarded_env: dict[str, str] = field(repr=False)
|
forwarded_env: dict[str, str] = field(repr=False)
|
||||||
agent_proxy_url: str = ""
|
|
||||||
agent_git_gate_url: str = ""
|
agent_git_gate_url: str = ""
|
||||||
agent_supervise_url: str = ""
|
agent_supervise_url: str = ""
|
||||||
|
# Read by provision-time consumers (git extraHeader, supervise MCP header)
|
||||||
|
# via getattr(plan, "identity_token", ""); stamped in launch after the
|
||||||
|
# bottle is registered. See launch.py's stamp for why it lives here and not
|
||||||
|
# only in the exec-time proxy env.
|
||||||
|
identity_token: str = ""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def container_name(self) -> str:
|
def container_name(self) -> str:
|
||||||
|
|||||||
@@ -36,9 +36,10 @@ from ...orchestrator.gateway import (
|
|||||||
Gateway,
|
Gateway,
|
||||||
GatewayError,
|
GatewayError,
|
||||||
)
|
)
|
||||||
from ...paths import host_db_path
|
from ...paths import host_db_dir
|
||||||
from ...supervise import DB_PATH_IN_CONTAINER
|
from ...supervise import DB_PATH_IN_CONTAINER
|
||||||
from . import util as container_mod
|
from . import util as container_mod
|
||||||
|
from .util import bind_mount_spec as _mount
|
||||||
|
|
||||||
# Distinct from the docker gateway's names so both backends' gateways can
|
# Distinct from the docker gateway's names so both backends' gateways can
|
||||||
# coexist on one host (a macOS host can run the docker backend too).
|
# coexist on one host (a macOS host can run the docker backend too).
|
||||||
@@ -69,7 +70,7 @@ def gateway_ca_dir() -> Path:
|
|||||||
The docker gateway uses a named volume for this; a plain host dir is the
|
The docker gateway uses a named volume for this; a plain host dir is the
|
||||||
same guarantee with fewer moving parts, and it lets `ca_cert_pem` read the
|
same guarantee with fewer moving parts, and it lets `ca_cert_pem` read the
|
||||||
PEM straight off the host instead of shelling into the container."""
|
PEM straight off the host instead of shelling into the container."""
|
||||||
path = host_db_path().parent / "mac-gateway-ca"
|
path = host_db_dir() / "mac-gateway-ca"
|
||||||
path.mkdir(parents=True, exist_ok=True)
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
return path
|
return path
|
||||||
|
|
||||||
@@ -88,19 +89,6 @@ def ensure_networks(
|
|||||||
container_mod.create_network(network, internal=True)
|
container_mod.create_network(network, internal=True)
|
||||||
|
|
||||||
|
|
||||||
def _host_db_dir() -> str:
|
|
||||||
db_dir = host_db_path().parent
|
|
||||||
db_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
return str(db_dir)
|
|
||||||
|
|
||||||
|
|
||||||
def _mount(source: str, target: str, *, readonly: bool = False) -> str:
|
|
||||||
spec = f"type=bind,source={source},target={target}"
|
|
||||||
if readonly:
|
|
||||||
spec += ",readonly"
|
|
||||||
return spec
|
|
||||||
|
|
||||||
|
|
||||||
class AppleGateway(Gateway):
|
class AppleGateway(Gateway):
|
||||||
"""The consolidated gateway as a single, fixed-name Apple container."""
|
"""The consolidated gateway as a single, fixed-name Apple container."""
|
||||||
|
|
||||||
@@ -187,7 +175,7 @@ class AppleGateway(Gateway):
|
|||||||
# The NAT gateway routes but does not resolve, so DNS is explicit.
|
# The NAT gateway routes but does not resolve, so DNS is explicit.
|
||||||
"--dns", container_mod.dns_server(),
|
"--dns", container_mod.dns_server(),
|
||||||
"--mount", _mount(str(gateway_ca_dir()), MITMPROXY_HOME),
|
"--mount", _mount(str(gateway_ca_dir()), MITMPROXY_HOME),
|
||||||
"--mount", _mount(_host_db_dir(), _SUPERVISE_DB_DIR_IN_CONTAINER),
|
"--mount", _mount(str(host_db_dir()), _SUPERVISE_DB_DIR_IN_CONTAINER),
|
||||||
"--env", f"SUPERVISE_DB_PATH={DB_PATH_IN_CONTAINER}",
|
"--env", f"SUPERVISE_DB_PATH={DB_PATH_IN_CONTAINER}",
|
||||||
]
|
]
|
||||||
if self._orchestrator_url:
|
if self._orchestrator_url:
|
||||||
@@ -204,8 +192,14 @@ class AppleGateway(Gateway):
|
|||||||
|
|
||||||
def ip_on_shared_network(self) -> str:
|
def ip_on_shared_network(self) -> str:
|
||||||
"""The gateway's address on the shared host-only network — what agents
|
"""The gateway's address on the shared host-only network — what agents
|
||||||
point their proxy / git-http / supervise URLs at."""
|
point their proxy / git-http / supervise URLs at. Polls: a freshly
|
||||||
return container_mod.container_ipv4_on_network(self.name, self.network)
|
run gateway can be up before vmnet's DHCP has assigned the address."""
|
||||||
|
ip = container_mod.wait_container_ipv4_on_network(self.name, self.network)
|
||||||
|
if not ip:
|
||||||
|
raise GatewayError(
|
||||||
|
f"gateway {self.name} never got an address on {self.network}"
|
||||||
|
)
|
||||||
|
return ip
|
||||||
|
|
||||||
def ca_cert_pem(self, *, timeout: float = DEFAULT_CA_TIMEOUT_SECONDS) -> str:
|
def ca_cert_pem(self, *, timeout: float = DEFAULT_CA_TIMEOUT_SECONDS) -> str:
|
||||||
"""The gateway's CA certificate (PEM) that agents install to trust its
|
"""The gateway's CA certificate (PEM) that agents install to trust its
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ from ...git_gate import (
|
|||||||
provision_git_gate_dynamic_keys,
|
provision_git_gate_dynamic_keys,
|
||||||
revoke_git_gate_provisioned_keys,
|
revoke_git_gate_provisioned_keys,
|
||||||
)
|
)
|
||||||
|
from ...git_http_backend import DEFAULT_PORT as _GIT_HTTP_PORT
|
||||||
from ...log import die, info, warn
|
from ...log import die, info, warn
|
||||||
from ...supervise import SUPERVISE_PORT
|
from ...supervise import SUPERVISE_PORT
|
||||||
from ..docker.egress import EGRESS_PORT
|
from ..docker.egress import EGRESS_PORT
|
||||||
@@ -68,7 +69,6 @@ from .consolidated_launch import (
|
|||||||
|
|
||||||
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
|
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
|
||||||
_AGENT_SLEEP_SECONDS = "2147483647"
|
_AGENT_SLEEP_SECONDS = "2147483647"
|
||||||
_GIT_HTTP_PORT = 9420
|
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
@@ -115,10 +115,16 @@ def launch(
|
|||||||
|
|
||||||
# Step 4: read the assigned address and register by it. This is the
|
# Step 4: read the assigned address and register by it. This is the
|
||||||
# attribution key; `--cap-drop CAP_NET_RAW` at run is what makes it
|
# attribution key; `--cap-drop CAP_NET_RAW` at run is what makes it
|
||||||
# unforgeable.
|
# unforgeable. Poll: `container run --detach` can return before vmnet's
|
||||||
source_ip = container_mod.container_ipv4_on_network(
|
# DHCP has assigned the address.
|
||||||
|
source_ip = container_mod.wait_container_ipv4_on_network(
|
||||||
plan.container_name, endpoint.network,
|
plan.container_name, endpoint.network,
|
||||||
)
|
)
|
||||||
|
if not source_ip:
|
||||||
|
die(
|
||||||
|
f"agent {plan.container_name} never got an address on "
|
||||||
|
f"{endpoint.network}"
|
||||||
|
)
|
||||||
effective_env = {**os.environ, **plan.agent_provision.provisioned_env}
|
effective_env = {**os.environ, **plan.agent_provision.provisioned_env}
|
||||||
token_values = egress_resolve_token_values(
|
token_values = egress_resolve_token_values(
|
||||||
plan.egress_plan.token_env_map, effective_env,
|
plan.egress_plan.token_env_map, effective_env,
|
||||||
@@ -140,6 +146,15 @@ def launch(
|
|||||||
f"(gateway {endpoint.gateway_ip}, ip {source_ip})"
|
f"(gateway {endpoint.gateway_ip}, ip {source_ip})"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Stamp the token onto the plan so provision-time consumers can read it,
|
||||||
|
# not only the exec-time egress proxy. git-gate's gitconfig extraHeader
|
||||||
|
# and the supervise MCP --header both reach the gateway on NO_PROXY (they
|
||||||
|
# bypass the egress proxy that carries the token), so without this the
|
||||||
|
# gateway's /resolve fail-closes and every git fetch/push and supervise
|
||||||
|
# call from the bottle is denied. Registration already produced the
|
||||||
|
# token above, so — unlike the run-time env — the plan CAN carry it.
|
||||||
|
plan = dataclasses.replace(plan, identity_token=ctx.identity_token)
|
||||||
|
|
||||||
bottle = MacosContainerBottle(
|
bottle = MacosContainerBottle(
|
||||||
plan.container_name,
|
plan.container_name,
|
||||||
teardown,
|
teardown,
|
||||||
@@ -227,7 +242,6 @@ def _stamp_agent_urls(
|
|||||||
)
|
)
|
||||||
return dataclasses.replace(
|
return dataclasses.replace(
|
||||||
plan,
|
plan,
|
||||||
agent_proxy_url=f"http://{endpoint.gateway_ip}:{EGRESS_PORT}",
|
|
||||||
agent_git_gate_url=git_gate_url,
|
agent_git_gate_url=git_gate_url,
|
||||||
agent_supervise_url=supervise_url,
|
agent_supervise_url=supervise_url,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ from .gateway import (
|
|||||||
AppleGateway,
|
AppleGateway,
|
||||||
ensure_networks,
|
ensure_networks,
|
||||||
)
|
)
|
||||||
|
from .util import bind_mount_spec as _mount
|
||||||
|
|
||||||
# Distinct from the docker backend's container name so both can run on one host.
|
# Distinct from the docker backend's container name so both can run on one host.
|
||||||
ORCHESTRATOR_NAME = "bot-bottle-mac-orchestrator"
|
ORCHESTRATOR_NAME = "bot-bottle-mac-orchestrator"
|
||||||
@@ -61,13 +62,6 @@ _HEALTH_POLL_SECONDS = 0.25
|
|||||||
_HEALTH_REQUEST_TIMEOUT_SECONDS = 1.0
|
_HEALTH_REQUEST_TIMEOUT_SECONDS = 1.0
|
||||||
|
|
||||||
|
|
||||||
def _mount(source: str, target: str, *, readonly: bool = False) -> str:
|
|
||||||
spec = f"type=bind,source={source},target={target}"
|
|
||||||
if readonly:
|
|
||||||
spec += ",readonly"
|
|
||||||
return spec
|
|
||||||
|
|
||||||
|
|
||||||
class MacosOrchestratorService:
|
class MacosOrchestratorService:
|
||||||
"""Manages the orchestrator control-plane container + the shared Apple
|
"""Manages the orchestrator control-plane container + the shared Apple
|
||||||
gateway. Callers only need `ensure_running()`, which returns the URL."""
|
gateway. Callers only need `ensure_running()`, which returns the URL."""
|
||||||
@@ -92,30 +86,24 @@ class MacosOrchestratorService:
|
|||||||
self._repo_root = repo_root
|
self._repo_root = repo_root
|
||||||
self._host_root = host_root or bot_bottle_root()
|
self._host_root = host_root or bot_bottle_root()
|
||||||
self._orchestrator_name = orchestrator_name
|
self._orchestrator_name = orchestrator_name
|
||||||
# Resolved once the container is up — there is no name to fall back on.
|
|
||||||
self._url = ""
|
|
||||||
|
|
||||||
@property
|
|
||||||
def url(self) -> str:
|
|
||||||
"""The control-plane URL, or "" before the container is up. One URL for
|
|
||||||
both the host CLI and the gateway (see the module docstring)."""
|
|
||||||
return self._url
|
|
||||||
|
|
||||||
def _resolve_url(self) -> str:
|
def _resolve_url(self) -> str:
|
||||||
"""The control-plane URL, or "" while the container has no address."""
|
"""The control-plane URL, or "" while the container has no address.
|
||||||
|
Resolved fresh each time (there is no name to fall back on, and a
|
||||||
|
recreated orchestrator can come back on a different DHCP address), so
|
||||||
|
nothing caches a URL that could go stale."""
|
||||||
ip = container_mod.try_container_ipv4_on_network(
|
ip = container_mod.try_container_ipv4_on_network(
|
||||||
self._orchestrator_name, self.network,
|
self._orchestrator_name, self.network,
|
||||||
)
|
)
|
||||||
return f"http://{ip}:{self.port}" if ip else ""
|
return f"http://{ip}:{self.port}" if ip else ""
|
||||||
|
|
||||||
def is_healthy(
|
def is_healthy(
|
||||||
self, url: str = "", *, timeout: float = _HEALTH_REQUEST_TIMEOUT_SECONDS,
|
self, url: str, *, timeout: float = _HEALTH_REQUEST_TIMEOUT_SECONDS,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
target = url or self._url
|
if not url:
|
||||||
if not target:
|
|
||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(f"{target}/health", timeout=timeout) as resp:
|
with urllib.request.urlopen(f"{url}/health", timeout=timeout) as resp:
|
||||||
return resp.status == 200
|
return resp.status == 200
|
||||||
except (urllib.error.URLError, TimeoutError, OSError):
|
except (urllib.error.URLError, TimeoutError, OSError):
|
||||||
return False
|
return False
|
||||||
@@ -192,16 +180,15 @@ class MacosOrchestratorService:
|
|||||||
control-plane URL. Idempotent — a healthy control plane running current
|
control-plane URL. Idempotent — a healthy control plane running current
|
||||||
code and a running gateway are left untouched."""
|
code and a running gateway are left untouched."""
|
||||||
current_hash = source_hash(self._repo_root)
|
current_hash = source_hash(self._repo_root)
|
||||||
if not self._orchestrator_source_current(current_hash):
|
url = self._running_healthy_url(current_hash)
|
||||||
|
if not url:
|
||||||
self._ensure_orchestrator_image()
|
self._ensure_orchestrator_image()
|
||||||
log.info(
|
log.info(
|
||||||
"starting orchestrator container",
|
"starting orchestrator container",
|
||||||
context={"name": self._orchestrator_name},
|
context={"name": self._orchestrator_name},
|
||||||
)
|
)
|
||||||
self._run_orchestrator_container(current_hash)
|
self._run_orchestrator_container(current_hash)
|
||||||
|
url = self._wait_healthy(startup_timeout)
|
||||||
url = self._wait_healthy(startup_timeout)
|
|
||||||
self._url = url
|
|
||||||
|
|
||||||
# Gateway second: it can only reach the control plane by IP, which does
|
# Gateway second: it can only reach the control plane by IP, which does
|
||||||
# not exist until the orchestrator container is up (see the docstring).
|
# not exist until the orchestrator container is up (see the docstring).
|
||||||
@@ -210,6 +197,21 @@ class MacosOrchestratorService:
|
|||||||
gateway.ensure_running()
|
gateway.ensure_running()
|
||||||
return url
|
return url
|
||||||
|
|
||||||
|
def _running_healthy_url(self, current_hash: str) -> str:
|
||||||
|
"""The control-plane URL if the running orchestrator is BOTH current
|
||||||
|
and answering /health, else "" (→ recreate).
|
||||||
|
|
||||||
|
Checking health, not just the source-hash label, is what lets the
|
||||||
|
service self-heal: a container that is running the current code but
|
||||||
|
whose HTTP server is wedged (bind failure, deadlock, OOM'd thread)
|
||||||
|
would otherwise be left alone and polled to death on every launch
|
||||||
|
forever. Mirrors the docker service's `is_healthy() and source_current`
|
||||||
|
gate."""
|
||||||
|
if not self._orchestrator_source_current(current_hash):
|
||||||
|
return ""
|
||||||
|
url = self._resolve_url()
|
||||||
|
return url if url and self.is_healthy(url) else ""
|
||||||
|
|
||||||
def _wait_healthy(self, startup_timeout: float) -> str:
|
def _wait_healthy(self, startup_timeout: float) -> str:
|
||||||
"""Poll until the control plane answers /health, resolving its address
|
"""Poll until the control plane answers /health, resolving its address
|
||||||
each time: the container is up before it has an IP, and it has an IP
|
each time: the container is up before it has an IP, and it has an IP
|
||||||
@@ -231,7 +233,6 @@ class MacosOrchestratorService:
|
|||||||
"""Remove the orchestrator + gateway containers (idempotent)."""
|
"""Remove the orchestrator + gateway containers (idempotent)."""
|
||||||
container_mod.force_remove_container(self._orchestrator_name)
|
container_mod.force_remove_container(self._orchestrator_name)
|
||||||
self.gateway("").stop()
|
self.gateway("").stop()
|
||||||
self._url = ""
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
|||||||
@@ -453,77 +453,73 @@ def run_container_argv(argv: list[str]) -> subprocess.CompletedProcess[str]:
|
|||||||
return subprocess.run(argv, capture_output=True, text=True, check=False)
|
return subprocess.run(argv, capture_output=True, text=True, check=False)
|
||||||
|
|
||||||
|
|
||||||
|
def bind_mount_spec(source: str, target: str, *, readonly: bool = False) -> str:
|
||||||
|
"""A `container run --mount` bind spec. One definition so the gateway and
|
||||||
|
orchestrator emit an identical string — a divergence here would silently
|
||||||
|
break one backend's mounts while the other kept working."""
|
||||||
|
spec = f"type=bind,source={source},target={target}"
|
||||||
|
if readonly:
|
||||||
|
spec += ",readonly"
|
||||||
|
return spec
|
||||||
|
|
||||||
|
|
||||||
def _normalize_digest(value: str) -> str:
|
def _normalize_digest(value: str) -> str:
|
||||||
return value.split(":", 1)[1] if ":" in value else value
|
return value.split(":", 1)[1] if ":" in value else value
|
||||||
|
|
||||||
|
|
||||||
def image_digest(ref: str) -> str:
|
def _inspect_first(argv: list[str]) -> dict[str, object]:
|
||||||
"""The digest of image `ref`, or "" if it can't be read. Empty is a
|
"""Run an inspect command and return its first JSON object, or {} on any
|
||||||
'don't know' signal — callers treat it as "don't churn a working
|
failure (non-zero exit, malformed JSON, unexpected shape). {} is the shared
|
||||||
container" rather than as a mismatch."""
|
'don't know' signal all the non-fatal inspect readers below build on — a
|
||||||
result = run_container_argv([_CONTAINER, "image", "inspect", ref])
|
caller comparing against it treats it as 'leave the working container
|
||||||
|
alone', never as a mismatch."""
|
||||||
|
result = run_container_argv(argv)
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
return ""
|
return {}
|
||||||
try:
|
try:
|
||||||
data = json.loads(result.stdout or "{}")
|
data = json.loads(result.stdout or "[]")
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
|
return {}
|
||||||
|
if isinstance(data, list):
|
||||||
|
data = data[0] if data else {}
|
||||||
|
return data if isinstance(data, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _descriptor_digest(node: object) -> str:
|
||||||
|
"""The normalized digest under a `{... "descriptor": {"digest": ...}}`
|
||||||
|
node, or "". Both the image and container inspect shapes nest the image's
|
||||||
|
identity this way, so the digest readers stay symmetric — a difference
|
||||||
|
between them is what would spuriously recreate a container."""
|
||||||
|
if not isinstance(node, dict):
|
||||||
return ""
|
return ""
|
||||||
if isinstance(data, list) and data:
|
descriptor = node.get("descriptor")
|
||||||
data = data[0]
|
if isinstance(descriptor, dict) and descriptor.get("digest"):
|
||||||
if not isinstance(data, dict):
|
return _normalize_digest(str(descriptor["digest"]))
|
||||||
return ""
|
return ""
|
||||||
config = data.get("configuration")
|
|
||||||
if isinstance(config, dict):
|
|
||||||
descriptor = config.get("descriptor")
|
def image_digest(ref: str) -> str:
|
||||||
if isinstance(descriptor, dict) and descriptor.get("digest"):
|
"""The digest of image `ref`, or "" if it can't be read. Reads exactly the
|
||||||
return _normalize_digest(str(descriptor["digest"]))
|
field `container_image_digest` reads (`configuration.descriptor.digest`) so
|
||||||
value = data.get("id")
|
the two are comparable; "" means 'don't know' → callers don't churn."""
|
||||||
return _normalize_digest(str(value)) if value else ""
|
data = _inspect_first([_CONTAINER, "image", "inspect", ref])
|
||||||
|
return _descriptor_digest(data.get("configuration"))
|
||||||
|
|
||||||
|
|
||||||
def container_image_digest(name: str) -> str:
|
def container_image_digest(name: str) -> str:
|
||||||
"""The digest of the image container `name` was created from, or "" if it
|
"""The digest of the image container `name` was created from, or "" if it
|
||||||
can't be read. Compare with `image_digest(ref)` to tell whether a running
|
can't be read. Compare with `image_digest(ref)` to tell whether a running
|
||||||
container predates an image rebuild."""
|
container predates an image rebuild."""
|
||||||
result = run_container_argv([_CONTAINER, "inspect", name])
|
config = _inspect_first([_CONTAINER, "inspect", name]).get("configuration")
|
||||||
if result.returncode != 0:
|
image = config.get("image") if isinstance(config, dict) else None
|
||||||
return ""
|
return _descriptor_digest(image)
|
||||||
try:
|
|
||||||
data = json.loads(result.stdout or "[]")
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
return ""
|
|
||||||
if isinstance(data, list) and data:
|
|
||||||
data = data[0]
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
return ""
|
|
||||||
config = data.get("configuration")
|
|
||||||
if not isinstance(config, dict):
|
|
||||||
return ""
|
|
||||||
image = config.get("image")
|
|
||||||
if not isinstance(image, dict):
|
|
||||||
return ""
|
|
||||||
descriptor = image.get("descriptor")
|
|
||||||
if isinstance(descriptor, dict) and descriptor.get("digest"):
|
|
||||||
return _normalize_digest(str(descriptor["digest"]))
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
def container_env(name: str) -> dict[str, str]:
|
def container_env(name: str) -> dict[str, str]:
|
||||||
"""The env container `name` was started with, or {} if unreadable. Lets a
|
"""The env container `name` was started with, or {} if unreadable. Lets a
|
||||||
caller tell whether a running container's baked-in configuration still
|
caller tell whether a running container's baked-in configuration still
|
||||||
matches what it would pass today."""
|
matches what it would pass today."""
|
||||||
result = run_container_argv([_CONTAINER, "inspect", name])
|
config = _inspect_first([_CONTAINER, "inspect", name]).get("configuration")
|
||||||
if result.returncode != 0:
|
|
||||||
return {}
|
|
||||||
try:
|
|
||||||
data = json.loads(result.stdout or "[]")
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
return {}
|
|
||||||
if isinstance(data, list) and data:
|
|
||||||
data = data[0]
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
return {}
|
|
||||||
config = data.get("configuration")
|
|
||||||
init = config.get("initProcess") if isinstance(config, dict) else None
|
init = config.get("initProcess") if isinstance(config, dict) else None
|
||||||
entries = init.get("environment") if isinstance(init, dict) else None
|
entries = init.get("environment") if isinstance(init, dict) else None
|
||||||
if not isinstance(entries, list):
|
if not isinstance(entries, list):
|
||||||
@@ -540,18 +536,7 @@ def try_container_ipv4_on_network(name: str, network: str) -> str:
|
|||||||
"""`container_ipv4_on_network` without the fatal exit: "" when the address
|
"""`container_ipv4_on_network` without the fatal exit: "" when the address
|
||||||
isn't readable yet. For pollers — a container is created before it has an
|
isn't readable yet. For pollers — a container is created before it has an
|
||||||
address, so "not yet" is an expected state there, not an error."""
|
address, so "not yet" is an expected state there, not an error."""
|
||||||
result = run_container_argv([_CONTAINER, "inspect", name])
|
status = _inspect_first([_CONTAINER, "inspect", name]).get("status")
|
||||||
if result.returncode != 0:
|
|
||||||
return ""
|
|
||||||
try:
|
|
||||||
data = json.loads(result.stdout or "[]")
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
return ""
|
|
||||||
if isinstance(data, list) and data:
|
|
||||||
data = data[0]
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
return ""
|
|
||||||
status = data.get("status")
|
|
||||||
networks = status.get("networks") if isinstance(status, dict) else None
|
networks = status.get("networks") if isinstance(status, dict) else None
|
||||||
if not isinstance(networks, list):
|
if not isinstance(networks, list):
|
||||||
return ""
|
return ""
|
||||||
@@ -564,6 +549,27 @@ def try_container_ipv4_on_network(name: str, network: str) -> str:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def wait_container_ipv4_on_network(
|
||||||
|
name: str, network: str, *, timeout: float = 15.0, poll: float = 0.25,
|
||||||
|
) -> str:
|
||||||
|
"""Poll for the container's DHCP-assigned address on `network`, returning
|
||||||
|
it once available or "" on timeout.
|
||||||
|
|
||||||
|
Apple Container has no `--ip`: `container run --detach` can return before
|
||||||
|
vmnet's DHCP has populated `status.networks[].ipv4Address`, so a bare read
|
||||||
|
right after start races the assignment. Callers that need the address (the
|
||||||
|
attribution key, the gateway's proxy target) poll through here instead of
|
||||||
|
the fatal `container_ipv4_on_network`."""
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
while True:
|
||||||
|
ip = try_container_ipv4_on_network(name, network)
|
||||||
|
if ip:
|
||||||
|
return ip
|
||||||
|
if time.monotonic() >= deadline:
|
||||||
|
return ""
|
||||||
|
time.sleep(poll)
|
||||||
|
|
||||||
|
|
||||||
def image_id(ref: str) -> str:
|
def image_id(ref: str) -> str:
|
||||||
"""Return the image digest/ID from `container image inspect`.
|
"""Return the image digest/ID from `container image inspect`.
|
||||||
|
|
||||||
|
|||||||
+10
-1
@@ -40,4 +40,13 @@ def host_db_path() -> Path:
|
|||||||
return bot_bottle_root() / "db" / HOST_DB_FILENAME
|
return bot_bottle_root() / "db" / HOST_DB_FILENAME
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["HOST_DB_FILENAME", "bot_bottle_root", "host_db_path"]
|
def host_db_dir() -> Path:
|
||||||
|
"""The directory holding the shared host state DB, created if missing.
|
||||||
|
Backends bind-mount this into their gateway so the supervise daemon writes
|
||||||
|
to the one DB the orchestrator (and the operator over HTTP) reads."""
|
||||||
|
db_dir = host_db_path().parent
|
||||||
|
db_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
return db_dir
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["HOST_DB_FILENAME", "bot_bottle_root", "host_db_path", "host_db_dir"]
|
||||||
|
|||||||
@@ -158,6 +158,36 @@ class TestIdentityTokenDelivery(unittest.TestCase):
|
|||||||
argv = bottle.agent_argv(["--help"], tty=False)
|
argv = bottle.agent_argv(["--help"], tty=False)
|
||||||
self.assertNotIn("--env", argv)
|
self.assertNotIn("--env", argv)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPlanIdentityToken(unittest.TestCase):
|
||||||
|
"""git-gate's gitconfig extraHeader and the supervise MCP --header read
|
||||||
|
`getattr(plan, "identity_token", "")` at provision time and both bypass the
|
||||||
|
egress proxy (NO_PROXY), so the exec-time proxy token never reaches them —
|
||||||
|
the plan must carry the token or /resolve fail-closes and git + supervise
|
||||||
|
are denied on macOS."""
|
||||||
|
|
||||||
|
def test_macos_plan_has_the_identity_token_field(self) -> None:
|
||||||
|
from dataclasses import fields
|
||||||
|
|
||||||
|
from bot_bottle.backend.macos_container.bottle_plan import (
|
||||||
|
MacosContainerBottlePlan,
|
||||||
|
)
|
||||||
|
names = {f.name for f in fields(MacosContainerBottlePlan)}
|
||||||
|
self.assertIn("identity_token", names)
|
||||||
|
|
||||||
|
def test_matches_the_docker_plan(self) -> None:
|
||||||
|
"""Both consolidated backends must expose identity_token so a shared
|
||||||
|
provision-time consumer degrades to neither backend silently."""
|
||||||
|
from dataclasses import fields
|
||||||
|
|
||||||
|
from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
|
||||||
|
from bot_bottle.backend.macos_container.bottle_plan import (
|
||||||
|
MacosContainerBottlePlan,
|
||||||
|
)
|
||||||
|
docker = {f.name for f in fields(DockerBottlePlan)}
|
||||||
|
macos = {f.name for f in fields(MacosContainerBottlePlan)}
|
||||||
|
self.assertIn("identity_token", docker & macos)
|
||||||
|
|
||||||
def test_provisioning_exec_also_carries_the_token(self) -> None:
|
def test_provisioning_exec_also_carries_the_token(self) -> None:
|
||||||
"""`provision` runs through `exec`; a provider whose provision step
|
"""`provision` runs through `exec`; a provider whose provision step
|
||||||
fetches anything would otherwise egress token-less and be denied."""
|
fetches anything would otherwise egress token-less and be denied."""
|
||||||
|
|||||||
@@ -273,5 +273,55 @@ resolver #2
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _completed(stdout: str, returncode: int = 0):
|
||||||
|
return util.subprocess.CompletedProcess(args=[], returncode=returncode, stdout=stdout, stderr="")
|
||||||
|
|
||||||
|
|
||||||
|
class TestInspectDigests(unittest.TestCase):
|
||||||
|
"""image_digest and container_image_digest must read the SAME field shape
|
||||||
|
(a `descriptor.digest`) so a running container and its image are
|
||||||
|
comparable. An asymmetry recreates the gateway on every launch."""
|
||||||
|
|
||||||
|
_IMAGE = '{"configuration": {"descriptor": {"digest": "sha256:abc123"}}, "id": "abc123"}'
|
||||||
|
_CONTAINER = '[{"configuration": {"image": {"descriptor": {"digest": "sha256:abc123"}}}}]'
|
||||||
|
|
||||||
|
def test_image_and_container_digests_agree_for_the_same_image(self):
|
||||||
|
with patch.object(util, "run_container_argv") as run:
|
||||||
|
run.return_value = _completed(self._IMAGE)
|
||||||
|
img = util.image_digest("bot-bottle-gateway:latest")
|
||||||
|
run.return_value = _completed(self._CONTAINER)
|
||||||
|
ctr = util.container_image_digest("bot-bottle-mac-gateway")
|
||||||
|
self.assertEqual("abc123", img)
|
||||||
|
self.assertEqual(img, ctr)
|
||||||
|
|
||||||
|
def test_image_digest_never_falls_back_to_a_tag_or_id(self):
|
||||||
|
"""The old `id` fallback could yield a value container_image_digest
|
||||||
|
can't produce, permanently mismatching. Missing descriptor → '' (don't
|
||||||
|
churn), never a stray id/tag."""
|
||||||
|
with patch.object(util, "run_container_argv") as run:
|
||||||
|
run.return_value = _completed('{"id": "bot-bottle-gateway:latest"}')
|
||||||
|
self.assertEqual("", util.image_digest("bot-bottle-gateway:latest"))
|
||||||
|
|
||||||
|
def test_unreadable_inspect_returns_empty(self):
|
||||||
|
with patch.object(util, "run_container_argv") as run:
|
||||||
|
run.return_value = _completed("", returncode=1)
|
||||||
|
self.assertEqual("", util.image_digest("x"))
|
||||||
|
self.assertEqual("", util.container_image_digest("x"))
|
||||||
|
self.assertEqual({}, util.container_env("x"))
|
||||||
|
|
||||||
|
|
||||||
|
class TestWaitContainerIpv4(unittest.TestCase):
|
||||||
|
def test_returns_address_once_dhcp_assigns_it(self):
|
||||||
|
with patch.object(util, "try_container_ipv4_on_network", side_effect=["", "", "192.168.128.4"]), \
|
||||||
|
patch.object(util.time, "sleep"):
|
||||||
|
ip = util.wait_container_ipv4_on_network("c", "net", timeout=5, poll=0)
|
||||||
|
self.assertEqual("192.168.128.4", ip)
|
||||||
|
|
||||||
|
def test_returns_empty_on_timeout(self):
|
||||||
|
with patch.object(util, "try_container_ipv4_on_network", return_value=""), \
|
||||||
|
patch.object(util.time, "sleep"):
|
||||||
|
self.assertEqual("", util.wait_container_ipv4_on_network("c", "net", timeout=-1))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -194,6 +194,28 @@ class TestMacosOrchestratorService(unittest.TestCase):
|
|||||||
svc.ensure_running()
|
svc.ensure_running()
|
||||||
run.assert_not_called()
|
run.assert_not_called()
|
||||||
|
|
||||||
|
def test_wedged_but_current_orchestrator_is_recreated(self) -> None:
|
||||||
|
"""A container running the current code but whose HTTP server is dead
|
||||||
|
must be recreated, not left alone and polled to death forever. Checking
|
||||||
|
the source-hash label alone (without health) would strand the host."""
|
||||||
|
svc = MacosOrchestratorService(repo_root=Path("/r"), host_root=Path("/h"))
|
||||||
|
run = Mock()
|
||||||
|
# Unhealthy on the pre-check, healthy once recreated + waited.
|
||||||
|
health = Mock(side_effect=[False, True])
|
||||||
|
with patch(f"{_ORCH}.container_mod") as mod, \
|
||||||
|
patch(f"{_ORCH}.source_hash", return_value="h1"), \
|
||||||
|
patch.object(svc, "_run_orchestrator_container", run), \
|
||||||
|
patch.object(svc, "_ensure_orchestrator_image"), \
|
||||||
|
patch.object(svc, "gateway"), \
|
||||||
|
patch.object(svc, "is_healthy", health):
|
||||||
|
mod.container_is_running.return_value = True
|
||||||
|
mod.inspect_container.return_value = {
|
||||||
|
"configuration": {"labels": {"bot-bottle-orchestrator-source-hash": "h1"}}
|
||||||
|
}
|
||||||
|
mod.try_container_ipv4_on_network.return_value = "192.168.128.2"
|
||||||
|
svc.ensure_running()
|
||||||
|
run.assert_called_once()
|
||||||
|
|
||||||
def test_changed_source_recreates_the_orchestrator(self) -> None:
|
def test_changed_source_recreates_the_orchestrator(self) -> None:
|
||||||
"""The control-plane process loaded its bind-mounted source at startup
|
"""The control-plane process loaded its bind-mounted source at startup
|
||||||
and won't reload it."""
|
and won't reload it."""
|
||||||
|
|||||||
Reference in New Issue
Block a user