core-ai - OBSOLETE
This commit is contained in:
@@ -0,0 +1,446 @@
|
||||
# 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
|
||||
@@ -287,6 +287,180 @@ class InfrastructureController(BaseController):
|
||||
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],
|
||||
@@ -1284,6 +1458,474 @@ class InfrastructureController(BaseController):
|
||||
logger.error(f"Failed to delete monitor {monitor_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to delete monitor: {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))
|
||||
|
||||
return router
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user