Stack Overview now renders each service as an expandable ContainerCard with a
curated single-container inspect view and admin start/stop/restart buttons,
both for local stacks (GET/POST /api/containers/{id}[/{action}]) and remote
stacks (proxied via /api/agents/{id}/containers/* to the agent's new
/agent/containers/* endpoints). Only compose-managed containers are exposed.
Also bumps version 0.23.0 -> 0.26.0 (the bumps for the already-committed
Phase 18 image-prune / Phase 19 compose-validate were missed) and backfills
README sections for Phase 18/19/20.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
93 lines
3.2 KiB
Python
93 lines
3.2 KiB
Python
"""Single-container inspect + lifecycle — shared by the central app and agent.
|
|
|
|
Only containers that belong to a compose-managed stack (i.e. carry the
|
|
``com.docker.compose.project`` label) are exposed, so this never becomes a
|
|
generic "control any container on the host" backdoor.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from docker_client import DockerError, get_client, safe_call
|
|
|
|
COMPOSE_LABEL = "com.docker.compose.project"
|
|
SERVICE_LABEL = "com.docker.compose.service"
|
|
|
|
ACTIONS = {"start", "stop", "restart"}
|
|
|
|
|
|
def _get_managed(container_id: str):
|
|
client = get_client()
|
|
container = safe_call(client.containers.get, container_id)
|
|
if COMPOSE_LABEL not in (container.labels or {}):
|
|
raise DockerError("not_managed", "container is not part of a managed stack")
|
|
return container
|
|
|
|
|
|
def inspect_container(container_id: str) -> dict:
|
|
"""Return a curated inspect view for a single managed container."""
|
|
c = _get_managed(container_id)
|
|
attrs = c.attrs
|
|
state = attrs.get("State", {}) or {}
|
|
config = attrs.get("Config", {}) or {}
|
|
health = (state.get("Health") or {}).get("Status")
|
|
network_settings = attrs.get("NetworkSettings", {}) or {}
|
|
networks = network_settings.get("Networks", {}) or {}
|
|
|
|
mounts = []
|
|
for m in attrs.get("Mounts", []) or []:
|
|
mounts.append(
|
|
{
|
|
"type": m.get("Type"),
|
|
"source": m.get("Source") or m.get("Name"),
|
|
"destination": m.get("Destination"),
|
|
"mode": m.get("Mode"),
|
|
"rw": m.get("RW"),
|
|
}
|
|
)
|
|
|
|
ports = []
|
|
for container_port, bindings in (network_settings.get("Ports") or {}).items():
|
|
if bindings:
|
|
for b in bindings:
|
|
ports.append(
|
|
{"container": container_port, "host_ip": b.get("HostIp"), "host_port": b.get("HostPort")}
|
|
)
|
|
else:
|
|
ports.append({"container": container_port, "host_port": None})
|
|
|
|
return {
|
|
"id": c.id,
|
|
"name": c.name,
|
|
"service": c.labels.get(SERVICE_LABEL, c.name),
|
|
"stack": c.labels.get(COMPOSE_LABEL),
|
|
"image": config.get("Image", "") or attrs.get("Image", ""),
|
|
"command": config.get("Cmd"),
|
|
"entrypoint": config.get("Entrypoint"),
|
|
"state": state.get("Status", c.status),
|
|
"status": state.get("Status", c.status),
|
|
"health": health,
|
|
"restart_count": attrs.get("RestartCount", 0),
|
|
"exit_code": state.get("ExitCode"),
|
|
"created": attrs.get("Created"),
|
|
"started_at": state.get("StartedAt"),
|
|
"finished_at": state.get("FinishedAt"),
|
|
"env": config.get("Env") or [],
|
|
"mounts": mounts,
|
|
"ports": ports,
|
|
"networks": sorted(networks.keys()),
|
|
"labels": c.labels or {},
|
|
}
|
|
|
|
|
|
def container_action(container_id: str, action: str) -> dict:
|
|
"""Start / stop / restart a single managed container."""
|
|
if action not in ACTIONS:
|
|
raise DockerError("bad_action", f"unsupported action '{action}'")
|
|
c = _get_managed(container_id)
|
|
if action == "start":
|
|
safe_call(c.start)
|
|
elif action == "stop":
|
|
safe_call(c.stop)
|
|
else:
|
|
safe_call(c.restart)
|
|
return {"ok": True, "action": action, "id": c.id}
|