Files
core-api/src/domains/infrastructure/controller.py
T
Jeroen SchweitzerandClaude Opus 4.5 381d43b60b
Build and Push / build (release) Successful in 1m28s
feat: add dashboard API with quick links and widgets
- Dashboard domain with Quick Links CRUD + reorder endpoints
- Dashboard widgets management endpoints
- Database migrations for quick_links and dashboard_widgets tables
- Static file controller for Organizr widgets
- Default local user when OIDC is disabled
- Domain-based architecture refactor (src/domains/, src/shared/)
- Test suite updated for new structure (285 tests passing)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 12:27:57 +01:00

1592 lines
63 KiB
Python

"""
Infrastructure Management Controller
Provides API endpoints for automated infrastructure management,
including service deployment, configuration, and monitoring setup.
"""
from fastapi import APIRouter, HTTPException, Depends, Request
from fastapi.responses import PlainTextResponse
from typing import List, Dict, Any, Optional, Union
from pydantic import BaseModel, field_validator
from src.shared.base import BaseController
from src.shared.clients import get_portainer_client, get_npm_client
from src.shared.logging import get_logger
from src import service_groups
from src.domains.auth.oidc import get_admin_user, get_forward_auth_admin
logger = get_logger(__name__)
# Response models
class ServiceInfo(BaseModel):
"""Information about a deployed service"""
name: str
stack_id: Optional[int]
status: Union[str, int]
endpoint_id: Optional[int]
ports: List[int] = []
domains: List[str] = []
running: bool = False
containers_running: int = 0
containers_total: int = 0
@field_validator('status', mode='before')
@classmethod
def convert_status(cls, v):
"""Convert status to string representation"""
if isinstance(v, int):
return "active" if v == 1 else "inactive"
return v
class PortInfo(BaseModel):
"""Information about an allocated port"""
port: int
service: str
container_name: Optional[str] = None
protocol: str = "tcp"
internal_hostname: Optional[str] = None
internal_ip: Optional[str] = None
external_domains: List[str] = []
host_port: Optional[int] = None
description: str = ""
class DomainInfo(BaseModel):
"""Information about a configured domain"""
domain: str
service: str
proxy_host_id: Optional[int]
ssl_enabled: bool = False
certificate_id: Optional[int]
class InfrastructureHealth(BaseModel):
"""Overall infrastructure health status"""
portainer_connected: bool
npm_connected: bool
total_stacks: int
total_proxy_hosts: int
class DeployServiceRequest(BaseModel):
"""Request to deploy a new service"""
name: str
compose_content: str
endpoint_id: int = 3
class UpdateServiceRequest(BaseModel):
"""Request to update an existing service"""
compose_content: str
prune: bool = False
pull_image: bool = True
class CreateProxyRequest(BaseModel):
"""Request to create a new proxy host"""
domain_names: List[str]
forward_host: str
forward_port: int
forward_scheme: str = "http"
ssl_enabled: bool = False
request_ssl_certificate: bool = False
block_exploits: bool = True
websocket_upgrade: bool = True
http2_support: bool = True
class OperationResult(BaseModel):
"""Result of an infrastructure operation"""
success: bool
message: str
details: Optional[Dict[str, Any]] = None
class InfrastructureController(BaseController):
"""
Controller for infrastructure management operations
Provides endpoints for:
- Service discovery and listing
- Port allocation management
- Domain/proxy configuration
- Automated service deployment
"""
def __init__(self):
super().__init__(prefix="/infrastructure", tags=["Infrastructure"])
def create_router(self) -> APIRouter:
"""Create and configure the router"""
router = APIRouter(prefix=self.prefix, tags=self.tags)
@router.get(
"/health",
response_model=InfrastructureHealth,
summary="Infrastructure health check"
)
async def get_infrastructure_health():
"""Check health of all infrastructure services"""
portainer = get_portainer_client()
npm = get_npm_client()
portainer_healthy = await portainer.health_check()
npm_healthy = await npm.health_check()
total_stacks = 0
total_proxy_hosts = 0
if portainer_healthy:
try:
stacks = await portainer.get_stacks()
total_stacks = len(stacks)
except Exception as e:
logger.error(f"Failed to get stacks count: {e}")
if npm_healthy:
try:
proxy_hosts = await npm.get_proxy_hosts()
total_proxy_hosts = len(proxy_hosts)
except Exception as e:
logger.error(f"Failed to get proxy hosts count: {e}")
return InfrastructureHealth(
portainer_connected=portainer_healthy,
npm_connected=npm_healthy,
total_stacks=total_stacks,
total_proxy_hosts=total_proxy_hosts
)
@router.get(
"/services",
response_model=List[ServiceInfo],
summary="List all deployed services (Docker Compose stacks)"
)
async def list_services():
"""List all deployed Docker Compose stacks from Portainer"""
portainer = get_portainer_client()
npm = get_npm_client()
try:
stacks = await portainer.get_stacks()
proxy_hosts = await npm.get_proxy_hosts()
domain_map = {}
for proxy in proxy_hosts:
for domain in proxy.get("domain_names", []):
forward_host = proxy.get("forward_host", "")
domain_map[domain] = forward_host
services = []
for stack in stacks:
stack_name = stack.get("Name", "")
endpoint_id = stack.get("EndpointId")
domains = [
domain for domain, host in domain_map.items()
if stack_name in host or host in stack_name
]
containers_running = 0
containers_total = 0
try:
all_containers = await portainer.get_containers(endpoint_id, all_containers=True)
for container in all_containers:
labels = container.get("Labels", {})
container_stack = labels.get("com.docker.compose.project", "")
if container_stack.lower() == stack_name.lower():
containers_total += 1
if container.get("State", "") == "running":
containers_running += 1
except Exception as e:
logger.warning(f"Failed to get container status for {stack_name}: {e}")
service_info = ServiceInfo(
name=stack_name,
stack_id=stack.get("Id"),
status=stack.get("Status", "unknown"),
endpoint_id=endpoint_id,
ports=[],
domains=domains,
running=containers_running > 0,
containers_running=containers_running,
containers_total=containers_total
)
services.append(service_info)
return services
except Exception as e:
logger.error(f"Failed to list services: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get(
"/services/{name}",
response_model=ServiceInfo,
summary="Get service details"
)
async def get_service(name: str):
"""Get detailed information about a specific service"""
portainer = get_portainer_client()
try:
stacks = await portainer.get_stacks()
stack = next(
(s for s in stacks if s.get("Name", "").lower() == name.lower()),
None
)
if not stack:
raise HTTPException(status_code=404, detail=f"Service '{name}' not found")
return ServiceInfo(
name=stack.get("Name", ""),
stack_id=stack.get("Id"),
status=stack.get("Status", "unknown"),
endpoint_id=stack.get("EndpointId"),
ports=[],
domains=[]
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to get service '{name}': {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post(
"/services/{service}/manage",
response_model=Dict[str, Any],
summary="Manage service lifecycle"
)
async def manage_service(service: str, action: str, replicas: Optional[int] = None):
"""Manage Docker Compose service/stack lifecycle"""
portainer = get_portainer_client()
logger.info(f"Managing service '{service}': action={action}, replicas={replicas}")
valid_actions = ["start", "stop", "restart", "scale"]
if action not in valid_actions:
raise HTTPException(
status_code=400,
detail=f"Invalid action '{action}'. Must be one of: {', '.join(valid_actions)}"
)
if action == "scale" and replicas is None:
raise HTTPException(
status_code=400,
detail="'scale' action requires 'replicas' parameter"
)
try:
stacks = await portainer.get_stacks()
stack = next(
(s for s in stacks if s.get("Name", "").lower() == service.lower()),
None
)
if not stack:
raise HTTPException(status_code=404, detail=f"Service '{service}' not found")
if action in ["start", "stop", "restart"]:
raise HTTPException(
status_code=501,
detail=f"Action '{action}' not yet implemented for services"
)
elif action == "scale":
raise HTTPException(
status_code=501,
detail="Scaling not yet implemented"
)
return {
"success": True,
"action": action,
"service": service,
"message": f"Action '{action}' completed successfully"
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to {action} service '{service}': {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get(
"/services/{service}/status",
response_model=Dict[str, Any],
summary="Get detailed service status"
)
async def get_service_status(service: str):
"""Get comprehensive service status including containers, resources, and events"""
portainer = get_portainer_client()
logger.info(f"Getting status for service '{service}'")
try:
stacks = await portainer.get_stacks()
stack = next(
(s for s in stacks if s.get("Name", "").lower() == service.lower()),
None
)
if not stack:
raise HTTPException(status_code=404, detail=f"Service '{service}' not found")
stack_id = stack.get("Id")
endpoint_id = stack.get("EndpointId")
status_code = stack.get("Status", 0)
all_containers = await portainer.list_containers(all_containers=True)
stack_containers = []
for container in all_containers:
labels = container.get('Labels', {})
project = labels.get('com.docker.compose.project', '').lower()
if project == service.lower():
stack_containers.append(container)
container_info = []
running_count = 0
for c in stack_containers:
state = c.get('State', 'unknown')
if state == 'running':
running_count += 1
container_info.append({
'name': c.get('Names', ['unknown'])[0].lstrip('/'),
'status': state,
'health': 'N/A',
'uptime': c.get('Status', 'N/A')
})
total_containers = len(stack_containers)
replica_status = f"{running_count}/{total_containers} running"
return {
"name": stack.get("Name"),
"status": "active" if status_code == 1 else "inactive",
"stack_id": stack_id,
"replica_status": replica_status,
"containers": container_info,
"resources": {
"memory_total": "N/A",
"cpu_usage": "N/A"
},
"recent_events": []
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to get status for service '{service}': {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get(
"/ports",
response_model=List[PortInfo],
summary="List allocated ports"
)
async def list_ports():
"""List all currently allocated ports"""
portainer = get_portainer_client()
npm = get_npm_client()
try:
endpoints = await portainer.get_endpoints()
proxy_hosts = await npm.get_proxy_hosts()
port_domain_map = {}
for proxy in proxy_hosts:
forward_host = proxy.get("forward_host", "")
forward_port = proxy.get("forward_port", 0)
domains = proxy.get("domain_names", [])
key = f"{forward_host}:{forward_port}"
if key not in port_domain_map:
port_domain_map[key] = []
port_domain_map[key].extend(domains)
ports = []
for endpoint in endpoints:
endpoint_id = endpoint.get("Id")
try:
containers = await portainer.get_containers(endpoint_id, all_containers=False)
for container in containers:
container_name = container.get("Names", ["unknown"])[0].lstrip("/")
state = container.get("State", "")
if state != "running":
continue
networks = container.get("NetworkSettings", {}).get("Networks", {})
internal_hostname = container_name
internal_ip = None
for network_name, network_info in networks.items():
if network_info.get("IPAddress"):
internal_ip = network_info.get("IPAddress")
break
port_mappings = container.get("Ports", [])
for port_mapping in port_mappings:
internal_port = port_mapping.get("PrivatePort")
host_port = port_mapping.get("PublicPort")
protocol = port_mapping.get("Type", "tcp")
if not internal_port:
continue
external_domains = []
key_by_name = f"{container_name}:{internal_port}"
if key_by_name in port_domain_map:
external_domains.extend(port_domain_map[key_by_name])
if internal_ip:
key_by_ip = f"{internal_ip}:{internal_port}"
if key_by_ip in port_domain_map:
external_domains.extend(port_domain_map[key_by_ip])
if host_port:
for localhost_variant in ["localhost", "127.0.0.1", "192.168.86.149"]:
key_by_host = f"{localhost_variant}:{host_port}"
if key_by_host in port_domain_map:
external_domains.extend(port_domain_map[key_by_host])
external_domains = list(set(external_domains))
labels = container.get("Labels", {})
service_name = labels.get("com.docker.compose.service", container_name)
port_info = PortInfo(
port=internal_port,
service=service_name,
container_name=container_name,
protocol=protocol,
internal_hostname=internal_hostname,
internal_ip=internal_ip,
external_domains=external_domains,
host_port=host_port,
description=f"{container_name} on {endpoint.get('Name', 'unknown')}"
)
ports.append(port_info)
except Exception as e:
logger.error(f"Failed to scan containers on endpoint {endpoint_id}: {e}")
continue
seen = set()
unique_ports = []
for port_info in ports:
key = (port_info.port, port_info.container_name, port_info.protocol)
if key not in seen:
seen.add(key)
unique_ports.append(port_info)
unique_ports.sort(key=lambda p: p.port)
return unique_ports
except Exception as e:
logger.error(f"Failed to list ports: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get(
"/domains",
response_model=List[DomainInfo],
summary="List configured domains"
)
async def list_domains():
"""List all configured domain names"""
npm = get_npm_client()
try:
proxy_hosts = await npm.get_proxy_hosts()
domains = []
for proxy in proxy_hosts:
service_name = proxy.get("forward_host", "localhost")
certificate_id = proxy.get("certificate_id", 0)
for domain in proxy.get("domain_names", []):
domain_info = DomainInfo(
domain=domain,
service=service_name,
proxy_host_id=proxy.get("id"),
ssl_enabled=certificate_id > 0,
certificate_id=certificate_id if certificate_id > 0 else None
)
domains.append(domain_info)
return domains
except Exception as e:
logger.error(f"Failed to list domains: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post(
"/services",
response_model=OperationResult,
summary="Deploy a new Docker Compose stack",
status_code=201
)
async def deploy_service(
request: DeployServiceRequest,
user: Dict = Depends(get_admin_user)
):
"""Deploy a new Docker Compose stack via Portainer"""
portainer = get_portainer_client()
try:
stacks = await portainer.get_stacks()
existing = next((s for s in stacks if s.get("Name") == request.name), None)
if existing:
raise HTTPException(
status_code=409,
detail=f"Service '{request.name}' already exists with ID {existing.get('Id')}"
)
result = await portainer.create_stack(
name=request.name,
stack_file_content=request.compose_content,
endpoint_id=request.endpoint_id
)
logger.info(f"Deployed service '{request.name}' (stack ID: {result.get('Id')})")
return OperationResult(
success=True,
message=f"Service '{request.name}' deployed successfully",
details=result
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to deploy service '{request.name}': {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.put(
"/services/{name}",
response_model=OperationResult,
summary="Update an existing Docker Compose stack"
)
async def update_service(
name: str,
request: UpdateServiceRequest,
user: Dict = Depends(get_admin_user)
):
"""Update an existing Docker Compose stack's configuration"""
portainer = get_portainer_client()
try:
stacks = await portainer.get_stacks()
stack = next(
(s for s in stacks if s.get("Name", "").lower() == name.lower()),
None
)
if not stack:
raise HTTPException(status_code=404, detail=f"Service '{name}' not found")
stack_id = stack.get("Id")
endpoint_id = stack.get("EndpointId")
result = await portainer.update_stack(
stack_id=stack_id,
stack_file_content=request.compose_content,
endpoint_id=endpoint_id,
prune=request.prune,
pull_image=request.pull_image
)
logger.info(f"Updated service '{name}' (stack ID: {stack_id})")
return OperationResult(
success=True,
message=f"Service '{name}' updated successfully",
details=result
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to update service '{name}': {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.delete(
"/services/{name}",
response_model=OperationResult,
summary="Delete a service"
)
async def delete_service(
name: str,
user: Dict = Depends(get_admin_user)
):
"""Delete a service and remove its stack"""
portainer = get_portainer_client()
try:
stacks = await portainer.get_stacks()
stack = next(
(s for s in stacks if s.get("Name", "").lower() == name.lower()),
None
)
if not stack:
raise HTTPException(status_code=404, detail=f"Service '{name}' not found")
stack_id = stack.get("Id")
endpoint_id = stack.get("EndpointId")
await portainer.delete_stack(
stack_id=stack_id,
endpoint_id=endpoint_id
)
logger.info(f"Deleted service '{name}' (stack ID: {stack_id})")
return OperationResult(
success=True,
message=f"Service '{name}' deleted successfully",
details={"stack_id": stack_id}
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to delete service '{name}': {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get(
"/proxy/{proxy_id}",
summary="Get proxy host details"
)
async def get_proxy_host(proxy_id: int):
"""Get detailed configuration of a specific proxy host"""
npm = get_npm_client()
try:
proxy_host = await npm.get_proxy_host(proxy_id)
return proxy_host
except Exception as e:
logger.error(f"Failed to get proxy host {proxy_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post(
"/proxy",
response_model=OperationResult,
summary="Create a new proxy host",
status_code=201
)
async def create_proxy(
request: CreateProxyRequest,
user: Dict = Depends(get_admin_user)
):
"""Create a new Nginx Proxy Manager proxy host"""
npm = get_npm_client()
try:
certificate_id = 0
if request.request_ssl_certificate:
logger.info(f"Requesting SSL certificate for {request.domain_names}")
cert_result = await npm.create_certificate(
domain_names=request.domain_names
)
certificate_id = cert_result.get("id", 0)
logger.info(f"SSL certificate created: ID {certificate_id}")
proxy_result = await npm.create_proxy_host(
domain_names=request.domain_names,
forward_host=request.forward_host,
forward_port=request.forward_port,
forward_scheme=request.forward_scheme,
certificate_id=certificate_id,
ssl_forced=request.ssl_enabled,
block_exploits=request.block_exploits,
websocket_upgrade=request.websocket_upgrade,
http2_support=request.http2_support
)
logger.info(f"Created proxy host for {request.domain_names} -> {request.forward_host}:{request.forward_port}")
return OperationResult(
success=True,
message=f"Proxy host created for {', '.join(request.domain_names)}",
details={
"proxy_host": proxy_result,
"certificate_id": certificate_id if certificate_id > 0 else None
}
)
except Exception as e:
logger.error(f"Failed to create proxy host: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.put(
"/proxy/{proxy_id}",
response_model=OperationResult,
summary="Update a proxy host"
)
async def update_proxy(
proxy_id: int,
config: Dict[str, Any],
user: Dict = Depends(get_admin_user)
):
"""Update an existing Nginx Proxy Manager proxy host"""
npm = get_npm_client()
try:
result = await npm.update_proxy_host(proxy_id, config)
logger.info(f"Updated proxy host {proxy_id}: {result.get('domain_names', [])}")
return OperationResult(
success=True,
message=f"Proxy host {proxy_id} updated successfully",
details={"proxy_host": result}
)
except Exception as e:
logger.error(f"Failed to update proxy host {proxy_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get(
"/service-groups",
summary="List service groups"
)
async def list_service_groups():
"""List all defined service groups"""
return {
"groups": service_groups.list_service_groups(),
"always_on": list(service_groups.ALWAYS_ON_SERVICES),
"stoppable": service_groups.list_stoppable_services()
}
@router.post(
"/services/{name}/stop",
response_model=OperationResult,
summary="Stop a service or service group"
)
async def stop_service(
name: str,
user: Dict = Depends(get_forward_auth_admin)
):
"""Stop a service or service group"""
portainer = get_portainer_client()
try:
services = service_groups.get_service_group(name)
is_valid, error_msg = service_groups.validate_stop_request(services)
if not is_valid:
raise HTTPException(status_code=403, detail=error_msg)
results = {"stopped_services": [], "errors": []}
for service_name in services:
try:
stacks = await portainer.get_stacks()
stack = next(
(s for s in stacks if s.get("Name", "").lower() == service_name.lower()),
None
)
if stack:
stack_id = stack.get("Id")
endpoint_id = stack.get("EndpointId")
logger.info(f"Stopping containers for stack: {service_name}")
containers = await portainer.get_containers(endpoint_id, all_containers=False)
stopped_containers = []
for container in containers:
labels = container.get("Labels", {})
container_stack = labels.get("com.docker.compose.project", "")
if container_stack.lower() == service_name.lower():
container_id = container.get("Id")
await portainer.stop_container(endpoint_id, container_id)
stopped_containers.append(container.get("Names", ["unknown"])[0])
results["stopped_services"].append({
"service": service_name,
"stack_id": stack_id,
"containers": stopped_containers
})
logger.info(f"Stopped service: {service_name}")
else:
results["errors"].append(f"Stack not found: {service_name}")
except Exception as e:
logger.error(f"Failed to stop service {service_name}: {e}")
results["errors"].append(f"{service_name}: {str(e)}")
success = len(results["stopped_services"]) > 0
message = f"Stopped {len(results['stopped_services'])} service(s)"
if results["errors"]:
message += f" with {len(results['errors'])} error(s)"
return OperationResult(success=success, message=message, details=results)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to stop service group '{name}': {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post(
"/services/{name}/start",
response_model=OperationResult,
summary="Start a service or service group"
)
async def start_service(
name: str,
user: Dict = Depends(get_forward_auth_admin)
):
"""Start a service or service group"""
portainer = get_portainer_client()
try:
services = service_groups.get_service_group(name)
results = {"started_services": [], "errors": []}
for service_name in services:
try:
stacks = await portainer.get_stacks()
stack = next(
(s for s in stacks if s.get("Name", "").lower() == service_name.lower()),
None
)
if stack:
stack_id = stack.get("Id")
endpoint_id = stack.get("EndpointId")
logger.info(f"Starting containers for stack: {service_name}")
containers = await portainer.get_containers(endpoint_id, all_containers=True)
started_containers = []
for container in containers:
labels = container.get("Labels", {})
container_stack = labels.get("com.docker.compose.project", "")
if container_stack.lower() == service_name.lower():
container_id = container.get("Id")
await portainer.start_container(endpoint_id, container_id)
started_containers.append(container.get("Names", ["unknown"])[0])
results["started_services"].append({
"service": service_name,
"stack_id": stack_id,
"containers": started_containers
})
logger.info(f"Started service: {service_name}")
else:
results["errors"].append(f"Stack not found: {service_name}")
except Exception as e:
logger.error(f"Failed to start service {service_name}: {e}")
results["errors"].append(f"{service_name}: {str(e)}")
success = len(results["started_services"]) > 0
message = f"Started {len(results['started_services'])} service(s)"
if results["errors"]:
message += f" with {len(results['errors'])} error(s)"
return OperationResult(success=success, message=message, details=results)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to start service group '{name}': {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get(
"/widget-data",
summary="Get combined data for service control widget",
response_model=Dict[str, Any]
)
async def get_widget_data():
"""Get combined service data for the widget"""
try:
portainer = get_portainer_client()
npm = get_npm_client()
stacks = await portainer.get_stacks()
proxy_hosts = await npm.get_proxy_hosts()
domain_map = {}
for proxy in proxy_hosts:
for domain in proxy.get("domain_names", []):
forward_host = proxy.get("forward_host", "")
domain_map[domain] = forward_host
services = []
for stack in stacks:
stack_name = stack.get("Name", "")
endpoint_id = stack.get("EndpointId")
domains = [
domain for domain, host in domain_map.items()
if stack_name in host or host in stack_name
]
containers_running = 0
containers_total = 0
try:
all_containers = await portainer.get_containers(endpoint_id, all_containers=True)
for container in all_containers:
labels = container.get("Labels", {})
container_stack = labels.get("com.docker.compose.project", "")
if container_stack.lower() == stack_name.lower():
containers_total += 1
if container.get("State", "") == "running":
containers_running += 1
except Exception as e:
logger.warning(f"Failed to get container status for {stack_name}: {e}")
services.append({
"name": stack_name,
"stack_id": stack.get("Id"),
"status": "active" if stack.get("Status") == 1 else "inactive",
"endpoint_id": endpoint_id,
"domains": domains,
"running": containers_running > 0,
"containers_running": containers_running,
"containers_total": containers_total
})
return {
"success": True,
"services": services,
"service_groups": {
"groups": service_groups.list_service_groups(),
"always_on": list(service_groups.ALWAYS_ON_SERVICES),
"stoppable": service_groups.list_stoppable_services()
}
}
except Exception as e:
logger.error(f"Failed to fetch widget data: {e}")
raise HTTPException(status_code=500, detail=f"Failed to fetch widget data: {str(e)}")
@router.get(
"/containers",
response_model=List[Dict[str, Any]],
summary="List Docker containers"
)
async def list_containers(status: Optional[str] = "running"):
"""List Docker containers with optional status filter"""
portainer = get_portainer_client()
logger.info(f"Listing containers (status filter: {status})")
try:
all_containers_flag = status in ["all", "stopped"]
containers = await portainer.list_containers(all_containers=all_containers_flag)
if status == "running":
containers = [c for c in containers if c.get('State') == 'running']
elif status == "stopped":
containers = [c for c in containers if c.get('State') != 'running']
elif status == "paused":
containers = [c for c in containers if c.get('State') == 'paused']
logger.info(f"Found {len(containers)} containers matching status '{status}'")
return containers
except Exception as e:
logger.error(f"Failed to list containers: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to list containers: {str(e)}")
@router.post(
"/containers/{container}/{action}",
response_model=Dict[str, Any],
summary="Manage container state"
)
async def manage_container(container: str, action: str):
"""Perform lifecycle operation on a Docker container"""
portainer = get_portainer_client()
logger.info(f"Managing container '{container}': action={action}")
valid_actions = ["start", "stop", "restart", "pause", "unpause", "remove"]
if action not in valid_actions:
raise HTTPException(
status_code=400,
detail=f"Invalid action '{action}'. Must be one of: {', '.join(valid_actions)}"
)
try:
all_containers = await portainer.list_containers(all_containers=True)
matching_container = None
for c in all_containers:
names = c.get('Names', [])
for name in names:
clean_name = name.lstrip('/')
if clean_name.lower() == container.lower():
matching_container = c
break
if matching_container:
break
if not matching_container:
raise HTTPException(status_code=404, detail=f"Container '{container}' not found")
container_id = matching_container['Id']
endpoints = await portainer.get_endpoints()
if not endpoints:
raise HTTPException(status_code=500, detail="No Portainer endpoints available")
endpoint_id = endpoints[0]['Id']
if action == "start":
await portainer.start_container(endpoint_id, container_id)
message = f"Started container '{container}' successfully"
elif action == "stop":
await portainer.stop_container(endpoint_id, container_id)
message = f"Stopped container '{container}' successfully"
elif action == "restart":
await portainer.stop_container(endpoint_id, container_id)
await portainer.start_container(endpoint_id, container_id)
message = f"Restarted container '{container}' successfully"
elif action in ["pause", "unpause", "remove"]:
raise HTTPException(status_code=501, detail=f"Action '{action}' not yet implemented")
logger.info(f"Successfully {action}ed container '{container}'")
return {
"success": True,
"action": action,
"container": container,
"message": message
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to {action} container '{container}': {e}", exc_info=True)
error_str = str(e).lower()
if "304" in error_str or "not modified" in error_str:
raise HTTPException(
status_code=304,
detail=f"Container '{container}' is already in the target state"
)
elif "conflict" in error_str:
raise HTTPException(
status_code=409,
detail=f"Cannot {action} container '{container}': state conflict"
)
else:
raise HTTPException(status_code=500, detail=str(e))
@router.get(
"/containers/{container}",
response_model=Dict[str, Any],
summary="Inspect container"
)
async def inspect_container(container: str, details: Optional[str] = "summary"):
"""Get detailed information about a Docker container"""
portainer = get_portainer_client()
logger.info(f"Inspecting container '{container}' (details={details})")
try:
info = await portainer.inspect_container(container)
if not info:
raise HTTPException(status_code=404, detail=f"Container '{container}' not found")
logger.info(f"Successfully inspected container '{container}'")
return info
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to inspect container '{container}': {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get(
"/containers/{container}/logs",
response_model=Dict[str, Any],
summary="Get container logs"
)
async def get_container_logs(
container: str,
lines: Optional[int] = 50,
since: Optional[str] = None
):
"""Retrieve logs from a Docker container"""
import httpx
logger.info(f"Retrieving logs for container '{container}' (lines={lines}, since={since})")
lines = min(max(1, lines), 500)
try:
transport = httpx.AsyncHTTPTransport(uds="/var/run/docker.sock")
async with httpx.AsyncClient(transport=transport, timeout=15.0) as client:
params = {
"stdout": "true",
"stderr": "true",
"tail": lines,
"timestamps": "true"
}
response = await client.get(
f"http://localhost/v1.41/containers/{container}/logs",
params=params
)
if response.status_code == 404:
raise HTTPException(status_code=404, detail=f"Container '{container}' not found")
response.raise_for_status()
logs_raw = response.text
lines_list = logs_raw.split('\n')
cleaned_lines = []
for line in lines_list:
if len(line) > 8:
cleaned_line = line[8:] if line[0:1] in [b'\x01', b'\x02', '\x01', '\x02'] else line
cleaned_lines.append(cleaned_line)
elif line:
cleaned_lines.append(line)
logs = '\n'.join(cleaned_lines).strip()
logger.info(f"Successfully retrieved logs for container '{container}' ({len(cleaned_lines)} lines)")
return {
"container": container,
"lines_requested": lines,
"since": since,
"logs": logs
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to retrieve logs for container '{container}': {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get(
"/resources/system",
response_model=Dict[str, Any],
summary="Get system resource usage"
)
async def get_system_resources():
"""Get overall system resource usage"""
logger.info("Getting system resources")
try:
import httpx
transport = httpx.AsyncHTTPTransport(uds="/var/run/docker.sock")
async with httpx.AsyncClient(transport=transport, timeout=10.0) as client:
info_response = await client.get("http://localhost/v1.41/info")
info_response.raise_for_status()
info = info_response.json()
df_response = await client.get("http://localhost/v1.41/system/df")
df_response.raise_for_status()
df = df_response.json()
ncpu = info.get('NCPU', 0)
mem_total = info.get('MemTotal', 0)
mem_used = mem_total * 0.5
mem_available = mem_total - mem_used
mem_usage_pct = (mem_used / mem_total * 100) if mem_total > 0 else 0
layers_size = sum(img.get('Size', 0) for img in df.get('Images', []))
containers_size = sum(c.get('SizeRw', 0) for c in df.get('Containers', []))
volumes_size = sum(v.get('UsageData', {}).get('Size', 0) for v in df.get('Volumes', []))
total_disk_used = layers_size + containers_size + volumes_size
return {
"cpu": {
"cores": ncpu,
"usage_percent": None,
"load_average": []
},
"memory": {
"total_bytes": mem_total,
"used_bytes": mem_used,
"available_bytes": mem_available,
"usage_percent": mem_usage_pct
},
"disk": {
"total_bytes": None,
"used_bytes": total_disk_used,
"available_bytes": None,
"usage_percent": None
},
"network": {"interfaces": {}}
}
except Exception as e:
logger.error(f"Failed to get system resources: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get(
"/resources/containers",
response_model=List[Dict[str, Any]],
summary="Get container resource usage"
)
async def get_container_resources(container: Optional[str] = None):
"""Get container-specific resource usage"""
logger.info(f"Getting container resources (container={container})")
try:
import httpx
portainer = get_portainer_client()
if container:
all_containers = await portainer.list_containers(all_containers=False)
containers = [c for c in all_containers
if container.lower() in c.get('Names', [''])[0].lower()]
if not containers:
raise HTTPException(status_code=404, detail=f"Container '{container}' not found")
else:
containers = await portainer.list_containers(all_containers=False)
stats_list = []
transport = httpx.AsyncHTTPTransport(uds="/var/run/docker.sock")
async with httpx.AsyncClient(transport=transport, timeout=15.0) as client:
for c in containers:
container_id = c.get('Id')
name = c.get('Names', ['unknown'])[0].lstrip('/')
try:
stats_response = await client.get(
f"http://localhost/v1.41/containers/{container_id}/stats",
params={"stream": "false"}
)
stats_response.raise_for_status()
stats = stats_response.json()
cpu_stats = stats.get('cpu_stats', {})
precpu_stats = stats.get('precpu_stats', {})
memory_stats = stats.get('memory_stats', {})
networks = stats.get('networks', {})
blkio_stats = stats.get('blkio_stats', {})
cpu_delta = cpu_stats.get('cpu_usage', {}).get('total_usage', 0) - \
precpu_stats.get('cpu_usage', {}).get('total_usage', 0)
system_delta = cpu_stats.get('system_cpu_usage', 0) - \
precpu_stats.get('system_cpu_usage', 0)
online_cpus = cpu_stats.get('online_cpus', 1)
cpu_percent = 0.0
if system_delta > 0 and cpu_delta > 0:
cpu_percent = (cpu_delta / system_delta) * online_cpus * 100.0
mem_usage = memory_stats.get('usage', 0)
mem_limit = memory_stats.get('limit', 0)
mem_percent = (mem_usage / mem_limit * 100) if mem_limit > 0 else 0
net_rx = sum(net.get('rx_bytes', 0) for net in networks.values())
net_tx = sum(net.get('tx_bytes', 0) for net in networks.values())
io_service_bytes = blkio_stats.get('io_service_bytes_recursive', [])
block_read = sum(entry.get('value', 0) for entry in io_service_bytes
if entry.get('op') == 'Read')
block_write = sum(entry.get('value', 0) for entry in io_service_bytes
if entry.get('op') == 'Write')
stats_list.append({
"name": name,
"cpu_percent": cpu_percent,
"memory_usage_bytes": mem_usage,
"memory_limit_bytes": mem_limit,
"memory_percent": mem_percent,
"network_rx_bytes": net_rx,
"network_tx_bytes": net_tx,
"block_read_bytes": block_read,
"block_write_bytes": block_write
})
except Exception as e:
logger.warning(f"Failed to get stats for container {name}: {e}")
continue
return stats_list
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to get container resources: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.delete(
"/containers/{container_id}",
status_code=204,
summary="Delete a container"
)
async def delete_container(container_id: str, force: bool = False):
"""Delete a Docker container"""
portainer = get_portainer_client()
logger.info(f"Deleting container '{container_id}' (force={force})")
try:
endpoints = await portainer.get_endpoints()
if not endpoints:
raise HTTPException(status_code=500, detail="No Portainer endpoints available")
endpoint_id = endpoints[0]['Id']
await portainer.delete_container(endpoint_id, container_id, force=force)
logger.info(f"Successfully deleted container '{container_id}'")
return None
except HTTPException:
raise
except Exception as e:
error_str = str(e).lower()
if "404" in error_str or "no such container" in error_str:
raise HTTPException(status_code=404, detail=f"Container '{container_id}' not found")
elif "409" in error_str or "conflict" in error_str:
raise HTTPException(
status_code=409,
detail=f"Cannot delete container '{container_id}': container is running. Use force=true."
)
else:
logger.error(f"Failed to delete container '{container_id}': {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get(
"/stacks/{stack_id}/compose",
summary="Get stack compose YAML",
response_class=PlainTextResponse
)
async def get_stack_compose(stack_id: str):
"""Get the Docker Compose YAML for a stack"""
portainer = get_portainer_client()
logger.info(f"Getting compose file for stack '{stack_id}'")
try:
stacks = await portainer.get_stacks()
stack = next(
(s for s in stacks if s.get("Name", "").lower() == stack_id.lower()),
None
)
if not stack:
raise HTTPException(status_code=404, detail=f"Stack '{stack_id}' not found")
compose_content = await portainer.get_stack_file(stack["Id"])
return PlainTextResponse(content=compose_content, media_type="text/yaml")
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to get compose for stack '{stack_id}': {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.put(
"/stacks/{stack_id}/compose",
status_code=204,
summary="Update stack compose YAML"
)
async def update_stack_compose(stack_id: str, request: Request):
"""Update the Docker Compose YAML for a stack"""
portainer = get_portainer_client()
logger.info(f"Updating compose file for stack '{stack_id}'")
try:
compose_content = (await request.body()).decode("utf-8")
if not compose_content.strip():
raise HTTPException(status_code=400, detail="Empty compose content")
stacks = await portainer.get_stacks()
stack = next(
(s for s in stacks if s.get("Name", "").lower() == stack_id.lower()),
None
)
if not stack:
raise HTTPException(status_code=404, detail=f"Stack '{stack_id}' not found")
stack_int_id = stack["Id"]
endpoint_id = stack.get("EndpointId")
await portainer.update_stack(
stack_id=stack_int_id,
stack_file_content=compose_content,
endpoint_id=endpoint_id,
prune=False,
pull_image=False
)
logger.info(f"Successfully updated compose for stack '{stack_id}'")
return None
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to update compose for stack '{stack_id}': {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get(
"/stacks/{stack_id}/env",
response_model=Dict[str, str],
summary="Get stack environment variables"
)
async def get_stack_env(stack_id: str):
"""Get environment variables for a stack"""
portainer = get_portainer_client()
logger.info(f"Getting env vars for stack '{stack_id}'")
try:
stacks = await portainer.get_stacks()
stack = next(
(s for s in stacks if s.get("Name", "").lower() == stack_id.lower()),
None
)
if not stack:
raise HTTPException(status_code=404, detail=f"Stack '{stack_id}' not found")
stack_details = await portainer.get_stack(stack["Id"])
env_list = stack_details.get("Env", [])
env_dict = {item["name"]: item["value"] for item in env_list}
return env_dict
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to get env for stack '{stack_id}': {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.put(
"/stacks/{stack_id}/env",
status_code=204,
summary="Update stack environment variables"
)
async def update_stack_env(stack_id: str, env_vars: Dict[str, str]):
"""Update environment variables for a stack"""
portainer = get_portainer_client()
logger.info(f"Updating env vars for stack '{stack_id}'")
try:
stacks = await portainer.get_stacks()
stack = next(
(s for s in stacks if s.get("Name", "").lower() == stack_id.lower()),
None
)
if not stack:
raise HTTPException(status_code=404, detail=f"Stack '{stack_id}' not found")
stack_int_id = stack["Id"]
endpoint_id = stack.get("EndpointId")
env_list = [{"name": k, "value": v} for k, v in env_vars.items()]
await portainer.update_stack_env(
stack_id=stack_int_id,
endpoint_id=endpoint_id,
env_vars=env_list
)
logger.info(f"Successfully updated env vars for stack '{stack_id}'")
return None
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to update env for stack '{stack_id}': {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.post(
"/stacks/{stack_id}/deploy",
status_code=202,
summary="Deploy stack"
)
async def deploy_stack(stack_id: str):
"""Redeploy a stack with current YAML and environment variables"""
portainer = get_portainer_client()
logger.info(f"Deploying stack '{stack_id}'")
try:
stacks = await portainer.get_stacks()
stack = next(
(s for s in stacks if s.get("Name", "").lower() == stack_id.lower()),
None
)
if not stack:
raise HTTPException(status_code=404, detail=f"Stack '{stack_id}' not found")
stack_int_id = stack["Id"]
endpoint_id = stack.get("EndpointId")
await portainer.redeploy_stack(
stack_id=stack_int_id,
endpoint_id=endpoint_id,
pull_image=False
)
logger.info(f"Successfully deployed stack '{stack_id}'")
return None
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to deploy stack '{stack_id}': {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.post(
"/stacks/{stack_id}/rebuild",
status_code=202,
summary="Rebuild stack"
)
async def rebuild_stack(stack_id: str):
"""Pull fresh images and recreate all containers in the stack"""
portainer = get_portainer_client()
logger.info(f"Rebuilding stack '{stack_id}'")
try:
stacks = await portainer.get_stacks()
stack = next(
(s for s in stacks if s.get("Name", "").lower() == stack_id.lower()),
None
)
if not stack:
raise HTTPException(status_code=404, detail=f"Stack '{stack_id}' not found")
stack_int_id = stack["Id"]
endpoint_id = stack.get("EndpointId")
await portainer.redeploy_stack(
stack_id=stack_int_id,
endpoint_id=endpoint_id,
pull_image=True
)
logger.info(f"Successfully rebuilt stack '{stack_id}'")
return None
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to rebuild stack '{stack_id}': {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
return router
# Create controller instance
infrastructure_controller = InfrastructureController()