feat: add stack management and container delete endpoints
Build and Push / build (release) Successful in 29s
Build and Push / build (release) Successful in 29s
- 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>
This commit is contained in:
@@ -4,7 +4,8 @@ Infrastructure Management Controller
|
||||
Provides API endpoints for automated infrastructure management,
|
||||
including service deployment, configuration, and monitoring setup.
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
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
|
||||
|
||||
@@ -1700,6 +1701,357 @@ class InfrastructureController(BaseController):
|
||||
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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user