"""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)