Files
stackpilot/backend/services/container_service.py
T
menzeljandClaude Opus 4.8 2f63247fc1 Phase 20: per-container inspect + start/stop/restart, local + agent (0.26.0)
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>
2026-06-09 12:12:58 +00:00

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}