490 lines
17 KiB
Python
490 lines
17 KiB
Python
"""Docker management API routes."""
|
|
from typing import Optional
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.core.dependencies import get_db, verify_api_key, get_current_user, require_admin
|
|
from app.crud.docker_container import DockerContainerRepository
|
|
from app.crud.docker_image import DockerImageRepository
|
|
from app.crud.docker_volume import DockerVolumeRepository
|
|
from app.crud.docker_alert import DockerAlertRepository
|
|
from app.crud.host import HostRepository
|
|
from app.schemas.docker import (
|
|
DockerHostListResponse,
|
|
DockerHostInfo,
|
|
EnableDockerRequest,
|
|
DockerContainerListResponse,
|
|
DockerContainerResponse,
|
|
DockerContainerAggregatedResponse,
|
|
DockerContainerAggregatedListResponse,
|
|
DockerImageListResponse,
|
|
DockerImageExtendedListResponse,
|
|
DockerImageExtendedResponse,
|
|
DockerVolumeListResponse,
|
|
DockerAlertListResponse,
|
|
DockerAlertAcknowledgeRequest,
|
|
ContainerActionResponse,
|
|
ContainerLogsRequest,
|
|
ContainerLogsResponse,
|
|
ContainerInspectResponse,
|
|
DockerCollectResponse,
|
|
DockerCollectAllResponse,
|
|
DockerStatsResponse,
|
|
ImageActionResponse,
|
|
)
|
|
from app.services.docker_service import docker_service
|
|
from app.services.docker_actions import docker_actions
|
|
from app.services.docker_alerts import docker_alerts_service
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
# === Docker Hosts ===
|
|
|
|
@router.get("/hosts", response_model=DockerHostListResponse)
|
|
async def list_docker_hosts(
|
|
api_key_valid: bool = Depends(verify_api_key),
|
|
):
|
|
"""List all hosts with Docker information."""
|
|
hosts = await docker_service.get_docker_hosts()
|
|
enabled = sum(1 for h in hosts if h.get("docker_enabled"))
|
|
|
|
return DockerHostListResponse(
|
|
hosts=[DockerHostInfo(**h) for h in hosts],
|
|
total=len(hosts),
|
|
enabled=enabled
|
|
)
|
|
|
|
|
|
@router.post("/hosts/{host_id}/enable")
|
|
async def enable_docker_monitoring(
|
|
host_id: str,
|
|
request: EnableDockerRequest = None,
|
|
api_key_valid: bool = Depends(verify_api_key),
|
|
):
|
|
"""Enable or disable Docker monitoring on a host."""
|
|
enabled = request.enabled if request else True
|
|
success = await docker_service.enable_docker_monitoring(host_id, enabled)
|
|
|
|
if not success:
|
|
raise HTTPException(status_code=404, detail="Host not found")
|
|
|
|
action = "enabled" if enabled else "disabled"
|
|
return {"message": f"Docker monitoring {action} for host", "host_id": host_id}
|
|
|
|
|
|
@router.post("/hosts/{host_id}/collect", response_model=DockerCollectResponse)
|
|
async def collect_docker_now(
|
|
host_id: str,
|
|
api_key_valid: bool = Depends(verify_api_key),
|
|
):
|
|
"""Force an immediate Docker collection on a host."""
|
|
result = await docker_service.collect_docker_host(host_id)
|
|
|
|
return DockerCollectResponse(
|
|
success=result.get("success", False),
|
|
host_id=host_id,
|
|
host_name=result.get("host_name", ""),
|
|
message="Collection completed" if result.get("success") else "Collection failed",
|
|
docker_version=result.get("docker_version"),
|
|
containers_count=result.get("containers_count", 0),
|
|
images_count=result.get("images_count", 0),
|
|
volumes_count=result.get("volumes_count", 0),
|
|
duration_ms=result.get("duration_ms", 0),
|
|
error=result.get("error")
|
|
)
|
|
|
|
|
|
@router.post("/collect-all", response_model=DockerCollectAllResponse)
|
|
async def collect_all_docker_hosts(
|
|
api_key_valid: bool = Depends(verify_api_key),
|
|
):
|
|
"""Collect Docker info from all enabled hosts."""
|
|
result = await docker_service.collect_all_hosts()
|
|
|
|
normalized_results = []
|
|
for r in result.get("results", []):
|
|
if not isinstance(r, dict):
|
|
continue
|
|
normalized_results.append({
|
|
"success": bool(r.get("success", False)),
|
|
"host_id": r.get("host_id", ""),
|
|
"host_name": r.get("host_name", ""),
|
|
"message": r.get("message") or ("Collection completed" if r.get("success") else "Collection failed"),
|
|
"docker_version": r.get("docker_version"),
|
|
"containers_count": r.get("containers_count", 0),
|
|
"images_count": r.get("images_count", 0),
|
|
"volumes_count": r.get("volumes_count", 0),
|
|
"duration_ms": r.get("duration_ms", 0),
|
|
"error": r.get("error"),
|
|
})
|
|
|
|
return DockerCollectAllResponse(
|
|
success=result.get("success", False),
|
|
message=f"Collected from {result.get('successful', 0)}/{result.get('total_hosts', 0)} hosts",
|
|
total_hosts=result.get("total_hosts", 0),
|
|
successful=result.get("successful", 0),
|
|
failed=result.get("failed", 0),
|
|
results=[DockerCollectResponse(**r) for r in normalized_results]
|
|
)
|
|
|
|
|
|
# === Containers ===
|
|
|
|
@router.get("/containers", response_model=DockerContainerAggregatedListResponse)
|
|
async def get_all_containers(
|
|
state: Optional[str] = Query(None, description="Filter by state (running, exited, paused, etc.)"),
|
|
compose_project: Optional[str] = Query(None, description="Filter by compose project"),
|
|
health: Optional[str] = Query(None, description="Filter by health status"),
|
|
host_id: Optional[str] = Query(None, description="Filter by specific host"),
|
|
api_key_valid: bool = Depends(verify_api_key),
|
|
db_session: AsyncSession = Depends(get_db),
|
|
):
|
|
"""List all containers across all Docker hosts."""
|
|
container_repo = DockerContainerRepository(db_session)
|
|
host_repo = HostRepository(db_session)
|
|
|
|
# Get host filter if provided
|
|
host_ids = [host_id] if host_id else None
|
|
|
|
# Fetch containers
|
|
containers = await container_repo.list_all(
|
|
state=state,
|
|
compose_project=compose_project,
|
|
health=health,
|
|
host_ids=host_ids
|
|
)
|
|
counts = await container_repo.count_all()
|
|
|
|
# Build host lookup for names/IPs
|
|
hosts = await host_repo.list_all()
|
|
host_map = {h.id: {"name": h.name, "ip": h.ip_address} for h in hosts}
|
|
|
|
# Build response with host info
|
|
container_responses = []
|
|
for c in containers:
|
|
host_info = host_map.get(c.host_id, {"name": "Unknown", "ip": "Unknown"})
|
|
container_responses.append(DockerContainerAggregatedResponse(
|
|
id=c.id,
|
|
host_id=c.host_id,
|
|
host_name=host_info["name"],
|
|
host_ip=host_info["ip"],
|
|
container_id=c.container_id,
|
|
name=c.name,
|
|
image=c.image,
|
|
state=c.state,
|
|
status=c.status,
|
|
health=c.health,
|
|
ports=c.ports,
|
|
labels=c.labels,
|
|
compose_project=c.compose_project,
|
|
created_at=c.created_at,
|
|
last_update_at=c.last_update_at
|
|
))
|
|
|
|
return DockerContainerAggregatedListResponse(
|
|
containers=container_responses,
|
|
total=counts.get("total", 0),
|
|
running=counts.get("running", 0),
|
|
stopped=counts.get("stopped", 0),
|
|
paused=counts.get("paused", 0),
|
|
hosts_count=counts.get("hosts_count", 0),
|
|
last_update=counts.get("last_update")
|
|
)
|
|
|
|
|
|
@router.get("/hosts/{host_id}/containers", response_model=DockerContainerListResponse)
|
|
async def get_containers(
|
|
host_id: str,
|
|
state: Optional[str] = Query(None, description="Filter by state"),
|
|
compose_project: Optional[str] = Query(None, description="Filter by compose project"),
|
|
api_key_valid: bool = Depends(verify_api_key),
|
|
db_session: AsyncSession = Depends(get_db),
|
|
):
|
|
"""List containers for a host."""
|
|
container_repo = DockerContainerRepository(db_session)
|
|
containers = await container_repo.list_by_host(host_id, state=state, compose_project=compose_project)
|
|
counts = await container_repo.count_by_host(host_id)
|
|
|
|
return DockerContainerListResponse(
|
|
containers=[DockerContainerResponse(
|
|
id=c.id,
|
|
host_id=c.host_id,
|
|
container_id=c.container_id,
|
|
name=c.name,
|
|
image=c.image,
|
|
state=c.state,
|
|
status=c.status,
|
|
health=c.health,
|
|
ports=c.ports,
|
|
labels=c.labels,
|
|
compose_project=c.compose_project,
|
|
created_at=c.created_at,
|
|
last_update_at=c.last_update_at
|
|
) for c in containers],
|
|
total=counts.get("total", 0),
|
|
running=counts.get("running", 0),
|
|
stopped=counts.get("total", 0) - counts.get("running", 0)
|
|
)
|
|
|
|
|
|
@router.post("/containers/{host_id}/{container_id}/start", response_model=ContainerActionResponse)
|
|
async def start_container(
|
|
host_id: str,
|
|
container_id: str,
|
|
user: dict = Depends(get_current_user),
|
|
):
|
|
"""Start a stopped container."""
|
|
result = await docker_actions.start_container(host_id, container_id)
|
|
return ContainerActionResponse(**result)
|
|
|
|
|
|
@router.post("/containers/{host_id}/{container_id}/stop", response_model=ContainerActionResponse)
|
|
async def stop_container(
|
|
host_id: str,
|
|
container_id: str,
|
|
timeout: int = Query(10, ge=1, le=300),
|
|
user: dict = Depends(get_current_user),
|
|
):
|
|
"""Stop a running container."""
|
|
result = await docker_actions.stop_container(host_id, container_id, timeout=timeout)
|
|
return ContainerActionResponse(**result)
|
|
|
|
|
|
@router.post("/containers/{host_id}/{container_id}/restart", response_model=ContainerActionResponse)
|
|
async def restart_container(
|
|
host_id: str,
|
|
container_id: str,
|
|
timeout: int = Query(10, ge=1, le=300),
|
|
user: dict = Depends(get_current_user),
|
|
):
|
|
"""Restart a container."""
|
|
result = await docker_actions.restart_container(host_id, container_id, timeout=timeout)
|
|
return ContainerActionResponse(**result)
|
|
|
|
|
|
@router.post("/containers/{host_id}/{container_id}/remove", response_model=ContainerActionResponse)
|
|
async def remove_container(
|
|
host_id: str,
|
|
container_id: str,
|
|
force: bool = Query(False, description="Force remove running container"),
|
|
remove_volumes: bool = Query(False, description="Remove associated volumes"),
|
|
user: dict = Depends(require_admin),
|
|
):
|
|
"""Remove a container (admin only)."""
|
|
result = await docker_actions.remove_container(
|
|
host_id, container_id, force=force, remove_volumes=remove_volumes
|
|
)
|
|
return ContainerActionResponse(**result)
|
|
|
|
|
|
@router.post("/containers/{host_id}/{container_id}/redeploy", response_model=ContainerActionResponse)
|
|
async def redeploy_container(
|
|
host_id: str,
|
|
container_id: str,
|
|
user: dict = Depends(get_current_user),
|
|
):
|
|
"""Redeploy a container by pulling latest image."""
|
|
result = await docker_actions.redeploy_container(host_id, container_id)
|
|
return ContainerActionResponse(**result)
|
|
|
|
|
|
@router.get("/containers/{host_id}/{container_id}/logs", response_model=ContainerLogsResponse)
|
|
async def get_container_logs(
|
|
host_id: str,
|
|
container_id: str,
|
|
tail: int = Query(200, ge=1, le=5000),
|
|
timestamps: bool = Query(False),
|
|
since: Optional[str] = Query(None),
|
|
api_key_valid: bool = Depends(verify_api_key),
|
|
):
|
|
"""Get container logs."""
|
|
result = await docker_actions.get_container_logs(
|
|
host_id, container_id, tail=tail, timestamps=timestamps, since=since
|
|
)
|
|
return ContainerLogsResponse(**result)
|
|
|
|
|
|
@router.get("/containers/{host_id}/{container_id}/inspect", response_model=ContainerInspectResponse)
|
|
async def inspect_container(
|
|
host_id: str,
|
|
container_id: str,
|
|
api_key_valid: bool = Depends(verify_api_key),
|
|
):
|
|
"""Get detailed container information."""
|
|
result = await docker_actions.inspect_container(host_id, container_id)
|
|
return ContainerInspectResponse(**result)
|
|
|
|
|
|
# === Images ===
|
|
|
|
@router.get("/hosts/{host_id}/images", response_model=DockerImageExtendedListResponse)
|
|
async def get_images(
|
|
host_id: str,
|
|
api_key_valid: bool = Depends(verify_api_key),
|
|
db_session: AsyncSession = Depends(get_db),
|
|
):
|
|
"""List images for a host with usage information."""
|
|
image_repo = DockerImageRepository(db_session)
|
|
container_repo = DockerContainerRepository(db_session)
|
|
|
|
images = await image_repo.list_by_host(host_id)
|
|
counts = await image_repo.count_by_host(host_id)
|
|
|
|
# Get all container images to determine which images are in use
|
|
containers = await container_repo.list_by_host(host_id)
|
|
used_images = set()
|
|
for c in containers:
|
|
if c.image:
|
|
used_images.add(c.image)
|
|
# Also add the image without tag for matching
|
|
if ':' in c.image:
|
|
used_images.add(c.image.split(':')[0])
|
|
|
|
unused_count = 0
|
|
images_with_usage = []
|
|
for img in images:
|
|
# Check if image is in use by comparing repo_tags
|
|
in_use = False
|
|
if img.repo_tags:
|
|
for tag in img.repo_tags:
|
|
if tag in used_images:
|
|
in_use = True
|
|
break
|
|
# Check without tag
|
|
if ':' in tag and tag.split(':')[0] in used_images:
|
|
in_use = True
|
|
break
|
|
|
|
if not in_use:
|
|
unused_count += 1
|
|
|
|
images_with_usage.append({
|
|
"id": img.id,
|
|
"host_id": img.host_id,
|
|
"image_id": img.image_id,
|
|
"repo_tags": img.repo_tags,
|
|
"size": img.size,
|
|
"created": img.created,
|
|
"last_update_at": img.last_update_at,
|
|
"in_use": in_use
|
|
})
|
|
|
|
return DockerImageExtendedListResponse(
|
|
images=[DockerImageExtendedResponse(**img) for img in images_with_usage],
|
|
total=counts.get("total", 0),
|
|
total_size=counts.get("total_size", 0),
|
|
unused_count=unused_count
|
|
)
|
|
|
|
|
|
@router.delete("/images/{host_id}/{image_id}", response_model=ImageActionResponse)
|
|
async def remove_image(
|
|
host_id: str,
|
|
image_id: str,
|
|
force: bool = Query(False, description="Force remove even if image is in use"),
|
|
user: dict = Depends(require_admin),
|
|
):
|
|
"""Remove a Docker image (admin only)."""
|
|
result = await docker_actions.remove_image(host_id, image_id, force=force)
|
|
return ImageActionResponse(**result)
|
|
|
|
|
|
# === Volumes ===
|
|
|
|
@router.get("/hosts/{host_id}/volumes", response_model=DockerVolumeListResponse)
|
|
async def get_volumes(
|
|
host_id: str,
|
|
api_key_valid: bool = Depends(verify_api_key),
|
|
db_session: AsyncSession = Depends(get_db),
|
|
):
|
|
"""List volumes for a host."""
|
|
volume_repo = DockerVolumeRepository(db_session)
|
|
volumes = await volume_repo.list_by_host(host_id)
|
|
total = await volume_repo.count_by_host(host_id)
|
|
|
|
return DockerVolumeListResponse(
|
|
volumes=[{
|
|
"id": vol.id,
|
|
"host_id": vol.host_id,
|
|
"name": vol.name,
|
|
"driver": vol.driver,
|
|
"mountpoint": vol.mountpoint,
|
|
"scope": vol.scope,
|
|
"last_update_at": vol.last_update_at
|
|
} for vol in volumes],
|
|
total=total
|
|
)
|
|
|
|
|
|
# === Alerts ===
|
|
|
|
@router.get("/alerts", response_model=DockerAlertListResponse)
|
|
async def list_alerts(
|
|
host_id: Optional[str] = Query(None),
|
|
state: Optional[str] = Query("open", description="Filter by state: open, closed, acknowledged"),
|
|
severity: Optional[str] = Query(None, description="Filter by severity"),
|
|
limit: int = Query(100, ge=1, le=500),
|
|
offset: int = Query(0, ge=0),
|
|
api_key_valid: bool = Depends(verify_api_key),
|
|
):
|
|
"""List Docker alerts."""
|
|
result = await docker_alerts_service.get_alerts(
|
|
host_id=host_id,
|
|
state=state,
|
|
severity=severity,
|
|
limit=limit,
|
|
offset=offset
|
|
)
|
|
|
|
return DockerAlertListResponse(
|
|
alerts=result.get("alerts", []),
|
|
total=result.get("total", 0),
|
|
open_count=result.get("open_count", 0),
|
|
acknowledged_count=result.get("acknowledged_count", 0)
|
|
)
|
|
|
|
|
|
@router.post("/alerts/{alert_id}/acknowledge")
|
|
async def acknowledge_alert(
|
|
alert_id: int,
|
|
request: DockerAlertAcknowledgeRequest = None,
|
|
user: dict = Depends(get_current_user),
|
|
):
|
|
"""Acknowledge an alert."""
|
|
username = user.get("username", "unknown")
|
|
note = request.note if request else None
|
|
|
|
result = await docker_alerts_service.acknowledge_alert(alert_id, username, note)
|
|
|
|
if not result:
|
|
raise HTTPException(status_code=404, detail="Alert not found or already acknowledged")
|
|
|
|
return {"message": "Alert acknowledged", "alert": result}
|
|
|
|
|
|
@router.post("/alerts/{alert_id}/close")
|
|
async def close_alert(
|
|
alert_id: int,
|
|
user: dict = Depends(get_current_user),
|
|
):
|
|
"""Close an alert manually."""
|
|
result = await docker_alerts_service.close_alert(alert_id)
|
|
|
|
if not result:
|
|
raise HTTPException(status_code=404, detail="Alert not found or already closed")
|
|
|
|
return {"message": "Alert closed", "alert": result}
|
|
|
|
|
|
# === Stats ===
|
|
|
|
@router.get("/stats", response_model=DockerStatsResponse)
|
|
async def get_docker_stats(
|
|
api_key_valid: bool = Depends(verify_api_key),
|
|
):
|
|
"""Get global Docker statistics."""
|
|
stats = await docker_alerts_service.get_stats()
|
|
return DockerStatsResponse(**stats)
|