Compare commits

...
1 Commits
Author SHA1 Message Date
jpmschweitzerandClaude Opus 4.5 167a3a43a8 feat: add stack management and container delete endpoints
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>
2025-12-31 10:19:50 +01:00
7 changed files with 1104 additions and 448 deletions
+18
View File
@@ -5,6 +5,24 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.3.0] - 2025-12-31
### Added
- **Stack Management Endpoints** for Tatlock Control Room integration
- `GET /infrastructure/stacks/{stackId}/compose` - Get stack Docker Compose YAML
- `PUT /infrastructure/stacks/{stackId}/compose` - Update stack Docker Compose YAML
- `GET /infrastructure/stacks/{stackId}/env` - Get stack environment variables
- `PUT /infrastructure/stacks/{stackId}/env` - Update stack environment variables
- `POST /infrastructure/stacks/{stackId}/deploy` - Redeploy stack
- `POST /infrastructure/stacks/{stackId}/rebuild` - Pull images and recreate containers
- `DELETE /infrastructure/containers/{id}` - Delete container with optional force flag
- Portainer client methods: `get_stack_file`, `redeploy_stack`, `update_stack_env`, `delete_container`, `restart_container`
### Removed
- REQUESTED_SERVICES.md - endpoint specifications now implemented
## [1.2.1] - 2025-12-17
### Fixed
-446
View File
@@ -1,446 +0,0 @@
# Requested Core-API Services for Core-AI Infrastructure Tools
This document specifies the API endpoints needed by core-ai infrastructure tools. All requests from core-ai should go through core-api for centralized logging and access control.
## Context
The core-ai service is implementing 9 infrastructure tools in 3 logical clusters:
1. **Container Lifecycle** (4 tools) - containers.py
2. **Service Management** (3 tools) - services.py
3. **Monitoring & Resources** (2 tools) - monitoring.py
These tools need corresponding core-api REST endpoints to perform operations via Portainer.
---
## Cluster 1: Container Lifecycle Management
### 1.1 List Containers
**Endpoint:** `GET /v1/infrastructure/containers`
**Query Parameters:**
- `status` (optional): Filter by status - "all", "running", "stopped", "paused" (default: "running")
**Response:**
```json
[
{
"Id": "abc123...",
"Names": ["/nginx"],
"State": "running",
"Status": "Up 3 days",
"Image": "nginx:latest",
"Ports": [
{"PrivatePort": 80, "PublicPort": 8080, "Type": "tcp"},
{"PrivatePort": 443, "PublicPort": 8443, "Type": "tcp"}
],
"StartedAt": "2024-12-01T10:00:00Z"
}
]
```
**Implementation Notes:**
- Use `PortainerClient.list_containers(all_containers=True)` with Docker socket fallback
- Filter results based on `status` query parameter
- Return standard Docker API container list format
---
### 1.2 Manage Container
**Endpoint:** `POST /v1/infrastructure/containers/{container}/{action}`
**Path Parameters:**
- `container`: Container name or ID (e.g., "nginx", "core-ai")
- `action`: One of: "start", "stop", "restart", "pause", "unpause", "remove"
**Response (Success):**
```json
{
"success": true,
"action": "restart",
"container": "nginx",
"message": "Container restarted successfully"
}
```
**Response (Error):**
```json
{
"success": false,
"error": "Container not found",
"message": "Container 'nginx2' not found. Available containers: nginx, core-ai, ollama"
}
```
**Status Codes:**
- `200` - Success
- `304` - Not Modified (already in target state)
- `404` - Container not found
- `409` - Conflict (e.g., cannot remove running container)
- `500` - Server error
**Implementation Notes:**
- For "restart": call stop then start
- For actions not yet in PortainerClient (pause, unpause, remove):
- Call Portainer API directly: `/api/endpoints/{endpoint_id}/docker/containers/{container_id}/{action}`
- Handle partial name matching (case-insensitive)
- Return helpful error messages suggesting `docker_list_containers()` when not found
---
### 1.3 Inspect Container
**Endpoint:** `GET /v1/infrastructure/containers/{container}`
**Path Parameters:**
- `container`: Container name or ID
**Query Parameters:**
- `details` (optional): Level of detail - "summary" (default), "full", "resources"
**Response:**
```json
{
"Id": "abc123...",
"Name": "/nginx",
"State": {
"Status": "running",
"Running": true,
"StartedAt": "2024-12-01T10:00:00Z",
"FinishedAt": "0001-01-01T00:00:00Z",
"ExitCode": 0
},
"Config": {
"Image": "nginx:latest",
"Env": ["PATH=/usr/local/sbin:...", "NGINX_VERSION=1.25.0"],
"Cmd": ["nginx", "-g", "daemon off;"]
},
"NetworkSettings": {
"Ports": {
"80/tcp": [{"HostIp": "0.0.0.0", "HostPort": "8080"}],
"443/tcp": [{"HostIp": "0.0.0.0", "HostPort": "8443"}]
},
"Networks": {
"bridge": {
"IPAddress": "172.17.0.2",
"Gateway": "172.17.0.1"
}
}
},
"HostConfig": {
"Memory": 536870912,
"NanoCpus": 1000000000,
"RestartPolicy": {"Name": "unless-stopped"}
},
"Mounts": [
{
"Type": "bind",
"Source": "/host/path",
"Destination": "/container/path"
}
]
}
```
**Implementation Notes:**
- Use `PortainerClient.inspect_container(container)` which auto-detects endpoint and falls back to Docker socket
- Return full Docker inspect response
- The core-ai tool will handle formatting based on `details` level
- Return 404 if container not found
---
### 1.4 Container Logs
**Endpoint:** `GET /v1/infrastructure/containers/{container}/logs`
**Path Parameters:**
- `container`: Container name or ID
**Query Parameters:**
- `lines` (optional): Number of log lines (default: 50, max: 500)
- `since` (optional): Time filter - "1h", "30m", or ISO timestamp
**Response:**
```json
{
"container": "nginx",
"lines_requested": 50,
"since": null,
"logs": "2024-12-04T10:00:00.123Z Starting nginx...\n2024-12-04T10:00:01.456Z Ready to accept connections\n..."
}
```
**Implementation Notes:**
- Access Docker API directly: `GET /v1.41/containers/{container}/logs`
- Use Docker socket transport (httpx with uds)
- Parameters: `stdout=true`, `stderr=true`, `tail={lines}`, `timestamps=true`
- If `since` provided: add `since={unix_timestamp}` parameter
- Strip Docker stream headers (8-byte binary prefix per line)
- Return plain text logs with timestamps
- Return 404 if container not found
---
## Cluster 2: Service Management
### 2.1 List Services
**Endpoint:** `GET /v1/infrastructure/services` (already exists, may need enhancement)
**Query Parameters:**
- `stack` (optional): Filter by stack name
**Response:**
```json
[
{
"name": "portainer",
"stack_id": 1,
"status": "active",
"containers_running": 3,
"containers_total": 3,
"ports": [9000, 8000],
"domains": ["portainer.example.com"]
}
]
```
**Implementation Notes:**
- Enhance existing `/infrastructure/services` endpoint if needed
- Ensure it returns stack/service information from Portainer
- Include container counts (running/total)
---
### 2.2 Manage Service
**Endpoint:** `POST /v1/infrastructure/services/{service}/{action}`
**Path Parameters:**
- `service`: Service/stack name
- `action`: One of: "start", "stop", "restart", "scale"
**Request Body (for scale action):**
```json
{
"replicas": 3
}
```
**Response:**
```json
{
"success": true,
"action": "restart",
"service": "web",
"message": "Service restarted successfully"
}
```
**Implementation Notes:**
- For "start"/"stop": Use Portainer stack start/stop API
- For "restart": Stop then start the stack
- For "scale": Update stack with new replica count
- This may require updating stack compose file
---
### 2.3 Service Status
**Endpoint:** `GET /v1/infrastructure/services/{service}/status`
**Path Parameters:**
- `service`: Service/stack name
**Response:**
```json
{
"name": "web",
"status": "active",
"stack_id": 5,
"containers": [
{
"name": "web_app_1",
"status": "running",
"health": "healthy",
"uptime": "2 days"
}
],
"replica_status": "3/3 running",
"resources": {
"memory_total": "1.2 GB",
"cpu_usage": "15%"
},
"recent_events": [
{"time": "2024-12-04T09:00:00Z", "action": "container_start", "container": "web_app_3"}
]
}
```
**Implementation Notes:**
- Get stack details from Portainer
- Get individual container statuses
- Calculate aggregate resource usage
- May require querying Docker events API for recent events
---
## Cluster 3: Monitoring & Resources
### 3.1 System Resources
**Endpoint:** `GET /v1/infrastructure/resources/system`
**Response:**
```json
{
"cpu": {
"cores": 8,
"usage_percent": 45.2,
"load_average": [2.5, 2.3, 2.1]
},
"memory": {
"total_bytes": 16777216000,
"used_bytes": 8388608000,
"available_bytes": 8388608000,
"usage_percent": 50.0
},
"disk": {
"total_bytes": 500000000000,
"used_bytes": 250000000000,
"available_bytes": 250000000000,
"usage_percent": 50.0
},
"network": {
"interfaces": {
"eth0": {
"rx_bytes": 1000000000,
"tx_bytes": 500000000
}
}
}
}
```
**Implementation Notes:**
- Use Docker system info API: `GET /v1.41/system/df`
- May also use `GET /v1.41/info` for system-wide stats
- Calculate percentages and format nicely
- Include load averages from system stats
---
### 3.2 Container Resources
**Endpoint:** `GET /v1/infrastructure/resources/containers`
**Query Parameters:**
- `container` (optional): Specific container name/ID (if omitted, return all)
**Response:**
```json
[
{
"name": "nginx",
"cpu_percent": 5.2,
"memory_usage_bytes": 45000000,
"memory_limit_bytes": 100000000,
"memory_percent": 45.0,
"network_rx_bytes": 50000000,
"network_tx_bytes": 25000000,
"block_read_bytes": 10000000,
"block_write_bytes": 5000000
}
]
```
**Implementation Notes:**
- Use Docker stats API: `GET /v1.41/containers/{id}/stats?stream=false`
- If `container` param provided: return single container stats
- If omitted: return stats for all running containers
- Calculate percentages where applicable
- Stats API returns real-time metrics (one-time snapshot, not streaming)
---
## Implementation Priority
**Phase 1 (Needed immediately for core-ai):**
1. `GET /v1/infrastructure/containers` - List containers
2. `POST /v1/infrastructure/containers/{container}/{action}` - Manage containers
3. `GET /v1/infrastructure/containers/{container}` - Inspect container
4. `GET /v1/infrastructure/containers/{container}/logs` - Container logs
**Phase 2 (Needed for full infrastructure tools):**
5. `POST /v1/infrastructure/services/{service}/{action}` - Manage services
6. `GET /v1/infrastructure/services/{service}/status` - Service status
7. `GET /v1/infrastructure/resources/system` - System resources
8. `GET /v1/infrastructure/resources/containers` - Container resources
---
## Security & Access Control
All endpoints should:
- Log all requests (especially write operations)
- Support OIDC authentication when enabled
- Require admin privileges for destructive operations (remove, scale)
- Rate limit to prevent abuse
- Validate input parameters
- Return sanitized errors (no sensitive data in error messages)
---
## Error Handling
Standard error response format:
```json
{
"error": "ContainerNotFound",
"message": "Container 'nginx2' not found",
"details": {
"container": "nginx2",
"available_containers": ["nginx", "core-ai", "ollama"]
}
}
```
Common error codes:
- `400` - Bad Request (invalid parameters)
- `404` - Not Found (container/service doesn't exist)
- `409` - Conflict (invalid state transition)
- `500` - Internal Server Error (Portainer/Docker API failed)
- `503` - Service Unavailable (Portainer/Docker not accessible)
---
## Testing
Each endpoint should have:
- Unit tests (mock Portainer client)
- Integration tests (real Portainer/Docker)
- Error case tests (not found, permission denied, etc.)
- Performance tests (ensure response times < 2s)
---
## Questions / Decisions Needed
1. **Authentication**: Should container management require admin role, or allow read-only for all users?
2. **Rate Limiting**: What limits should be applied to prevent abuse?
3. **Caching**: Should container lists be cached? (TTL: 5s?)
4. **Async**: Should heavy operations (like logs) be async with job IDs?
5. **Webhooks**: Should operations emit events for monitoring?
---
## Notes
- All endpoints follow RESTful conventions
- Use existing PortainerClient methods where available
- Fall back to Docker socket when Portainer doesn't have data
- Log all operations with timestamps, user, and outcome
- Consider adding `/v1/infrastructure/containers/search` for fuzzy name matching
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "core-api"
version = "1.2.1"
version = "1.3.0"
description = "Core Code API - Infrastructure management and tools API"
readme = "README.md"
requires-python = ">=3.12"
+146
View File
@@ -210,6 +210,152 @@ class PortainerClient:
response.raise_for_status()
return True
async def get_stack_file(self, stack_id: int) -> str:
"""
Get the compose file content for a stack
Args:
stack_id: Stack identifier
Returns:
Docker Compose YAML content as string
"""
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.get(
f"{self.base_url}/api/stacks/{stack_id}/file",
headers=self._get_headers()
)
response.raise_for_status()
data = response.json()
return data.get("StackFileContent", "")
async def redeploy_stack(
self,
stack_id: int,
endpoint_id: int,
pull_image: bool = False
) -> Dict[str, Any]:
"""
Redeploy a stack with its current configuration
Args:
stack_id: Stack identifier
endpoint_id: Portainer endpoint
pull_image: Pull latest images before deployment
Returns:
Updated stack details
"""
# Get current stack file content
stack_content = await self.get_stack_file(stack_id)
# Get current stack to preserve env vars
stack = await self.get_stack(stack_id)
env_vars = stack.get("Env", [])
payload = {
"stackFileContent": stack_content,
"env": env_vars,
"prune": False,
"pullImage": pull_image
}
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.put(
f"{self.base_url}/api/stacks/{stack_id}",
headers=self._get_headers(),
params={"endpointId": endpoint_id},
json=payload
)
response.raise_for_status()
return response.json()
async def update_stack_env(
self,
stack_id: int,
endpoint_id: int,
env_vars: List[Dict[str, str]]
) -> Dict[str, Any]:
"""
Update stack environment variables
Args:
stack_id: Stack identifier
endpoint_id: Portainer endpoint
env_vars: List of {"name": "VAR_NAME", "value": "var_value"} dicts
Returns:
Updated stack details
"""
# Get current stack file content (required for update)
stack_content = await self.get_stack_file(stack_id)
payload = {
"stackFileContent": stack_content,
"env": env_vars,
"prune": False,
"pullImage": False
}
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.put(
f"{self.base_url}/api/stacks/{stack_id}",
headers=self._get_headers(),
params={"endpointId": endpoint_id},
json=payload
)
response.raise_for_status()
return response.json()
async def delete_container(
self,
endpoint_id: int,
container_id: str,
force: bool = False
) -> bool:
"""
Delete a container
Args:
endpoint_id: Portainer endpoint identifier
container_id: Container ID or name
force: Force remove running container
Returns:
True if successful
"""
params = {"force": "true" if force else "false"}
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.delete(
f"{self.base_url}/api/endpoints/{endpoint_id}/docker/containers/{container_id}",
headers=self._get_headers(),
params=params
)
response.raise_for_status()
logger.info(f"Deleted container {container_id}")
return True
async def restart_container(self, endpoint_id: int, container_id: str) -> bool:
"""
Restart a container
Args:
endpoint_id: Portainer endpoint identifier
container_id: Container ID or name
Returns:
True if successful
"""
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.post(
f"{self.base_url}/api/endpoints/{endpoint_id}/docker/containers/{container_id}/restart",
headers=self._get_headers()
)
response.raise_for_status()
logger.info(f"Restarted container {container_id}")
return True
async def get_containers(self, endpoint_id: int, all_containers: bool = True) -> List[Dict[str, Any]]:
"""
List containers on a specific endpoint
+353 -1
View File
@@ -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
+376
View File
@@ -402,3 +402,379 @@ class TestInfrastructureGetService:
response = client.get("/infrastructure/services/nonexistent")
assert response.status_code == 404
class TestInfrastructureDeleteContainer:
"""Test DELETE /infrastructure/containers/{container_id} endpoint."""
@patch("src.controllers.infrastructure_controller.get_portainer_client")
@patch("src.controllers.infrastructure_controller.get_npm_client")
def test_delete_container_returns_204(self, mock_get_npm, mock_get_portainer, client):
"""Delete container should return 204 on success."""
mock_portainer = AsyncMock()
mock_portainer.get_endpoints.return_value = [{"Id": 1}]
mock_portainer.delete_container.return_value = True
mock_get_portainer.return_value = mock_portainer
mock_npm = AsyncMock()
mock_get_npm.return_value = mock_npm
response = client.delete("/infrastructure/containers/abc123")
assert response.status_code == 204
@patch("src.controllers.infrastructure_controller.get_portainer_client")
@patch("src.controllers.infrastructure_controller.get_npm_client")
def test_delete_container_with_force(self, mock_get_npm, mock_get_portainer, client):
"""Delete container should pass force parameter."""
mock_portainer = AsyncMock()
mock_portainer.get_endpoints.return_value = [{"Id": 1}]
mock_portainer.delete_container.return_value = True
mock_get_portainer.return_value = mock_portainer
mock_npm = AsyncMock()
mock_get_npm.return_value = mock_npm
response = client.delete("/infrastructure/containers/abc123?force=true")
assert response.status_code == 204
mock_portainer.delete_container.assert_called_once_with(1, "abc123", force=True)
@patch("src.controllers.infrastructure_controller.get_portainer_client")
@patch("src.controllers.infrastructure_controller.get_npm_client")
def test_delete_container_returns_404_for_missing(self, mock_get_npm, mock_get_portainer, client):
"""Delete container should return 404 if not found."""
mock_portainer = AsyncMock()
mock_portainer.get_endpoints.return_value = [{"Id": 1}]
mock_portainer.delete_container.side_effect = Exception("404 no such container")
mock_get_portainer.return_value = mock_portainer
mock_npm = AsyncMock()
mock_get_npm.return_value = mock_npm
response = client.delete("/infrastructure/containers/nonexistent")
assert response.status_code == 404
class TestInfrastructureStackCompose:
"""Test /infrastructure/stacks/{stackId}/compose endpoints."""
@patch("src.controllers.infrastructure_controller.get_portainer_client")
@patch("src.controllers.infrastructure_controller.get_npm_client")
def test_get_stack_compose_returns_yaml(self, mock_get_npm, mock_get_portainer, client):
"""Get stack compose should return YAML content."""
mock_portainer = AsyncMock()
mock_portainer.get_stacks.return_value = [
{"Id": 1, "Name": "mystack", "EndpointId": 1}
]
mock_portainer.get_stack_file.return_value = "version: '3'\nservices:\n web:\n image: nginx"
mock_get_portainer.return_value = mock_portainer
mock_npm = AsyncMock()
mock_get_npm.return_value = mock_npm
response = client.get("/infrastructure/stacks/mystack/compose")
assert response.status_code == 200
assert "text/yaml" in response.headers.get("content-type", "")
assert "version:" in response.text
@patch("src.controllers.infrastructure_controller.get_portainer_client")
@patch("src.controllers.infrastructure_controller.get_npm_client")
def test_get_stack_compose_returns_404_for_missing(self, mock_get_npm, mock_get_portainer, client):
"""Get stack compose should return 404 if stack not found."""
mock_portainer = AsyncMock()
mock_portainer.get_stacks.return_value = []
mock_get_portainer.return_value = mock_portainer
mock_npm = AsyncMock()
mock_get_npm.return_value = mock_npm
response = client.get("/infrastructure/stacks/nonexistent/compose")
assert response.status_code == 404
@patch("src.controllers.infrastructure_controller.get_portainer_client")
@patch("src.controllers.infrastructure_controller.get_npm_client")
def test_put_stack_compose_returns_204(self, mock_get_npm, mock_get_portainer, client):
"""Update stack compose should return 204 on success."""
mock_portainer = AsyncMock()
mock_portainer.get_stacks.return_value = [
{"Id": 1, "Name": "mystack", "EndpointId": 1}
]
mock_portainer.update_stack.return_value = {"Id": 1}
mock_get_portainer.return_value = mock_portainer
mock_npm = AsyncMock()
mock_get_npm.return_value = mock_npm
response = client.put(
"/infrastructure/stacks/mystack/compose",
content="version: '3'\nservices:\n web:\n image: nginx:latest",
headers={"Content-Type": "text/yaml"}
)
assert response.status_code == 204
@patch("src.controllers.infrastructure_controller.get_portainer_client")
@patch("src.controllers.infrastructure_controller.get_npm_client")
def test_put_stack_compose_returns_400_for_empty(self, mock_get_npm, mock_get_portainer, client):
"""Update stack compose should return 400 for empty content."""
mock_portainer = AsyncMock()
mock_portainer.get_stacks.return_value = [
{"Id": 1, "Name": "mystack", "EndpointId": 1}
]
mock_get_portainer.return_value = mock_portainer
mock_npm = AsyncMock()
mock_get_npm.return_value = mock_npm
response = client.put(
"/infrastructure/stacks/mystack/compose",
content="",
headers={"Content-Type": "text/yaml"}
)
assert response.status_code == 400
@patch("src.controllers.infrastructure_controller.get_portainer_client")
@patch("src.controllers.infrastructure_controller.get_npm_client")
def test_put_stack_compose_returns_404_for_missing(self, mock_get_npm, mock_get_portainer, client):
"""Update stack compose should return 404 if stack not found."""
mock_portainer = AsyncMock()
mock_portainer.get_stacks.return_value = []
mock_get_portainer.return_value = mock_portainer
mock_npm = AsyncMock()
mock_get_npm.return_value = mock_npm
response = client.put(
"/infrastructure/stacks/nonexistent/compose",
content="version: '3'",
headers={"Content-Type": "text/yaml"}
)
assert response.status_code == 404
class TestInfrastructureStackEnv:
"""Test /infrastructure/stacks/{stackId}/env endpoints."""
@patch("src.controllers.infrastructure_controller.get_portainer_client")
@patch("src.controllers.infrastructure_controller.get_npm_client")
def test_get_stack_env_returns_dict(self, mock_get_npm, mock_get_portainer, client):
"""Get stack env should return environment variables as dict."""
mock_portainer = AsyncMock()
mock_portainer.get_stacks.return_value = [
{"Id": 1, "Name": "mystack", "EndpointId": 1}
]
mock_portainer.get_stack.return_value = {
"Id": 1,
"Name": "mystack",
"Env": [
{"name": "DB_HOST", "value": "localhost"},
{"name": "DB_PORT", "value": "5432"}
]
}
mock_get_portainer.return_value = mock_portainer
mock_npm = AsyncMock()
mock_get_npm.return_value = mock_npm
response = client.get("/infrastructure/stacks/mystack/env")
assert response.status_code == 200
data = response.json()
assert data == {"DB_HOST": "localhost", "DB_PORT": "5432"}
@patch("src.controllers.infrastructure_controller.get_portainer_client")
@patch("src.controllers.infrastructure_controller.get_npm_client")
def test_get_stack_env_returns_empty_dict(self, mock_get_npm, mock_get_portainer, client):
"""Get stack env should return empty dict if no env vars."""
mock_portainer = AsyncMock()
mock_portainer.get_stacks.return_value = [
{"Id": 1, "Name": "mystack", "EndpointId": 1}
]
mock_portainer.get_stack.return_value = {"Id": 1, "Name": "mystack", "Env": []}
mock_get_portainer.return_value = mock_portainer
mock_npm = AsyncMock()
mock_get_npm.return_value = mock_npm
response = client.get("/infrastructure/stacks/mystack/env")
assert response.status_code == 200
assert response.json() == {}
@patch("src.controllers.infrastructure_controller.get_portainer_client")
@patch("src.controllers.infrastructure_controller.get_npm_client")
def test_get_stack_env_returns_404_for_missing(self, mock_get_npm, mock_get_portainer, client):
"""Get stack env should return 404 if stack not found."""
mock_portainer = AsyncMock()
mock_portainer.get_stacks.return_value = []
mock_get_portainer.return_value = mock_portainer
mock_npm = AsyncMock()
mock_get_npm.return_value = mock_npm
response = client.get("/infrastructure/stacks/nonexistent/env")
assert response.status_code == 404
@patch("src.controllers.infrastructure_controller.get_portainer_client")
@patch("src.controllers.infrastructure_controller.get_npm_client")
def test_put_stack_env_returns_204(self, mock_get_npm, mock_get_portainer, client):
"""Update stack env should return 204 on success."""
mock_portainer = AsyncMock()
mock_portainer.get_stacks.return_value = [
{"Id": 1, "Name": "mystack", "EndpointId": 1}
]
mock_portainer.update_stack_env.return_value = {"Id": 1}
mock_get_portainer.return_value = mock_portainer
mock_npm = AsyncMock()
mock_get_npm.return_value = mock_npm
response = client.put(
"/infrastructure/stacks/mystack/env",
json={"DB_HOST": "newhost", "DB_PORT": "5433"}
)
assert response.status_code == 204
@patch("src.controllers.infrastructure_controller.get_portainer_client")
@patch("src.controllers.infrastructure_controller.get_npm_client")
def test_put_stack_env_returns_404_for_missing(self, mock_get_npm, mock_get_portainer, client):
"""Update stack env should return 404 if stack not found."""
mock_portainer = AsyncMock()
mock_portainer.get_stacks.return_value = []
mock_get_portainer.return_value = mock_portainer
mock_npm = AsyncMock()
mock_get_npm.return_value = mock_npm
response = client.put(
"/infrastructure/stacks/nonexistent/env",
json={"KEY": "value"}
)
assert response.status_code == 404
class TestInfrastructureStackDeploy:
"""Test /infrastructure/stacks/{stackId}/deploy endpoint."""
@patch("src.controllers.infrastructure_controller.get_portainer_client")
@patch("src.controllers.infrastructure_controller.get_npm_client")
def test_deploy_stack_returns_202(self, mock_get_npm, mock_get_portainer, client):
"""Deploy stack should return 202 Accepted."""
mock_portainer = AsyncMock()
mock_portainer.get_stacks.return_value = [
{"Id": 1, "Name": "mystack", "EndpointId": 1}
]
mock_portainer.redeploy_stack.return_value = {"Id": 1}
mock_get_portainer.return_value = mock_portainer
mock_npm = AsyncMock()
mock_get_npm.return_value = mock_npm
response = client.post("/infrastructure/stacks/mystack/deploy")
assert response.status_code == 202
@patch("src.controllers.infrastructure_controller.get_portainer_client")
@patch("src.controllers.infrastructure_controller.get_npm_client")
def test_deploy_stack_does_not_pull_images(self, mock_get_npm, mock_get_portainer, client):
"""Deploy stack should not pull images."""
mock_portainer = AsyncMock()
mock_portainer.get_stacks.return_value = [
{"Id": 1, "Name": "mystack", "EndpointId": 1}
]
mock_portainer.redeploy_stack.return_value = {"Id": 1}
mock_get_portainer.return_value = mock_portainer
mock_npm = AsyncMock()
mock_get_npm.return_value = mock_npm
response = client.post("/infrastructure/stacks/mystack/deploy")
assert response.status_code == 202
mock_portainer.redeploy_stack.assert_called_once_with(
stack_id=1,
endpoint_id=1,
pull_image=False
)
@patch("src.controllers.infrastructure_controller.get_portainer_client")
@patch("src.controllers.infrastructure_controller.get_npm_client")
def test_deploy_stack_returns_404_for_missing(self, mock_get_npm, mock_get_portainer, client):
"""Deploy stack should return 404 if stack not found."""
mock_portainer = AsyncMock()
mock_portainer.get_stacks.return_value = []
mock_get_portainer.return_value = mock_portainer
mock_npm = AsyncMock()
mock_get_npm.return_value = mock_npm
response = client.post("/infrastructure/stacks/nonexistent/deploy")
assert response.status_code == 404
class TestInfrastructureStackRebuild:
"""Test /infrastructure/stacks/{stackId}/rebuild endpoint."""
@patch("src.controllers.infrastructure_controller.get_portainer_client")
@patch("src.controllers.infrastructure_controller.get_npm_client")
def test_rebuild_stack_returns_202(self, mock_get_npm, mock_get_portainer, client):
"""Rebuild stack should return 202 Accepted."""
mock_portainer = AsyncMock()
mock_portainer.get_stacks.return_value = [
{"Id": 1, "Name": "mystack", "EndpointId": 1}
]
mock_portainer.redeploy_stack.return_value = {"Id": 1}
mock_get_portainer.return_value = mock_portainer
mock_npm = AsyncMock()
mock_get_npm.return_value = mock_npm
response = client.post("/infrastructure/stacks/mystack/rebuild")
assert response.status_code == 202
@patch("src.controllers.infrastructure_controller.get_portainer_client")
@patch("src.controllers.infrastructure_controller.get_npm_client")
def test_rebuild_stack_pulls_images(self, mock_get_npm, mock_get_portainer, client):
"""Rebuild stack should pull images."""
mock_portainer = AsyncMock()
mock_portainer.get_stacks.return_value = [
{"Id": 1, "Name": "mystack", "EndpointId": 1}
]
mock_portainer.redeploy_stack.return_value = {"Id": 1}
mock_get_portainer.return_value = mock_portainer
mock_npm = AsyncMock()
mock_get_npm.return_value = mock_npm
response = client.post("/infrastructure/stacks/mystack/rebuild")
assert response.status_code == 202
mock_portainer.redeploy_stack.assert_called_once_with(
stack_id=1,
endpoint_id=1,
pull_image=True
)
@patch("src.controllers.infrastructure_controller.get_portainer_client")
@patch("src.controllers.infrastructure_controller.get_npm_client")
def test_rebuild_stack_returns_404_for_missing(self, mock_get_npm, mock_get_portainer, client):
"""Rebuild stack should return 404 if stack not found."""
mock_portainer = AsyncMock()
mock_portainer.get_stacks.return_value = []
mock_get_portainer.return_value = mock_portainer
mock_npm = AsyncMock()
mock_get_npm.return_value = mock_npm
response = client.post("/infrastructure/stacks/nonexistent/rebuild")
assert response.status_code == 404
@patch("src.controllers.infrastructure_controller.get_portainer_client")
@patch("src.controllers.infrastructure_controller.get_npm_client")
def test_rebuild_stack_case_insensitive(self, mock_get_npm, mock_get_portainer, client):
"""Rebuild stack should match stack name case-insensitively."""
mock_portainer = AsyncMock()
mock_portainer.get_stacks.return_value = [
{"Id": 1, "Name": "MyStack", "EndpointId": 1}
]
mock_portainer.redeploy_stack.return_value = {"Id": 1}
mock_get_portainer.return_value = mock_portainer
mock_npm = AsyncMock()
mock_get_npm.return_value = mock_npm
response = client.post("/infrastructure/stacks/mystack/rebuild")
assert response.status_code == 202
+210
View File
@@ -561,6 +561,216 @@ class TestPortainerClientWrapperMethods:
assert result is None
class TestPortainerClientStackFile:
"""Test stack file operations."""
@pytest.mark.asyncio
async def test_get_stack_file_returns_content(self):
"""get_stack_file should return compose YAML content."""
client = PortainerClient(base_url="http://portainer:9000", api_key="key")
compose_content = "version: '3'\nservices:\n web:\n image: nginx"
with patch("httpx.AsyncClient") as mock_client_class:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"StackFileContent": compose_content}
mock_response.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.get.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client_class.return_value = mock_client
result = await client.get_stack_file(1)
assert result == compose_content
@pytest.mark.asyncio
async def test_get_stack_file_returns_empty_string_if_missing(self):
"""get_stack_file should return empty string if content missing."""
client = PortainerClient(base_url="http://portainer:9000", api_key="key")
with patch("httpx.AsyncClient") as mock_client_class:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {}
mock_response.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.get.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client_class.return_value = mock_client
result = await client.get_stack_file(1)
assert result == ""
class TestPortainerClientRedeployStack:
"""Test stack redeploy operations."""
@pytest.mark.asyncio
async def test_redeploy_stack_without_pull(self):
"""redeploy_stack should redeploy without pulling images by default."""
client = PortainerClient(base_url="http://portainer:9000", api_key="key")
with patch.object(client, "get_stack_file", new_callable=AsyncMock) as mock_file:
mock_file.return_value = "version: '3'"
with patch.object(client, "get_stack", new_callable=AsyncMock) as mock_stack:
mock_stack.return_value = {"Id": 1, "Env": [{"name": "KEY", "value": "val"}]}
with patch("httpx.AsyncClient") as mock_client_class:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"Id": 1}
mock_response.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.put.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client_class.return_value = mock_client
result = await client.redeploy_stack(1, 1, pull_image=False)
call_args = mock_client.put.call_args
assert call_args[1]["json"]["pullImage"] is False
assert result == {"Id": 1}
@pytest.mark.asyncio
async def test_redeploy_stack_with_pull(self):
"""redeploy_stack should pull images when requested."""
client = PortainerClient(base_url="http://portainer:9000", api_key="key")
with patch.object(client, "get_stack_file", new_callable=AsyncMock) as mock_file:
mock_file.return_value = "version: '3'"
with patch.object(client, "get_stack", new_callable=AsyncMock) as mock_stack:
mock_stack.return_value = {"Id": 1, "Env": []}
with patch("httpx.AsyncClient") as mock_client_class:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"Id": 1}
mock_response.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.put.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client_class.return_value = mock_client
await client.redeploy_stack(1, 1, pull_image=True)
call_args = mock_client.put.call_args
assert call_args[1]["json"]["pullImage"] is True
class TestPortainerClientUpdateStackEnv:
"""Test stack environment variable updates."""
@pytest.mark.asyncio
async def test_update_stack_env_sends_env_vars(self):
"""update_stack_env should update environment variables."""
client = PortainerClient(base_url="http://portainer:9000", api_key="key")
env_vars = [{"name": "DB_HOST", "value": "localhost"}]
with patch.object(client, "get_stack_file", new_callable=AsyncMock) as mock_file:
mock_file.return_value = "version: '3'"
with patch("httpx.AsyncClient") as mock_client_class:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"Id": 1}
mock_response.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.put.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client_class.return_value = mock_client
await client.update_stack_env(1, 1, env_vars)
call_args = mock_client.put.call_args
assert call_args[1]["json"]["env"] == env_vars
class TestPortainerClientDeleteContainer:
"""Test container deletion."""
@pytest.mark.asyncio
async def test_delete_container_returns_true(self):
"""delete_container should return True on success."""
client = PortainerClient(base_url="http://portainer:9000", api_key="key")
with patch("httpx.AsyncClient") as mock_client_class:
mock_response = MagicMock()
mock_response.status_code = 204
mock_response.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.delete.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client_class.return_value = mock_client
result = await client.delete_container(1, "abc123")
assert result is True
@pytest.mark.asyncio
async def test_delete_container_with_force(self):
"""delete_container should pass force parameter."""
client = PortainerClient(base_url="http://portainer:9000", api_key="key")
with patch("httpx.AsyncClient") as mock_client_class:
mock_response = MagicMock()
mock_response.status_code = 204
mock_response.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.delete.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client_class.return_value = mock_client
await client.delete_container(1, "abc123", force=True)
call_args = mock_client.delete.call_args
assert call_args[1]["params"]["force"] == "true"
class TestPortainerClientRestartContainer:
"""Test container restart."""
@pytest.mark.asyncio
async def test_restart_container_returns_true(self):
"""restart_container should return True on success."""
client = PortainerClient(base_url="http://portainer:9000", api_key="key")
with patch("httpx.AsyncClient") as mock_client_class:
mock_response = MagicMock()
mock_response.status_code = 204
mock_response.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.post.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client_class.return_value = mock_client
result = await client.restart_container(1, "abc123")
assert result is True
mock_client.post.assert_called_once()
class TestPortainerClientSingleton:
"""Test singleton pattern."""