Files
core-api/src/controllers/infrastructure_controller.py
T
jpmschweitzerandClaude Opus 4.5 167a3a43a8
Build and Push / build (release) Successful in 29s
feat: add stack management and container delete endpoints
- Add stack compose YAML get/update endpoints
- Add stack environment variable get/update endpoints
- Add stack deploy and rebuild endpoints
- Add container delete endpoint with force option
- Add Portainer client methods for new operations
- Add comprehensive test coverage (30 new tests)
- Remove REQUESTED_SERVICES.md (now implemented)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 10:19:50 +01:00

2060 lines
80 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.controllers.base import BaseController
from src.clients.portainer_client import get_portainer_client
from src.clients.npm_client import get_npm_client
from src.logging_config import get_logger
from src import service_groups
from src.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 # True if at least one container is running
containers_running: int = 0 # Number of running containers
containers_total: int = 0 # Total number of containers
@field_validator('status', mode='before')
@classmethod
def convert_status(cls, v):
"""Convert status to string representation"""
if isinstance(v, int):
# Portainer status: 1=active, 2=inactive
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 # Port exposed on host, if different from internal
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
# Request models for write operations
class DeployServiceRequest(BaseModel):
"""Request to deploy a new service"""
name: str
compose_content: str
endpoint_id: int = 3 # Default to local endpoint
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
Returns status of Portainer, NPM, and summary statistics.
"""
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)",
description="List all services deployed via Portainer stacks. Each 'service' represents a Docker Compose stack."
)
async def list_services():
"""
List all deployed Docker Compose stacks from Portainer
Returns comprehensive stack/service information including:
- Stack name (the service name)
- Status (active/inactive)
- Exposed ports
- Configured domains (from NPM reverse proxy)
- Running container count
"""
portainer = get_portainer_client()
npm = get_npm_client()
try:
stacks = await portainer.get_stacks()
proxy_hosts = await npm.get_proxy_hosts()
# Build domain mapping (domain -> service name)
domain_map = {}
for proxy in proxy_hosts:
for domain in proxy.get("domain_names", []):
# Try to extract service name from forward_host
forward_host = proxy.get("forward_host", "")
domain_map[domain] = forward_host
services = []
for stack in stacks:
# Find domains for this stack
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
]
# Get container status for this stack
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=[], # TODO: Extract from stack file
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
Args:
name: Service/stack name
Returns:
Service details including status and configuration
"""
portainer = get_portainer_client()
try:
stacks = await portainer.get_stacks()
# Find stack by name (case-insensitive)
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.
Args:
service: Service/stack name
action: One of: "start", "stop", "restart", "scale"
replicas: Number of replicas (required for scale action)
Returns:
Success status and message
"""
from pydantic import BaseModel
class ManageServiceRequest(BaseModel):
action: str
replicas: Optional[int] = None
portainer = get_portainer_client()
logger.info(f"Managing service '{service}': action={action}, replicas={replicas}")
# Validate action
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)}"
)
# Validate scale has replicas
if action == "scale" and replicas is None:
raise HTTPException(
status_code=400,
detail="'scale' action requires 'replicas' parameter"
)
try:
# Find the stack by name
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")
# Perform the action
if action in ["start", "stop", "restart"]:
# These actions need to be implemented
# For now, return not implemented
raise HTTPException(
status_code=501,
detail=f"Action '{action}' not yet implemented for services"
)
elif action == "scale":
# Scaling requires updating the stack's compose file
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.
Args:
service: Service/stack name
Returns:
Detailed status information
"""
portainer = get_portainer_client()
logger.info(f"Getting status for service '{service}'")
try:
# Find the stack
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)
# Get containers for this stack
all_containers = await portainer.list_containers(all_containers=True)
# Filter containers belonging to this stack
# Stack name is usually in the container labels
stack_containers = []
for container in all_containers:
labels = container.get('Labels', {})
# Check if container belongs to this stack
# Docker Compose adds labels like: com.docker.compose.project
project = labels.get('com.docker.compose.project', '').lower()
if project == service.lower():
stack_containers.append(container)
# Format container info
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', # Would need to inspect container for health
'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", # Would need to aggregate container stats
"cpu_usage": "N/A"
},
"recent_events": [] # Would need to query Docker events API
}
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
Scans all running containers to extract:
- Internal and external port mappings
- Container internal hostnames and IPs
- External domain names (from NPM proxy configuration)
"""
portainer = get_portainer_client()
npm = get_npm_client()
try:
# Get all endpoints (Docker environments)
endpoints = await portainer.get_endpoints()
# Get proxy hosts for external domain mapping
proxy_hosts = await npm.get_proxy_hosts()
# Build mapping of forward_host:forward_port -> domains
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 = []
# Scan containers on each endpoint
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", "")
# Skip non-running containers
if state != "running":
continue
# Extract network information
networks = container.get("NetworkSettings", {}).get("Networks", {})
internal_hostname = container_name
internal_ip = None
# Get first network IP
for network_name, network_info in networks.items():
if network_info.get("IPAddress"):
internal_ip = network_info.get("IPAddress")
break
# Extract port mappings
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
# Find external domains for this port
external_domains = []
# Try matching by container name and port
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])
# Try matching by internal IP and port
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])
# Try matching by localhost and host port
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])
# Deduplicate external domains
external_domains = list(set(external_domains))
# Get service name from stack label or container name
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
# Deduplicate ports based on (port, container_name, protocol)
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)
# Sort by port number
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
Returns domain-to-service mappings with SSL status.
"""
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))
# Write endpoints
@router.post(
"/services",
response_model=OperationResult,
summary="Deploy a new Docker Compose stack",
description="Deploy a new service by creating a Docker Compose stack in Portainer. Provide the stack name and compose file content. Requires admin authentication.",
status_code=201
)
async def deploy_service(
request: DeployServiceRequest,
user: Dict = Depends(get_admin_user)
):
"""
Deploy a new Docker Compose stack via Portainer
Creates a new Portainer stack from the provided Docker Compose content.
This is equivalent to deploying a stack through the Portainer UI.
Args:
request: Stack deployment configuration containing:
- name: Stack name (must be unique)
- compose_content: Full docker-compose.yml content as string
- endpoint_id: Portainer endpoint ID (default: 3 for local)
Returns:
Operation result with created stack details including stack ID
Raises:
409: If a stack with the same name already exists
500: If deployment fails
"""
portainer = get_portainer_client()
try:
# Check if stack already exists
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')}"
)
# Create new stack
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",
description="Update a deployed stack's Docker Compose configuration. This redeploys the stack with the new configuration. Requires admin authentication."
)
async def update_service(
name: str,
request: UpdateServiceRequest,
user: Dict = Depends(get_admin_user)
):
"""
Update an existing Docker Compose stack's configuration
Updates the stack's compose file and redeploys it. This is equivalent
to updating a stack through the Portainer UI.
Args:
name: Service/stack name
request: Update configuration
Returns:
Operation result with updated stack details
"""
portainer = get_portainer_client()
try:
# Find stack by name
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")
# Update stack
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",
description="Delete a service and remove its stack. Requires admin authentication."
)
async def delete_service(
name: str,
user: Dict = Depends(get_admin_user)
):
"""
Delete a service and remove its stack
Args:
name: Service/stack name
Returns:
Operation result confirmation
"""
portainer = get_portainer_client()
try:
# Find stack by name
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")
# Delete stack
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
Args:
proxy_id: NPM proxy host ID
Returns:
Complete proxy host configuration including locations
"""
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",
description="Create a new Nginx Proxy Manager proxy host with optional SSL certificate. Requires admin authentication.",
status_code=201
)
async def create_proxy(
request: CreateProxyRequest,
user: Dict = Depends(get_admin_user)
):
"""
Create a new Nginx Proxy Manager proxy host
Optionally request an SSL certificate from Let's Encrypt.
Args:
request: Proxy host configuration
Returns:
Operation result with proxy host details
"""
npm = get_npm_client()
try:
certificate_id = 0
# Request SSL certificate if requested
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}")
# Create proxy host
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",
description="Update an existing Nginx Proxy Manager proxy host configuration. Requires admin authentication."
)
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
Args:
proxy_id: Proxy host ID to update
config: Full proxy host configuration (get from get_proxy_host, modify, then update)
Returns:
Operation result with updated proxy host details
"""
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))
# Service Control Endpoints
@router.get(
"/service-groups",
summary="List service groups"
)
async def list_service_groups():
"""
List all defined service groups
Returns service groups with their member services and status.
"""
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",
description="Stop a service or service group by stopping containers. Requires admin authentication when accessed externally via api.schweitz.net."
)
async def stop_service(
name: str,
user: Dict = Depends(get_forward_auth_admin)
):
"""
Stop a service or service group
This will:
1. Validate service can be stopped (not always-on)
2. Stop the Portainer stack containers
Args:
name: Service or group name
Returns:
Operation result with details
"""
portainer = get_portainer_client()
try:
# Get all services in the group
services = service_groups.get_service_group(name)
# Validate none are always-on
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": []
}
# Stop each service
for service_name in services:
try:
# Stop Portainer stack containers
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}")
# Get containers for this stack
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")
# Stop container via Portainer Docker API
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",
description="Start a service or service group by starting containers. Requires admin authentication when accessed externally via api.schweitz.net."
)
async def start_service(
name: str,
user: Dict = Depends(get_forward_auth_admin)
):
"""
Start a service or service group
This will:
1. Start the Portainer stack containers
Args:
name: Service or group name
Returns:
Operation result with details
"""
portainer = get_portainer_client()
try:
# Get all services in the group
services = service_groups.get_service_group(name)
results = {
"started_services": [],
"errors": []
}
# Start each service
for service_name in services:
try:
# Start Portainer stack containers
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}")
# Get containers for this stack
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")
# Start container via Portainer Docker API
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
Returns all data needed by service-control widget in a single call:
- Service list with status and container counts
- Service groups and always-on list
This endpoint is designed for browser-based widgets to avoid
multiple API calls and cross-origin issues.
"""
try:
portainer = get_portainer_client()
npm = get_npm_client()
# Fetch services (same logic as /services endpoint)
stacks = await portainer.get_stacks()
proxy_hosts = await npm.get_proxy_hosts()
# Build domain mapping
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
]
# Get container status
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)}")
# ========================================================================
# Container Management Endpoints (for core-ai infrastructure tools)
# ========================================================================
@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.
Args:
status: Filter by status - "all", "running", "stopped", "paused" (default: "running")
Returns:
List of containers in Docker API format
"""
portainer = get_portainer_client()
logger.info(f"Listing containers (status filter: {status})")
try:
# Get all containers using Portainer with Docker socket fallback
all_containers_flag = status in ["all", "stopped"]
containers = await portainer.list_containers(all_containers=all_containers_flag)
# Filter by status if needed
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.
Args:
container: Container name or ID
action: One of: "start", "stop", "restart", "pause", "unpause", "remove"
Returns:
Success status and message
"""
portainer = get_portainer_client()
logger.info(f"Managing container '{container}': action={action}")
# Validate 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:
# Find the container
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']
# Get endpoints
endpoints = await portainer.get_endpoints()
if not endpoints:
raise HTTPException(status_code=500, detail="No Portainer endpoints available")
endpoint_id = endpoints[0]['Id']
# Perform the action
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"]:
# These actions need to be added to Portainer client
# For now, return error
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)
# Provide helpful error messages
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.
Args:
container: Container name or ID
details: Level of detail - "summary", "full", or "resources" (not used server-side, for client formatting)
Returns:
Container details in Docker inspect format
"""
portainer = get_portainer_client()
logger.info(f"Inspecting container '{container}' (details={details})")
try:
# Use Portainer's inspect_container which auto-detects endpoint and falls back to Docker socket
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.
Args:
container: Container name or ID
lines: Number of log lines to retrieve (default: 50, max: 500)
since: Time filter - "1h", "30m", or ISO timestamp (not yet implemented)
Returns:
Container logs with metadata
"""
import httpx
logger.info(f"Retrieving logs for container '{container}' (lines={lines}, since={since})")
# Clamp lines to reasonable limit
lines = min(max(1, lines), 500)
try:
# Access Docker socket directly to get logs
transport = httpx.AsyncHTTPTransport(uds="/var/run/docker.sock")
async with httpx.AsyncClient(transport=transport, timeout=15.0) as client:
# Get container logs via Docker API
params = {
"stdout": "true",
"stderr": "true",
"tail": lines,
"timestamps": "true"
}
# TODO: Add 'since' parameter support
# if since:
# params["since"] = parse_since_to_timestamp(since)
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
# Docker logs come with binary prefixes (8 bytes per line)
# Strip these headers for cleaner output
lines_list = logs_raw.split('\n')
cleaned_lines = []
for line in lines_list:
if len(line) > 8:
# Skip the 8-byte Docker stream header if present
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))
# ========================================================================
# Monitoring & Resource Endpoints (for core-ai infrastructure tools)
# ========================================================================
@router.get(
"/resources/system",
response_model=Dict[str, Any],
summary="Get system resource usage"
)
async def get_system_resources():
"""
Get overall system resource usage.
Returns:
System-level metrics including CPU, memory, disk, and network
"""
logger.info("Getting system resources")
try:
# Access Docker system info via socket
import httpx
transport = httpx.AsyncHTTPTransport(uds="/var/run/docker.sock")
async with httpx.AsyncClient(transport=transport, timeout=10.0) as client:
# Get system info
info_response = await client.get("http://localhost/v1.41/info")
info_response.raise_for_status()
info = info_response.json()
# Get system df (disk usage)
df_response = await client.get("http://localhost/v1.41/system/df")
df_response.raise_for_status()
df = df_response.json()
# Extract system metrics
ncpu = info.get('NCPU', 0)
mem_total = info.get('MemTotal', 0)
# Calculate memory usage (rough estimate)
# Docker doesn't directly provide used memory, so we estimate
mem_used = mem_total * 0.5 # Placeholder - would need psutil or /proc
mem_available = mem_total - mem_used
mem_usage_pct = (mem_used / mem_total * 100) if mem_total > 0 else 0
# Disk usage from system/df
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, # Would need to calculate from stats over time
"load_average": [] # Not available from Docker API
},
"memory": {
"total_bytes": mem_total,
"used_bytes": mem_used,
"available_bytes": mem_available,
"usage_percent": mem_usage_pct
},
"disk": {
"total_bytes": None, # Not available from Docker API
"used_bytes": total_disk_used,
"available_bytes": None,
"usage_percent": None
},
"network": {
"interfaces": {} # Would need to parse network stats
}
}
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.
Args:
container: Optional specific container name/ID
Returns:
List of container resource stats
"""
logger.info(f"Getting container resources (container={container})")
try:
import httpx
portainer = get_portainer_client()
# Get list of containers
if container:
# Get specific 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:
# Get all running containers
containers = await portainer.list_containers(all_containers=False)
# Get stats for each container
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:
# Get container stats (one-time, not streaming)
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()
# Parse stats
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', {})
# Calculate CPU percentage
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
# Memory stats
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
# Network stats
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())
# Block I/O stats
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}")
# Skip containers that fail to get stats
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))
# ========================================================================
# Container Delete Endpoint (for Tatlock Control Room)
# ========================================================================
@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.
Args:
container_id: Full container ID
force: Force remove running container (default: false)
Returns:
204 No Content on success
"""
portainer = get_portainer_client()
logger.info(f"Deleting container '{container_id}' (force={force})")
try:
# Get endpoint
endpoints = await portainer.get_endpoints()
if not endpoints:
raise HTTPException(status_code=500, detail="No Portainer endpoints available")
endpoint_id = endpoints[0]['Id']
# Delete the container
await portainer.delete_container(endpoint_id, container_id, force=force)
logger.info(f"Successfully deleted container '{container_id}'")
return None # 204 No Content
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 to force remove."
)
else:
logger.error(f"Failed to delete container '{container_id}': {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
# ========================================================================
# Stack Management Endpoints (for Tatlock Control Room)
# ========================================================================
@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.
Args:
stack_id: Stack name (compose project name)
Returns:
Docker Compose YAML as text/yaml
"""
portainer = get_portainer_client()
logger.info(f"Getting compose file for stack '{stack_id}'")
try:
# Find stack by name
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")
# Get compose file content
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.
Args:
stack_id: Stack name (compose project name)
request: Raw YAML content in request body
Returns:
204 No Content on success
"""
portainer = get_portainer_client()
logger.info(f"Updating compose file for stack '{stack_id}'")
try:
# Read raw YAML from request body
compose_content = (await request.body()).decode("utf-8")
if not compose_content.strip():
raise HTTPException(status_code=400, detail="Empty compose content")
# Find stack by name
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")
# Update stack with new compose content
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 # 204 No Content
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.
Args:
stack_id: Stack name (compose project name)
Returns:
Dictionary of environment variable name-value pairs
"""
portainer = get_portainer_client()
logger.info(f"Getting env vars for stack '{stack_id}'")
try:
# Find stack by name
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")
# Get full stack details including env vars
stack_details = await portainer.get_stack(stack["Id"])
env_list = stack_details.get("Env", [])
# Convert from [{name, value}] to {name: value}
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.
Args:
stack_id: Stack name (compose project name)
env_vars: Dictionary of environment variable name-value pairs
Returns:
204 No Content on success
"""
portainer = get_portainer_client()
logger.info(f"Updating env vars for stack '{stack_id}'")
try:
# Find stack by name
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")
# Convert from {name: value} to [{name, value}]
env_list = [{"name": k, "value": v} for k, v in env_vars.items()]
# Update stack env vars
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 # 204 No Content
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.
Args:
stack_id: Stack name (compose project name)
Returns:
202 Accepted
"""
portainer = get_portainer_client()
logger.info(f"Deploying stack '{stack_id}'")
try:
# Find stack by name
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")
# Redeploy without pulling new images
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 # 202 Accepted
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.
Args:
stack_id: Stack name (compose project name)
Returns:
202 Accepted
"""
portainer = get_portainer_client()
logger.info(f"Rebuilding stack '{stack_id}'")
try:
# Find stack by name
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")
# Redeploy with image pull
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 # 202 Accepted
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()