Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
167a3a43a8 |
@@ -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/),
|
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).
|
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
|
## [1.2.1] - 2025-12-17
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -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
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "core-api"
|
name = "core-api"
|
||||||
version = "1.2.1"
|
version = "1.3.0"
|
||||||
description = "Core Code API - Infrastructure management and tools API"
|
description = "Core Code API - Infrastructure management and tools API"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
|
|||||||
@@ -210,6 +210,152 @@ class PortainerClient:
|
|||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return True
|
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]]:
|
async def get_containers(self, endpoint_id: int, all_containers: bool = True) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
List containers on a specific endpoint
|
List containers on a specific endpoint
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ Infrastructure Management Controller
|
|||||||
Provides API endpoints for automated infrastructure management,
|
Provides API endpoints for automated infrastructure management,
|
||||||
including service deployment, configuration, and monitoring setup.
|
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 typing import List, Dict, Any, Optional, Union
|
||||||
from pydantic import BaseModel, field_validator
|
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)
|
logger.error(f"Failed to get container resources: {e}", exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
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
|
return router
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -402,3 +402,379 @@ class TestInfrastructureGetService:
|
|||||||
|
|
||||||
response = client.get("/infrastructure/services/nonexistent")
|
response = client.get("/infrastructure/services/nonexistent")
|
||||||
assert response.status_code == 404
|
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
|
||||||
|
|||||||
@@ -561,6 +561,216 @@ class TestPortainerClientWrapperMethods:
|
|||||||
assert result is None
|
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:
|
class TestPortainerClientSingleton:
|
||||||
"""Test singleton pattern."""
|
"""Test singleton pattern."""
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user