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>
This commit is contained in:
menzelj
2026-06-09 12:12:58 +00:00
co-authored by Claude Opus 4.8
parent 9c4d319f8f
commit 2f63247fc1
11 changed files with 460 additions and 38 deletions
+17 -1
View File
@@ -40,6 +40,7 @@ from docker_client import DockerError, get_client, safe_call
from services import (
backup_service,
compose_service,
container_service,
device_service,
file_service,
image_service,
@@ -62,7 +63,7 @@ def _map_docker(exc: DockerError):
raise HTTPException(status_code=code, detail=exc.detail or exc.error)
raise exc # falls through to the global 502 DockerError handler
AGENT_VERSION = "0.23.0"
AGENT_VERSION = "0.26.0"
# --------------------------------------------------------------------------- #
@@ -504,6 +505,21 @@ def image_prune(all_unused: bool = Query(False, alias="all")) -> dict:
return image_service.prune_images(all_unused)
# --------------------------------------------------------------------------- #
# Containers (single-container inspect + lifecycle)
# --------------------------------------------------------------------------- #
@app.get("/agent/containers/{container_id}", dependencies=[Depends(verify_token)])
def inspect_container(container_id: str) -> dict:
return container_service.inspect_container(container_id)
@app.post("/agent/containers/{container_id}/{action}", dependencies=[Depends(verify_token)])
def container_action(container_id: str, action: str) -> dict:
return container_service.container_action(container_id, action)
# --------------------------------------------------------------------------- #
# Volumes
# --------------------------------------------------------------------------- #
+3 -1
View File
@@ -18,6 +18,7 @@ from routers import (
audit,
auth,
backups,
containers,
destinations,
editor,
files,
@@ -55,7 +56,7 @@ async def lifespan(app: FastAPI):
schedule_task.cancel()
app = FastAPI(title="StackPilot", version="0.23.0", lifespan=lifespan)
app = FastAPI(title="StackPilot", version="0.26.0", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
@@ -76,6 +77,7 @@ async def docker_error_handler(_request: Request, exc: DockerError):
app.include_router(auth.router)
app.include_router(stacks.router)
app.include_router(containers.router)
app.include_router(system.router)
app.include_router(volumes.router)
app.include_router(editor.router)
+36
View File
@@ -947,3 +947,39 @@ async def agent_files_upload(
target=f"{agent.name}:{path}", detail=rel_path or file.filename, ip=_ip(request),
)
return result
# --------------------------------------------------------------------------- #
# Containers (proxied)
# --------------------------------------------------------------------------- #
@router.get("/{agent_id}/containers/{container_id}")
async def agent_container_inspect(
agent_id: int,
container_id: str,
session: Session = Depends(get_session),
_user: User = Depends(get_current_user),
) -> dict:
agent = _get_or_404(session, agent_id)
return await _proxy(session, agent, "GET", f"/agent/containers/{container_id}")
@router.post("/{agent_id}/containers/{container_id}/{action}")
async def agent_container_action(
agent_id: int,
container_id: str,
action: str,
request: Request,
session: Session = Depends(get_session),
user: User = Depends(require_admin),
) -> dict:
agent = _get_or_404(session, agent_id)
result = await _proxy(
session, agent, "POST", f"/agent/containers/{container_id}/{action}"
)
audit_service.record(
session, user=user.username, action=f"agent.container.{action}",
target=f"{agent.name}/{container_id[:12]}", ip=_ip(request),
)
return result
+37
View File
@@ -0,0 +1,37 @@
"""Single-container inspect + lifecycle for compose-managed containers."""
from __future__ import annotations
from fastapi import APIRouter, Depends, Request
from sqlmodel import Session
from auth import get_current_user, require_admin
from database import get_session
from models.user import User
from services import audit_service, container_service
router = APIRouter(prefix="/api/containers", tags=["containers"])
def _ip(request: Request) -> str:
return request.client.host if request.client else "unknown"
@router.get("/{container_id}")
def inspect(container_id: str, _user: User = Depends(get_current_user)) -> dict:
return container_service.inspect_container(container_id)
@router.post("/{container_id}/{action}")
def action(
container_id: str,
action: str,
request: Request,
session: Session = Depends(get_session),
user: User = Depends(require_admin),
) -> dict:
result = container_service.container_action(container_id, action)
audit_service.record(
session, user=user.username, action=f"container.{action}",
target=container_id[:12], ip=_ip(request),
)
return result
+92
View File
@@ -0,0 +1,92 @@
"""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}