ca91fc4d91
The control plane had no caller authentication — dispatch() routed every request straight through, and the only guards anywhere were the (source_ip, identity_token) checks inside /resolve and /attribute, which authenticate the *bottle* a request is about, not the *caller*. On the consolidated backends the agent shares a network with the control-plane port (macOS: the infra container runs 8099 on the agent's --internal network; docker: the orchestrator container sits on the shared gateway network), so a prompt-injected agent could GET /bottles (enumerate siblings + policies), PUT /bottles/<id>/policy (rewrite its own egress allowlist to allow-all), POST /resolve (read the injected upstream credentials it's never meant to see), and POST /supervise/respond (self-approve its own proposals). Every README guarantee collapsed to the agent's discretion. Fix: a per-host control-plane secret required on every route but GET /health, compared with hmac.compare_digest. It is held only by the trusted callers and never handed to an agent: - minted + persisted 0600 at <root>/control-plane-token (paths.host_control_plane_token); - injected as $BOT_BOTTLE_CONTROL_PLANE_TOKEN into the orchestrator + gateway containers via bare `--env NAME` (value inherited from the launch process, so it never lands on argv or in `container/docker inspect`); - presented by the gateway's PolicyResolver (reads the env) on /resolve, and by the host CLI's OrchestratorClient (reads the host file) on every call. The agent container is never given the env var or the host file, so from a bottle every /bottles*, /resolve, /attribute, and /supervise/* call now returns 401 — closing the enumeration, allowlist-rewrite, credential-lift, and self-approval. The existing (source_ip, identity_token) checks stay as defense-in-depth. Enforced when configured: macOS + docker inject the secret (→ enforced). With no secret set the server runs open and warns loudly at startup — a fail-visible fallback for the unit suite and for Firecracker, whose port-scoped nft already blocks agents from 8099 (wiring the secret into its infra-VM init is a clean fast-follow, left out here to avoid churning the prebuilt-artifact hash). Verified end-to-end on real Apple Container: infra comes up healthy, the host CLI (with the secret) lists bottles while an unauthenticated GET /bottles gets 401, all five issue-#400 attacks from inside the agent get 401, and egress policy still works (200 allowed / 403 denied) — proving the gateway authenticates to /resolve with the secret. 1829 unit tests pass, pyright clean, pylint 9.91. Refs #400. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
625 lines
21 KiB
Python
625 lines
21 KiB
Python
"""Host-side primitives for Apple's `container` CLI."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import ipaddress
|
|
import platform
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
import time
|
|
from typing import Iterable
|
|
|
|
from ...log import die, info
|
|
|
|
|
|
_CONTAINER = "container"
|
|
_DEFAULT_DNS = "1.1.1.1"
|
|
|
|
|
|
def is_macos() -> bool:
|
|
return platform.system() == "Darwin"
|
|
|
|
|
|
def is_available() -> bool:
|
|
return is_macos() and shutil.which(_CONTAINER) is not None
|
|
|
|
|
|
def require_container() -> None:
|
|
"""Fail with an install pointer if Apple Container is unavailable."""
|
|
if not is_macos():
|
|
info("BOT_BOTTLE_BACKEND=macos-container requires macOS.")
|
|
die("macos-container backend is only supported on macOS")
|
|
if shutil.which(_CONTAINER) is None:
|
|
info("Apple Container is required but was not found on PATH.")
|
|
info("Install: https://github.com/apple/container/releases")
|
|
die("container not found")
|
|
_require_container_service()
|
|
|
|
|
|
def _require_container_service() -> None:
|
|
result = subprocess.run(
|
|
[_CONTAINER, "system", "status"],
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
check=False,
|
|
)
|
|
if result.returncode != 0:
|
|
info("Apple Container system service is not running.")
|
|
info("Start it with: container system start")
|
|
die("container system service not running")
|
|
|
|
|
|
def dns_server() -> str:
|
|
override = os.environ.get("BOT_BOTTLE_MACOS_CONTAINER_DNS", "").strip()
|
|
if override:
|
|
return override
|
|
return _host_ipv4_dns() or _DEFAULT_DNS
|
|
|
|
|
|
def build_image(ref: str, context: str, *, dockerfile: str = "") -> None:
|
|
"""Build an OCI image with Apple's BuildKit-backed `container build`.
|
|
|
|
Set `BOT_BOTTLE_NO_CACHE=1` (the `start --no-cache` flag) to force
|
|
`--no-cache`. The npm/curl installers some provider Dockerfiles
|
|
shell out to can silently no-op on a transient network failure —
|
|
e.g. an `optionalDependencies` fetch for a platform-native binary —
|
|
and the builder will then cache that broken layer indefinitely."""
|
|
info(
|
|
f"building image {ref} from {context} with Apple Container "
|
|
"(layer cache keeps repeat builds fast)"
|
|
)
|
|
_ensure_builder_dns()
|
|
args = [_CONTAINER, "build", "-t", ref, "--dns", dns_server()]
|
|
if os.environ.get("BOT_BOTTLE_NO_CACHE") == "1":
|
|
args.append("--no-cache")
|
|
if dockerfile:
|
|
# `container build` resolves -f relative to the current working
|
|
# directory, not the build context. Anchor a relative Dockerfile to
|
|
# the context so builds work from any cwd.
|
|
if not os.path.isabs(dockerfile):
|
|
dockerfile = os.path.join(context, dockerfile)
|
|
args.extend(["-f", dockerfile])
|
|
args.append(context)
|
|
subprocess.run(args, check=True)
|
|
|
|
|
|
def verify_agent_image(image: str, argv: tuple[str, ...]) -> None:
|
|
"""Run `argv` inside a throwaway container of a freshly built agent
|
|
image and die loudly if it fails, instead of shipping an image
|
|
whose CLI only breaks at first real use. No-op when the provider
|
|
hasn't declared a smoke test (`AgentProviderRuntime.smoke_test`)."""
|
|
if not argv:
|
|
return
|
|
result = subprocess.run(
|
|
[_CONTAINER, "run", "--rm", "--entrypoint", argv[0], image, *argv[1:]],
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
if result.returncode != 0:
|
|
detail = (result.stderr or result.stdout or "").strip()
|
|
die(
|
|
f"agent image {image!r} failed its post-build smoke test "
|
|
f"({' '.join(argv)}): {detail}\n"
|
|
f"Try rebuilding from scratch: bot-bottle start --no-cache"
|
|
)
|
|
|
|
|
|
def commit_container(container_name: str, image_tag: str) -> None:
|
|
"""Snapshot a running Apple Container as a local image.
|
|
|
|
`container export` requires a stopped container, but Apple Container
|
|
removes containers when they stop, making stop-then-export impossible.
|
|
Instead, exec into the running container as root and stream the root
|
|
filesystem out via tar, then build a new image from that archive.
|
|
The bottle continues running after commit.
|
|
"""
|
|
with tempfile.TemporaryDirectory(prefix="bot-bottle-container-commit.") as tmp:
|
|
rootfs_tar = os.path.join(tmp, "rootfs.tar")
|
|
dockerfile = os.path.join(tmp, "Dockerfile")
|
|
with open(rootfs_tar, "wb") as tar_out:
|
|
result = subprocess.run(
|
|
[
|
|
_CONTAINER, "exec",
|
|
"--user", "root",
|
|
container_name,
|
|
"tar", "--create",
|
|
"--exclude=./proc",
|
|
"--exclude=./sys",
|
|
"--exclude=./dev",
|
|
"--exclude=./run",
|
|
"--file=-",
|
|
"--directory=/",
|
|
".",
|
|
],
|
|
stdout=tar_out,
|
|
stderr=subprocess.PIPE,
|
|
check=False,
|
|
)
|
|
if result.returncode != 0:
|
|
die(
|
|
f"container exec tar {container_name!r} failed: "
|
|
f"{(result.stderr or b'').decode().strip() or '<no stderr>'}"
|
|
)
|
|
with open(dockerfile, "w", encoding="utf-8") as f:
|
|
f.write(
|
|
"FROM scratch\n"
|
|
"ADD rootfs.tar /\n"
|
|
"USER node\n"
|
|
"WORKDIR /home/node\n"
|
|
)
|
|
build_image(image_tag, tmp, dockerfile=dockerfile)
|
|
info(f"committed {container_name!r} → {image_tag!r}")
|
|
|
|
|
|
def _ensure_builder_dns() -> None:
|
|
dns = dns_server()
|
|
status = _builder_status()
|
|
override = os.environ.get("BOT_BOTTLE_MACOS_CONTAINER_DNS", "").strip()
|
|
if _builder_running(status) and _builder_resolves_build_hosts():
|
|
if override and not _builder_has_dns(status, dns):
|
|
_restart_builder_with_dns(dns)
|
|
return
|
|
_restart_builder_with_dns(dns)
|
|
|
|
|
|
def _restart_builder_with_dns(dns: str) -> None:
|
|
subprocess.run(
|
|
[_CONTAINER, "builder", "stop"],
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
check=False,
|
|
)
|
|
subprocess.run(
|
|
[_CONTAINER, "builder", "start", "--dns", dns],
|
|
check=True,
|
|
)
|
|
|
|
|
|
def _host_ipv4_dns() -> str:
|
|
if not is_macos():
|
|
return ""
|
|
result = subprocess.run(
|
|
["scutil", "--dns"],
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
if result.returncode != 0:
|
|
return ""
|
|
blocks: list[list[str]] = []
|
|
current: list[str] = []
|
|
for line in result.stdout.splitlines():
|
|
if line.startswith("resolver #") and current:
|
|
blocks.append(current)
|
|
current = []
|
|
current.append(line)
|
|
if current:
|
|
blocks.append(current)
|
|
for direct_only in (True, False):
|
|
for block in blocks:
|
|
text = "\n".join(block)
|
|
if direct_only and "Directly Reachable Address" not in text:
|
|
continue
|
|
for line in block:
|
|
if "nameserver[" not in line or ":" not in line:
|
|
continue
|
|
candidate = line.split(":", 1)[1].strip()
|
|
if _usable_ipv4(candidate):
|
|
return candidate
|
|
return ""
|
|
|
|
|
|
def _usable_ipv4(value: str) -> bool:
|
|
try:
|
|
address = ipaddress.ip_address(value)
|
|
except ValueError:
|
|
return False
|
|
return (
|
|
address.version == 4
|
|
and not address.is_loopback
|
|
and not address.is_link_local
|
|
and not address.is_multicast
|
|
and not address.is_unspecified
|
|
)
|
|
|
|
|
|
def _builder_status() -> list[dict[str, object]]:
|
|
result = subprocess.run(
|
|
[_CONTAINER, "builder", "status", "--format", "json"],
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
if result.returncode != 0:
|
|
return []
|
|
try:
|
|
data = json.loads(result.stdout or "[]")
|
|
except json.JSONDecodeError:
|
|
return []
|
|
if isinstance(data, list):
|
|
return [entry for entry in data if isinstance(entry, dict)]
|
|
if isinstance(data, dict):
|
|
return [data]
|
|
return []
|
|
|
|
|
|
def _builder_running(status: list[dict[str, object]]) -> bool:
|
|
for entry in status:
|
|
entry_status = entry.get("status")
|
|
if isinstance(entry_status, dict) and entry_status.get("state") == "running":
|
|
return True
|
|
return False
|
|
|
|
|
|
def _builder_dns_nameservers(status: list[dict[str, object]]) -> list[str]:
|
|
out: list[str] = []
|
|
for entry in status:
|
|
config = entry.get("configuration")
|
|
config_dns = config.get("dns") if isinstance(config, dict) else None
|
|
nameservers = (
|
|
config_dns.get("nameservers")
|
|
if isinstance(config_dns, dict)
|
|
else None
|
|
)
|
|
if not isinstance(nameservers, list):
|
|
continue
|
|
out.extend(name for name in nameservers if isinstance(name, str))
|
|
return out
|
|
|
|
|
|
def _builder_has_dns(status: list[dict[str, object]], dns: str) -> bool:
|
|
return dns in _builder_dns_nameservers(status)
|
|
|
|
|
|
def _builder_resolves_build_hosts() -> bool:
|
|
result = subprocess.run(
|
|
[_CONTAINER, "exec", "buildkit", "getent", "hosts", "deb.debian.org"],
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
check=False,
|
|
)
|
|
return result.returncode == 0
|
|
|
|
|
|
def image_exists(ref: str) -> bool:
|
|
return _silent_run([_CONTAINER, "image", "inspect", ref]) == 0
|
|
|
|
|
|
def container_exists(name: str) -> bool:
|
|
result = subprocess.run(
|
|
[_CONTAINER, "list", "--all", "--quiet"],
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
if result.returncode != 0:
|
|
return False
|
|
return name in {line.strip() for line in result.stdout.splitlines()}
|
|
|
|
|
|
def container_is_running(name: str) -> bool:
|
|
"""Return True if the named container is currently running.
|
|
|
|
`container list` without `--all` lists only running containers."""
|
|
result = subprocess.run(
|
|
[_CONTAINER, "list", "--quiet"],
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
if result.returncode != 0:
|
|
return False
|
|
return name in {line.strip() for line in result.stdout.splitlines()}
|
|
|
|
|
|
def stop_container(name: str) -> None:
|
|
"""Stop the named container without deleting it."""
|
|
result = subprocess.run(
|
|
[_CONTAINER, "stop", name],
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
if result.returncode != 0:
|
|
die(
|
|
f"container stop {name!r} failed: "
|
|
f"{(result.stderr or '').strip() or '<no stderr>'}"
|
|
)
|
|
|
|
|
|
def force_remove_container(name: str) -> None:
|
|
if container_exists(name):
|
|
subprocess.run(
|
|
[_CONTAINER, "delete", "--force", name],
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
check=False,
|
|
)
|
|
|
|
|
|
def copy_into_container(name: str, host_path: str, container_path: str) -> None:
|
|
cmd = [_CONTAINER, "cp", host_path, f"{name}:{container_path}"]
|
|
result = _run_container_op(cmd)
|
|
if result.returncode != 0:
|
|
die(
|
|
f"container cp into {name}:{container_path} failed: "
|
|
f"{(result.stderr or '').strip() or '<no stderr>'}"
|
|
)
|
|
|
|
|
|
def exec_container(name: str, argv: list[str]) -> None:
|
|
result = _run_container_op([_CONTAINER, "exec", name, *argv])
|
|
if result.returncode != 0:
|
|
die(
|
|
f"container exec in {name} failed: "
|
|
f"{(result.stderr or '').strip() or '<no stderr>'}"
|
|
)
|
|
|
|
|
|
def _run_container_op(cmd: list[str]) -> subprocess.CompletedProcess[str]:
|
|
result = subprocess.run(
|
|
cmd,
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
for _ in range(19):
|
|
if result.returncode == 0:
|
|
return result
|
|
time.sleep(0.1)
|
|
result = subprocess.run(
|
|
cmd,
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
return result
|
|
|
|
|
|
def create_network(name: str, *, internal: bool = False) -> None:
|
|
args = [
|
|
_CONTAINER, "network", "create",
|
|
"--label", "bot-bottle.backend=macos-container",
|
|
]
|
|
if internal:
|
|
args.append("--internal")
|
|
args.append(name)
|
|
result = subprocess.run(
|
|
args, capture_output=True, text=True, check=False,
|
|
)
|
|
if result.returncode == 0:
|
|
return
|
|
if "already exists" in (result.stderr or "").lower():
|
|
return
|
|
die(
|
|
f"container network create {name} failed: "
|
|
f"{(result.stderr or '').strip() or '<no stderr>'}"
|
|
)
|
|
|
|
|
|
def remove_network(name: str) -> None:
|
|
result = subprocess.run(
|
|
[_CONTAINER, "network", "delete", name],
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
check=False,
|
|
)
|
|
if result.returncode != 0:
|
|
return
|
|
|
|
|
|
def inspect_container(name: str) -> dict[str, object]:
|
|
result = subprocess.run(
|
|
[_CONTAINER, "inspect", name],
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
if result.returncode != 0:
|
|
die(
|
|
f"container inspect {name} failed: "
|
|
f"{(result.stderr or '').strip() or '<no stderr>'}"
|
|
)
|
|
try:
|
|
data = json.loads(result.stdout or "[]")
|
|
except json.JSONDecodeError as exc:
|
|
die(f"container inspect {name} returned malformed JSON: {exc}")
|
|
if isinstance(data, list) and data and isinstance(data[0], dict):
|
|
return data[0]
|
|
if isinstance(data, dict):
|
|
return data
|
|
die(f"container inspect {name} returned an unexpected shape")
|
|
raise AssertionError("unreachable")
|
|
|
|
|
|
def container_ipv4_on_network(name: str, network: str) -> str:
|
|
"""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], *, env: dict[str, str] | None = None,
|
|
) -> 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.
|
|
|
|
`env` sets the child process environment — used to hand a secret to a bare
|
|
`--env NAME` flag (Apple's "just key → inherit from host" form) so the
|
|
value is inherited from this process, never written onto argv or into
|
|
`container inspect`'s recorded command line."""
|
|
return subprocess.run(
|
|
argv, capture_output=True, text=True, check=False, env=env)
|
|
|
|
|
|
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:
|
|
return value.split(":", 1)[1] if ":" in value else value
|
|
|
|
|
|
def _inspect_first(argv: list[str]) -> dict[str, object]:
|
|
"""Run an inspect command and return its first JSON object, or {} on any
|
|
failure (non-zero exit, malformed JSON, unexpected shape). {} is the shared
|
|
'don't know' signal all the non-fatal inspect readers below build on — a
|
|
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:
|
|
return {}
|
|
try:
|
|
data = json.loads(result.stdout or "[]")
|
|
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 ""
|
|
descriptor = node.get("descriptor")
|
|
if isinstance(descriptor, dict) and descriptor.get("digest"):
|
|
return _normalize_digest(str(descriptor["digest"]))
|
|
return ""
|
|
|
|
|
|
def image_digest(ref: str) -> str:
|
|
"""The digest of image `ref`, or "" if it can't be read. Reads exactly the
|
|
field `container_image_digest` reads (`configuration.descriptor.digest`) so
|
|
the two are comparable; "" means 'don't know' → callers don't churn."""
|
|
data = _inspect_first([_CONTAINER, "image", "inspect", ref])
|
|
return _descriptor_digest(data.get("configuration"))
|
|
|
|
|
|
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."""
|
|
config = _inspect_first([_CONTAINER, "inspect", name]).get("configuration")
|
|
image = config.get("image") if isinstance(config, dict) else None
|
|
return _descriptor_digest(image)
|
|
|
|
|
|
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."""
|
|
config = _inspect_first([_CONTAINER, "inspect", name]).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."""
|
|
status = _inspect_first([_CONTAINER, "inspect", name]).get("status")
|
|
networks = status.get("networks") if isinstance(status, dict) else None
|
|
if not isinstance(networks, list):
|
|
return ""
|
|
for entry in networks:
|
|
if not isinstance(entry, dict) or entry.get("network") != network:
|
|
continue
|
|
raw = entry.get("ipv4Address")
|
|
if isinstance(raw, str) and raw:
|
|
return raw.split("/", 1)[0]
|
|
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:
|
|
"""Return the image digest/ID from `container image inspect`.
|
|
|
|
The command returns JSON on current Apple Container releases. Keep
|
|
parsing narrow and fatal so callers do not cache on an empty key.
|
|
"""
|
|
import json
|
|
|
|
result = subprocess.run(
|
|
[_CONTAINER, "image", "inspect", ref],
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
if result.returncode != 0:
|
|
die(
|
|
f"container image inspect for {ref!r} failed: "
|
|
f"{(result.stderr or '').strip() or '<no stderr>'}"
|
|
)
|
|
try:
|
|
data = json.loads(result.stdout or "{}")
|
|
except json.JSONDecodeError as exc:
|
|
die(f"container image inspect for {ref!r} returned malformed JSON: {exc}")
|
|
if isinstance(data, list) and data:
|
|
data = data[0]
|
|
if isinstance(data, dict):
|
|
value = data.get("id") or data.get("digest") or data.get("ID")
|
|
if value:
|
|
return str(value)
|
|
die(f"container image inspect for {ref!r} did not include an image id")
|
|
raise AssertionError("unreachable")
|
|
|
|
|
|
def save(ref: str, output: str) -> None:
|
|
subprocess.run([_CONTAINER, "image", "save", ref, "-o", output], check=True)
|
|
|
|
|
|
def _silent_run(cmd: Iterable[str]) -> int:
|
|
return subprocess.run(
|
|
list(cmd),
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
check=False,
|
|
).returncode
|