Build and Push / build (release) Successful in 1m28s
- 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>
1592 lines
63 KiB
Python
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()
|