feat(macos): consolidated per-host gateway for the Apple backend (PRD 0070)

Re-enables the macos-container backend on the shared per-host orchestrator +
gateway, replacing the per-bottle companion container removed in #385. This is
the last backend in PRD 0070's roadmap.

Apple Container 1.0.0 forced three departures from the docker shape, each
verified against the live CLI (findings recorded in the networking spike):

- No `--ip`. The address is DHCP-assigned and knowable only once the container
  runs, so the order inverts: gateway up -> run agent -> read its address ->
  register. The identity token is minted by registration and therefore cannot
  be in the agent's run-time env; it rides the proxy URL applied at
  `container exec` time (bare `--env` names keep it off argv).
- No container DNS. The gateway can only be handed the control plane's IP, so
  the orchestrator starts first and the gateway is pointed at its address.
- No `network connect`. Networks are fixed at run time, so the shared host-only
  network is created up front; per-bottle networks would restart the gateway
  on every launch and defeat the consolidation.

The agent runs with `--cap-drop CAP_NET_RAW`: Apple grants NET_RAW by default,
which would let an agent forge a neighbour's source address on the shared
segment. NET_ADMIN is already absent, so this closes the source-address half of
PRD 0070's attribution invariant.

Verified end-to-end on real Apple Container 1.0.0: both images build, the
control plane comes up healthy, the gateway reaches it by IP, and a registered
agent gets 200 for a host in its routes and 403 for one outside them. Bring-up
is idempotent — a second launch does not churn the singletons.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 01:00:20 -04:00
parent dfc693e0b6
commit c69642e568
15 changed files with 1856 additions and 56 deletions
+119 -10
View File
@@ -437,22 +437,131 @@ def inspect_container(name: str) -> dict[str, object]:
def container_ipv4_on_network(name: str, network: str) -> str:
data = inspect_container(name)
"""The container's IPv4 address on `network`. Fatal if absent — callers
that can tolerate "not yet" want `try_container_ipv4_on_network`."""
ip = try_container_ipv4_on_network(name, network)
if not ip:
die(f"container {name} has no IPv4 address on {network}")
return ip
def run_container_argv(argv: list[str]) -> subprocess.CompletedProcess[str]:
"""Run a `container` command, returning the result for the caller to
interpret. Unlike the `die`-on-failure helpers above, this lets callers
that raise their own typed errors (the gateway / orchestrator lifecycle)
keep control of the failure path."""
return subprocess.run(argv, capture_output=True, text=True, check=False)
def _normalize_digest(value: str) -> str:
return value.split(":", 1)[1] if ":" in value else value
def image_digest(ref: str) -> str:
"""The digest of image `ref`, or "" if it can't be read. Empty is a
'don't know' signal — callers treat it as "don't churn a working
container" rather than as a mismatch."""
result = run_container_argv([_CONTAINER, "image", "inspect", ref])
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")
if isinstance(config, dict):
descriptor = config.get("descriptor")
if isinstance(descriptor, dict) and descriptor.get("digest"):
return _normalize_digest(str(descriptor["digest"]))
value = data.get("id")
return _normalize_digest(str(value)) if value else ""
def container_image_digest(name: str) -> str:
"""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
container predates an image rebuild."""
result = run_container_argv([_CONTAINER, "inspect", name])
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")
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]:
"""The env container `name` was started with, or {} if unreadable. Lets a
caller tell whether a running container's baked-in configuration still
matches what it would pass today."""
result = run_container_argv([_CONTAINER, "inspect", name])
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
entries = init.get("environment") if isinstance(init, dict) else None
if not isinstance(entries, list):
return {}
env: dict[str, str] = {}
for entry in entries:
if isinstance(entry, str) and "=" in entry:
key, value = entry.split("=", 1)
env[key] = value
return env
def try_container_ipv4_on_network(name: str, network: str) -> str:
"""`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
address, so "not yet" is an expected state there, not an error."""
result = run_container_argv([_CONTAINER, "inspect", name])
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
if not isinstance(networks, list):
die(f"container inspect {name} did not include status.networks")
return ""
for entry in networks:
if not isinstance(entry, dict):
continue
if entry.get("network") != network:
if not isinstance(entry, dict) or entry.get("network") != network:
continue
raw = entry.get("ipv4Address")
if not isinstance(raw, str) or not raw:
die(f"container {name} has no IPv4 address on {network}")
return raw.split("/", 1)[0]
die(f"container {name} is not attached to network {network}")
raise AssertionError("unreachable")
if isinstance(raw, str) and raw:
return raw.split("/", 1)[0]
return ""
def image_id(ref: str) -> str: