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)."""
|
||||
|
||||
Reference in New Issue
Block a user