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:
co-authored by
Claude Opus 4.8
parent
9c4d319f8f
commit
2f63247fc1
+17
-1
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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}
|
||||
Reference in New Issue
Block a user