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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user