Phase 13: multi-host networks & images (0.14.0)
Networks and Images are now per-host, rendered as a section for the local host
plus one per registered agent (like the Stacks page).
- agent_app.py: new /agent/networks (list/inspect/containers/connect/disconnect/
create/delete/prune) and /agent/images (list/updates/check), reusing
network_service and a new image_service; DockerError mapped to HTTP status
(forbidden -> 400 so the proxy doesn't treat it as a token failure).
- routers/agents.py: proxy routes at /api/agents/{id}/networks/* and
/api/agents/{id}/images/*, audit-logging mutations.
- services/image_service.py: extracted the image-listing logic so the central
router and the agent share it.
- Frontend: networksApi/imagesApi take an optional agentId; Networks/Images
pages render NetworksSection/ImagesSection per host with a shared HostHeader.
Remote "Prune unused" networks resolves the address-pool-exhaustion deploy
error from the UI.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
4bc0fb8901
commit
012614f5fb
+120
-2
@@ -36,11 +36,28 @@ from pydantic import BaseModel
|
||||
|
||||
from config import settings
|
||||
from docker_client import DockerError, get_client, safe_call
|
||||
from services import backup_service, compose_service
|
||||
from services import (
|
||||
backup_service,
|
||||
compose_service,
|
||||
image_service,
|
||||
network_service,
|
||||
update_service,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("stackpilot.agent")
|
||||
|
||||
AGENT_VERSION = "0.13.2"
|
||||
# Map network_service's DockerError codes to HTTP status. forbidden is mapped to
|
||||
# 400 (not 403) so the central proxy doesn't misread it as a token failure.
|
||||
_DOCKER_STATUS = {"invalid_request": 400, "forbidden": 400, "not_found": 404}
|
||||
|
||||
|
||||
def _map_docker(exc: DockerError):
|
||||
code = _DOCKER_STATUS.get(exc.error)
|
||||
if code:
|
||||
raise HTTPException(status_code=code, detail=exc.detail or exc.error)
|
||||
raise exc # falls through to the global 502 DockerError handler
|
||||
|
||||
AGENT_VERSION = "0.14.0"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -67,6 +84,21 @@ class StackBody(BaseModel):
|
||||
env: str | None = None
|
||||
|
||||
|
||||
class NetworkCreateBody(BaseModel):
|
||||
name: str
|
||||
driver: str = "bridge"
|
||||
subnet: str | None = None
|
||||
gateway: str | None = None
|
||||
internal: bool = False
|
||||
attachable: bool = True
|
||||
|
||||
|
||||
class ContainerRefBody(BaseModel):
|
||||
container: str
|
||||
aliases: list[str] | None = None
|
||||
force: bool = False
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Helpers
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -288,6 +320,92 @@ async def restore_stack(
|
||||
os.unlink(tmp.name)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Networks
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@app.get("/agent/networks", dependencies=[Depends(verify_token)])
|
||||
def list_networks() -> list[dict]:
|
||||
return network_service.list_networks()
|
||||
|
||||
|
||||
@app.get("/agent/networks/{network_id}", dependencies=[Depends(verify_token)])
|
||||
def inspect_network(network_id: str) -> dict:
|
||||
try:
|
||||
return network_service.inspect_network(network_id)
|
||||
except DockerError as exc:
|
||||
_map_docker(exc)
|
||||
|
||||
|
||||
@app.get("/agent/networks/{network_id}/containers", dependencies=[Depends(verify_token)])
|
||||
def network_containers(network_id: str) -> list[dict]:
|
||||
try:
|
||||
return network_service.connectable_containers(network_id)
|
||||
except DockerError as exc:
|
||||
_map_docker(exc)
|
||||
|
||||
|
||||
@app.post("/agent/networks/{network_id}/connect", dependencies=[Depends(verify_token)])
|
||||
def connect_container(network_id: str, body: ContainerRefBody) -> dict:
|
||||
try:
|
||||
network_service.connect_container(network_id, body.container, body.aliases)
|
||||
except DockerError as exc:
|
||||
_map_docker(exc)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.post("/agent/networks/{network_id}/disconnect", dependencies=[Depends(verify_token)])
|
||||
def disconnect_container(network_id: str, body: ContainerRefBody) -> dict:
|
||||
try:
|
||||
network_service.disconnect_container(network_id, body.container, body.force)
|
||||
except DockerError as exc:
|
||||
_map_docker(exc)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.post("/agent/networks", dependencies=[Depends(verify_token)], status_code=201)
|
||||
def create_network(body: NetworkCreateBody) -> dict:
|
||||
try:
|
||||
return network_service.create_network(body.model_dump())
|
||||
except DockerError as exc:
|
||||
_map_docker(exc)
|
||||
|
||||
|
||||
@app.delete("/agent/networks/{network_id}", dependencies=[Depends(verify_token)])
|
||||
def delete_network(network_id: str) -> dict:
|
||||
try:
|
||||
network_service.delete_network(network_id)
|
||||
except DockerError as exc:
|
||||
_map_docker(exc)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.post("/agent/networks/prune", dependencies=[Depends(verify_token)])
|
||||
def prune_networks() -> dict:
|
||||
return network_service.prune_networks()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Images
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@app.get("/agent/images", dependencies=[Depends(verify_token)])
|
||||
def list_images() -> list[dict]:
|
||||
return image_service.list_images()
|
||||
|
||||
|
||||
@app.get("/agent/images/updates", dependencies=[Depends(verify_token)])
|
||||
def image_updates() -> dict:
|
||||
return update_service.get_cache()
|
||||
|
||||
|
||||
@app.post("/agent/images/check", dependencies=[Depends(verify_token)])
|
||||
async def image_check() -> dict:
|
||||
return await update_service.check_all()
|
||||
|
||||
|
||||
@app.websocket("/agent/ws/logs/{stack_id}")
|
||||
async def ws_logs(websocket: WebSocket, stack_id: str, token: str | None = Query(default=None)):
|
||||
"""Stream `docker compose logs -f` to the central app (token via query param)."""
|
||||
|
||||
+1
-1
@@ -55,7 +55,7 @@ async def lifespan(app: FastAPI):
|
||||
schedule_task.cancel()
|
||||
|
||||
|
||||
app = FastAPI(title="StackPilot", version="0.13.2", lifespan=lifespan)
|
||||
app = FastAPI(title="StackPilot", version="0.14.0", lifespan=lifespan)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
|
||||
@@ -17,6 +17,7 @@ from models.agent import Agent, AgentCreate, AgentRead, AgentUpdate
|
||||
from models.backup_destination import BackupDestination
|
||||
from models.stack import StackCreate, StackUpdate
|
||||
from models.user import User
|
||||
from routers.networks import ContainerRef, NetworkCreate
|
||||
from services import (
|
||||
agent_service,
|
||||
audit_service,
|
||||
@@ -473,3 +474,171 @@ async def agent_restore_from(
|
||||
ip=_ip(request),
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Networks (proxied)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@router.get("/{agent_id}/networks")
|
||||
async def agent_networks(
|
||||
agent_id: int,
|
||||
session: Session = Depends(get_session),
|
||||
_user: User = Depends(get_current_user),
|
||||
) -> list[dict]:
|
||||
agent = _get_or_404(session, agent_id)
|
||||
return await _proxy(session, agent, "GET", "/agent/networks") or []
|
||||
|
||||
|
||||
@router.get("/{agent_id}/networks/{network_id}")
|
||||
async def agent_network_inspect(
|
||||
agent_id: int,
|
||||
network_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/networks/{network_id}")
|
||||
|
||||
|
||||
@router.get("/{agent_id}/networks/{network_id}/containers")
|
||||
async def agent_network_containers(
|
||||
agent_id: int,
|
||||
network_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
_user: User = Depends(get_current_user),
|
||||
) -> list[dict]:
|
||||
agent = _get_or_404(session, agent_id)
|
||||
return await _proxy(session, agent, "GET", f"/agent/networks/{network_id}/containers") or []
|
||||
|
||||
|
||||
@router.post("/{agent_id}/networks/prune")
|
||||
async def agent_network_prune(
|
||||
agent_id: int,
|
||||
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", "/agent/networks/prune")
|
||||
audit_service.record(
|
||||
session, user=user.username, action="agent.network.prune", target=agent.name,
|
||||
detail=str(result.get("NetworksDeleted") or []), ip=_ip(request),
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/{agent_id}/networks", status_code=201)
|
||||
async def agent_network_create(
|
||||
agent_id: int,
|
||||
body: NetworkCreate,
|
||||
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", "/agent/networks", json=body.model_dump())
|
||||
audit_service.record(
|
||||
session, user=user.username, action="agent.network.create",
|
||||
target=f"{agent.name}/{body.name}", ip=_ip(request),
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.delete("/{agent_id}/networks/{network_id}")
|
||||
async def agent_network_delete(
|
||||
agent_id: int,
|
||||
network_id: 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, "DELETE", f"/agent/networks/{network_id}")
|
||||
audit_service.record(
|
||||
session, user=user.username, action="agent.network.delete",
|
||||
target=f"{agent.name}/{network_id}", ip=_ip(request),
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/{agent_id}/networks/{network_id}/connect")
|
||||
async def agent_network_connect(
|
||||
agent_id: int,
|
||||
network_id: str,
|
||||
body: ContainerRef,
|
||||
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/networks/{network_id}/connect", json=body.model_dump()
|
||||
)
|
||||
audit_service.record(
|
||||
session, user=user.username, action="agent.network.connect",
|
||||
target=f"{agent.name}/{network_id}", detail=body.container, ip=_ip(request),
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/{agent_id}/networks/{network_id}/disconnect")
|
||||
async def agent_network_disconnect(
|
||||
agent_id: int,
|
||||
network_id: str,
|
||||
body: ContainerRef,
|
||||
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/networks/{network_id}/disconnect", json=body.model_dump()
|
||||
)
|
||||
audit_service.record(
|
||||
session, user=user.username, action="agent.network.disconnect",
|
||||
target=f"{agent.name}/{network_id}", detail=body.container, ip=_ip(request),
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Images (proxied)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@router.get("/{agent_id}/images")
|
||||
async def agent_images(
|
||||
agent_id: int,
|
||||
session: Session = Depends(get_session),
|
||||
_user: User = Depends(get_current_user),
|
||||
) -> list[dict]:
|
||||
agent = _get_or_404(session, agent_id)
|
||||
return await _proxy(session, agent, "GET", "/agent/images") or []
|
||||
|
||||
|
||||
@router.get("/{agent_id}/images/updates")
|
||||
async def agent_image_updates(
|
||||
agent_id: int,
|
||||
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", "/agent/images/updates")
|
||||
|
||||
|
||||
@router.post("/{agent_id}/images/check")
|
||||
async def agent_image_check(
|
||||
agent_id: int,
|
||||
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", "/agent/images/check")
|
||||
audit_service.record(
|
||||
session, user=user.username, action="agent.image.check", target=agent.name,
|
||||
ip=_ip(request),
|
||||
)
|
||||
return result
|
||||
|
||||
@@ -4,54 +4,15 @@ from __future__ import annotations
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from auth import get_current_user, require_admin
|
||||
from docker_client import DockerError, get_client, safe_call
|
||||
from models.user import User
|
||||
from services import update_service
|
||||
from services import image_service, update_service
|
||||
|
||||
router = APIRouter(prefix="/api/images", tags=["images"])
|
||||
|
||||
COMPOSE_PROJECT_LABEL = "com.docker.compose.project"
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_images(_user: User = Depends(get_current_user)) -> list[dict]:
|
||||
try:
|
||||
client = get_client()
|
||||
images = safe_call(client.images.list)
|
||||
containers = safe_call(client.containers.list, all=True)
|
||||
except DockerError:
|
||||
return []
|
||||
|
||||
# Map image ref -> stacks using it.
|
||||
usage: dict[str, set[str]] = {}
|
||||
for c in containers:
|
||||
ref = c.attrs.get("Config", {}).get("Image")
|
||||
stack = c.labels.get(COMPOSE_PROJECT_LABEL)
|
||||
if ref:
|
||||
usage.setdefault(ref, set())
|
||||
if stack:
|
||||
usage[ref].add(stack)
|
||||
|
||||
cache = update_service.get_cache()
|
||||
result = []
|
||||
for img in images:
|
||||
tags = img.tags or []
|
||||
if not tags:
|
||||
continue
|
||||
for tag in tags:
|
||||
upd = cache.get(tag)
|
||||
result.append(
|
||||
{
|
||||
"id": img.short_id,
|
||||
"tag": tag,
|
||||
"size": img.attrs.get("Size", 0),
|
||||
"created": img.attrs.get("Created"),
|
||||
"stacks": sorted(usage.get(tag, set())),
|
||||
"update": upd,
|
||||
}
|
||||
)
|
||||
result.sort(key=lambda r: r["tag"])
|
||||
return result
|
||||
return image_service.list_images()
|
||||
|
||||
|
||||
@router.get("/updates")
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Image listing — shared by the central images router and the agent."""
|
||||
from __future__ import annotations
|
||||
|
||||
from docker_client import DockerError, get_client, safe_call
|
||||
from services import update_service
|
||||
|
||||
COMPOSE_PROJECT_LABEL = "com.docker.compose.project"
|
||||
|
||||
|
||||
def list_images() -> list[dict]:
|
||||
"""Return one row per image tag, annotated with using-stacks + update status."""
|
||||
try:
|
||||
client = get_client()
|
||||
images = safe_call(client.images.list)
|
||||
containers = safe_call(client.containers.list, all=True)
|
||||
except DockerError:
|
||||
return []
|
||||
|
||||
# Map image ref -> stacks using it.
|
||||
usage: dict[str, set[str]] = {}
|
||||
for c in containers:
|
||||
ref = c.attrs.get("Config", {}).get("Image")
|
||||
stack = c.labels.get(COMPOSE_PROJECT_LABEL)
|
||||
if ref:
|
||||
usage.setdefault(ref, set())
|
||||
if stack:
|
||||
usage[ref].add(stack)
|
||||
|
||||
cache = update_service.get_cache()
|
||||
result = []
|
||||
for img in images:
|
||||
tags = img.tags or []
|
||||
if not tags:
|
||||
continue
|
||||
for tag in tags:
|
||||
result.append(
|
||||
{
|
||||
"id": img.short_id,
|
||||
"tag": tag,
|
||||
"size": img.attrs.get("Size", 0),
|
||||
"created": img.attrs.get("Created"),
|
||||
"stacks": sorted(usage.get(tag, set())),
|
||||
"update": cache.get(tag),
|
||||
}
|
||||
)
|
||||
result.sort(key=lambda r: r["tag"])
|
||||
return result
|
||||
Reference in New Issue
Block a user