Compare commits

...
11 Commits
Author SHA1 Message Date
jpmschweitzerandClaude Opus 4.5 68909e6576 feat: add development server startup script
Build and Push / build (release) Successful in 29s
Add wakeup.sh for local development with:
- Port conflict detection
- Virtual environment activation
- Log file management
- Colored output

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 11:15:17 +01:00
jpmschweitzerandClaude Opus 4.5 435a3faeed chore: clean up .gitignore
Remove duplicate credentials.py entry

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 11:15:01 +01:00
jpmschweitzerandClaude Opus 4.5 06cb4c17c8 chore: bump version to 1.3.1
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 11:14:27 +01:00
jpmschweitzerandClaude Opus 4.5 ffa075528b test: update tests for Portainer client without socket fallback
- Remove TestPortainerClientDockerSocketFallback class
- Update wrapper method tests to expect RuntimeError on missing endpoints
- Remove all socket fallback related test cases

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 11:14:04 +01:00
jpmschweitzerandClaude Opus 4.5 dbbb92d292 refactor: remove Docker socket fallback from Portainer client
- Remove _list_containers_via_socket method
- Remove _inspect_container_via_socket method
- Simplify list_containers and inspect_container to use Portainer API only
- Raise RuntimeError when no Portainer endpoints available

BREAKING: Portainer API configuration is now required for all container
and stack operations. Set PORTAINER_URL and PORTAINER_API_KEY env vars.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 11:13:46 +01:00
jpmschweitzerandClaude Opus 4.5 3ecfb91cc2 refactor: simplify configuration to use only environment variables
- Remove credentials.py import, use pydantic-settings .env support
- Remove unused search API keys (Brave, Google)
- Update default search provider to SearXNG
- Add extra="ignore" to allow flexible env var usage
- Update .env.example with organized sections

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 11:13:26 +01:00
jpmschweitzerandClaude Opus 4.5 167a3a43a8 feat: add stack management and container delete endpoints
Build and Push / build (release) Successful in 29s
- Add stack compose YAML get/update endpoints
- Add stack environment variable get/update endpoints
- Add stack deploy and rebuild endpoints
- Add container delete endpoint with force option
- Add Portainer client methods for new operations
- Add comprehensive test coverage (30 new tests)
- Remove REQUESTED_SERVICES.md (now implemented)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 10:19:50 +01:00
jpmschweitzerandClaude Opus 4.5 b5bdff3d54 Bump version to 1.2.1
Build and Push / build (release) Successful in 28s
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 18:51:00 +01:00
jpmschweitzerandClaude Opus 4.5 5c4ac70450 Add AGENTS.md with project guidelines
Documents coding standards, git discipline, release flow,
and FastAPI architecture best practices.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 18:46:15 +01:00
jpmschweitzerandClaude Opus 4.5 ad1b55009a Remove obsolete web scraper config from .env.example
Remove APP_VERSION (now from pyproject.toml) and web scraper settings.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 18:46:02 +01:00
jpmschweitzerandClaude Opus 4.5 fd8aee3227 Fix device control response returning stale state
Add 300ms delay after executing device action before fetching new state,
allowing Home Assistant time to update the entity state.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 18:40:19 +01:00
14 changed files with 1365 additions and 747 deletions
+46 -12
View File
@@ -1,23 +1,57 @@
# Core Code API Configuration # Core Code API Configuration
# Copy to .env and fill in real values
# Application settings # =============================================================================
# Application
# =============================================================================
APP_NAME="Core Code API" APP_NAME="Core Code API"
APP_VERSION="1.0.0"
DEBUG=false DEBUG=false
LOG_LEVEL=INFO
# Server settings # =============================================================================
# Server
# =============================================================================
HOST=0.0.0.0 HOST=0.0.0.0
PORT=8083 PORT=8083
# CORS settings (default allows all origins for internal use) # CORS (default allows all origins for internal use)
# CORS_ORIGINS=["http://192.168.86.149:82"] # CORS_ORIGINS=["http://192.168.86.149:82"]
# Logging # =============================================================================
LOG_LEVEL=INFO # Infrastructure Services
# =============================================================================
# Web Scraper Module # Portainer API (required for container/stack management)
WEB_SCRAPER_REQUEST_TIMEOUT=30 PORTAINER_URL=http://portainer:9000
WEB_SCRAPER_MAX_REDIRECTS=5 PORTAINER_API_KEY=ptr_your-api-key-here
WEB_SCRAPER_USER_AGENT="Mozilla/5.0 (compatible; CoreCode/1.0)"
WEB_SCRAPER_DEFAULT_MAX_LENGTH=10000 # Nginx Proxy Manager API
WEB_SCRAPER_MAX_LINKS_TO_EXTRACT=50 NPM_URL=http://npm:81
NPM_EMAIL=admin@example.com
NPM_PASSWORD=your-npm-password
# =============================================================================
# Home Automation
# =============================================================================
# Home Assistant API
HOMEASSISTANT_URL=http://homeassistant:8123
HOMEASSISTANT_TOKEN=your-long-lived-access-token
# =============================================================================
# AI Services
# =============================================================================
# Ollama API
OLLAMA_BASE_URL=http://ollama:11434
# SearXNG (self-hosted search)
SEARXNG_URL=http://searxng:8080
# =============================================================================
# Vector Database
# =============================================================================
# Qdrant
QDRANT_HOST=qdrant
QDRANT_PORT=6333
-1
View File
@@ -105,7 +105,6 @@ data/
# Credentials and secrets # Credentials and secrets
src/credentials.py src/credentials.py
credentials.py
*.pem *.pem
*.key *.key
secrets/ secrets/
+72
View File
@@ -0,0 +1,72 @@
# AGENTS.md
> **Start every session by reading this file.**
> This file outlines the operational protocols, coding standards, and architectural decisions for this FastAPI project.
## 1. Agent Operational Protocols
### 🧠 Work Patterns (Plan-Act-Reflect)
* **Plan:** Before writing code, briefly outline your plan. Identify which files you will touch and what the side effects might be.
* **Act:** Execute the changes in small, atomic steps.
* **Reflect:** After coding, verify your work. Did you break existing tests? Did you add new tests?
### 🛡️ Git Discipline
* **NEVER commit to `main` or `master` directly.** Always create a feature branch: `feature/your-feature-name` or `fix/issue-description`.
* **Commit Messages:** Use the [Conventional Commits](https://www.conventionalcommits.org/) format.
* `feat: add user login endpoint`
* `fix: resolve database connection timeout`
* `refactor: split monolith dependency file`
* **Atomic Commits:** Keep commits small. One logical change = one commit.
### 📝 Changelog Maintenance
* **Update `CHANGELOG.md`** with every user-facing change.
* Format: `## [Unreleased] - YYYY-MM-DD` followed by `### Added`, `### Changed`, or `### Fixed`.
### 🚀 Release Flow
When changes are ready for deployment:
1. **Ask user if deploy cycle is desired **
2. **Update version** in `pyproject.toml`:
- Bug fixes: bump patch version (1.8.3 → 1.8.4)
- New features: bump minor version (1.8.4 → 1.9.0)
3. **Update CHANGELOG.md**:
- Move items from `[Unreleased]` to new version section
- Add release date: `## [1.8.4] - 2025-12-16`
4. **Commit and tag**:
```bash
git add -A
git commit -m "fix: description of changes"
git tag v1.8.4
git push origin main --tags
```
5. **CI/CD triggers automatically**:
- Gitea CI builds Docker image on new tag
- Watchtower pulls and deploys to production
- Verify deployment: `curl http://192.168.86.149:8000/health`
---
## 2. FastAPI Architecture & Best Practices
*Reference: [FastAPI Best Practices](https://github.com/zhanymkanov/fastapi-best-practices)*
### 📂 Project Structure (Directory-based, NOT File-type based)
Do **not** group files by type (e.g., one huge `routers` folder). Group by **domain/module** inside a `src/` directory.
**Correct Structure:**
```text
src/
├── auth/
│ ├── router.py # Endpoints
│ ├── schemas.py # Pydantic models
│ ├── service.py # Business logic (CRUD, etc.)
│ ├── dependencies.py# Module-specific dependencies
│ └── config.py # Module-specific settings
├── posts/
│ ├── router.py
│ └── ...
└── main.py # App entry point
+46
View File
@@ -5,6 +5,52 @@ 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.1] - 2025-12-31
### Changed
- Simplified configuration: all settings now read from environment variables/.env only
- Removed Docker socket fallback for container operations (Portainer API is now required)
- Updated default search provider to SearXNG
### Removed
- `src/credentials.py` - credentials now managed via environment variables
- Docker socket fallback methods from Portainer client
- Unused search API settings (Brave, Google)
## [1.3.0] - 2025-12-31
### Added
- **Stack Management Endpoints** for Tatlock Control Room integration
- `GET /infrastructure/stacks/{stackId}/compose` - Get stack Docker Compose YAML
- `PUT /infrastructure/stacks/{stackId}/compose` - Update stack Docker Compose YAML
- `GET /infrastructure/stacks/{stackId}/env` - Get stack environment variables
- `PUT /infrastructure/stacks/{stackId}/env` - Update stack environment variables
- `POST /infrastructure/stacks/{stackId}/deploy` - Redeploy stack
- `POST /infrastructure/stacks/{stackId}/rebuild` - Pull images and recreate containers
- `DELETE /infrastructure/containers/{id}` - Delete container with optional force flag
- Portainer client methods: `get_stack_file`, `redeploy_stack`, `update_stack_env`, `delete_container`, `restart_container`
### Removed
- REQUESTED_SERVICES.md - endpoint specifications now implemented
## [1.2.1] - 2025-12-17
### Fixed
- Device control endpoint now returns correct new state after action (added 300ms delay for HA state propagation)
### Added
- AGENTS.md with project coding guidelines and release flow documentation
### Changed
- Removed obsolete web scraper settings from .env.example
## [1.2.0] - 2025-12-17 ## [1.2.0] - 2025-12-17
### Added ### Added
-446
View File
@@ -1,446 +0,0 @@
# Requested Core-API Services for Core-AI Infrastructure Tools
This document specifies the API endpoints needed by core-ai infrastructure tools. All requests from core-ai should go through core-api for centralized logging and access control.
## Context
The core-ai service is implementing 9 infrastructure tools in 3 logical clusters:
1. **Container Lifecycle** (4 tools) - containers.py
2. **Service Management** (3 tools) - services.py
3. **Monitoring & Resources** (2 tools) - monitoring.py
These tools need corresponding core-api REST endpoints to perform operations via Portainer.
---
## Cluster 1: Container Lifecycle Management
### 1.1 List Containers
**Endpoint:** `GET /v1/infrastructure/containers`
**Query Parameters:**
- `status` (optional): Filter by status - "all", "running", "stopped", "paused" (default: "running")
**Response:**
```json
[
{
"Id": "abc123...",
"Names": ["/nginx"],
"State": "running",
"Status": "Up 3 days",
"Image": "nginx:latest",
"Ports": [
{"PrivatePort": 80, "PublicPort": 8080, "Type": "tcp"},
{"PrivatePort": 443, "PublicPort": 8443, "Type": "tcp"}
],
"StartedAt": "2024-12-01T10:00:00Z"
}
]
```
**Implementation Notes:**
- Use `PortainerClient.list_containers(all_containers=True)` with Docker socket fallback
- Filter results based on `status` query parameter
- Return standard Docker API container list format
---
### 1.2 Manage Container
**Endpoint:** `POST /v1/infrastructure/containers/{container}/{action}`
**Path Parameters:**
- `container`: Container name or ID (e.g., "nginx", "core-ai")
- `action`: One of: "start", "stop", "restart", "pause", "unpause", "remove"
**Response (Success):**
```json
{
"success": true,
"action": "restart",
"container": "nginx",
"message": "Container restarted successfully"
}
```
**Response (Error):**
```json
{
"success": false,
"error": "Container not found",
"message": "Container 'nginx2' not found. Available containers: nginx, core-ai, ollama"
}
```
**Status Codes:**
- `200` - Success
- `304` - Not Modified (already in target state)
- `404` - Container not found
- `409` - Conflict (e.g., cannot remove running container)
- `500` - Server error
**Implementation Notes:**
- For "restart": call stop then start
- For actions not yet in PortainerClient (pause, unpause, remove):
- Call Portainer API directly: `/api/endpoints/{endpoint_id}/docker/containers/{container_id}/{action}`
- Handle partial name matching (case-insensitive)
- Return helpful error messages suggesting `docker_list_containers()` when not found
---
### 1.3 Inspect Container
**Endpoint:** `GET /v1/infrastructure/containers/{container}`
**Path Parameters:**
- `container`: Container name or ID
**Query Parameters:**
- `details` (optional): Level of detail - "summary" (default), "full", "resources"
**Response:**
```json
{
"Id": "abc123...",
"Name": "/nginx",
"State": {
"Status": "running",
"Running": true,
"StartedAt": "2024-12-01T10:00:00Z",
"FinishedAt": "0001-01-01T00:00:00Z",
"ExitCode": 0
},
"Config": {
"Image": "nginx:latest",
"Env": ["PATH=/usr/local/sbin:...", "NGINX_VERSION=1.25.0"],
"Cmd": ["nginx", "-g", "daemon off;"]
},
"NetworkSettings": {
"Ports": {
"80/tcp": [{"HostIp": "0.0.0.0", "HostPort": "8080"}],
"443/tcp": [{"HostIp": "0.0.0.0", "HostPort": "8443"}]
},
"Networks": {
"bridge": {
"IPAddress": "172.17.0.2",
"Gateway": "172.17.0.1"
}
}
},
"HostConfig": {
"Memory": 536870912,
"NanoCpus": 1000000000,
"RestartPolicy": {"Name": "unless-stopped"}
},
"Mounts": [
{
"Type": "bind",
"Source": "/host/path",
"Destination": "/container/path"
}
]
}
```
**Implementation Notes:**
- Use `PortainerClient.inspect_container(container)` which auto-detects endpoint and falls back to Docker socket
- Return full Docker inspect response
- The core-ai tool will handle formatting based on `details` level
- Return 404 if container not found
---
### 1.4 Container Logs
**Endpoint:** `GET /v1/infrastructure/containers/{container}/logs`
**Path Parameters:**
- `container`: Container name or ID
**Query Parameters:**
- `lines` (optional): Number of log lines (default: 50, max: 500)
- `since` (optional): Time filter - "1h", "30m", or ISO timestamp
**Response:**
```json
{
"container": "nginx",
"lines_requested": 50,
"since": null,
"logs": "2024-12-04T10:00:00.123Z Starting nginx...\n2024-12-04T10:00:01.456Z Ready to accept connections\n..."
}
```
**Implementation Notes:**
- Access Docker API directly: `GET /v1.41/containers/{container}/logs`
- Use Docker socket transport (httpx with uds)
- Parameters: `stdout=true`, `stderr=true`, `tail={lines}`, `timestamps=true`
- If `since` provided: add `since={unix_timestamp}` parameter
- Strip Docker stream headers (8-byte binary prefix per line)
- Return plain text logs with timestamps
- Return 404 if container not found
---
## Cluster 2: Service Management
### 2.1 List Services
**Endpoint:** `GET /v1/infrastructure/services` (already exists, may need enhancement)
**Query Parameters:**
- `stack` (optional): Filter by stack name
**Response:**
```json
[
{
"name": "portainer",
"stack_id": 1,
"status": "active",
"containers_running": 3,
"containers_total": 3,
"ports": [9000, 8000],
"domains": ["portainer.example.com"]
}
]
```
**Implementation Notes:**
- Enhance existing `/infrastructure/services` endpoint if needed
- Ensure it returns stack/service information from Portainer
- Include container counts (running/total)
---
### 2.2 Manage Service
**Endpoint:** `POST /v1/infrastructure/services/{service}/{action}`
**Path Parameters:**
- `service`: Service/stack name
- `action`: One of: "start", "stop", "restart", "scale"
**Request Body (for scale action):**
```json
{
"replicas": 3
}
```
**Response:**
```json
{
"success": true,
"action": "restart",
"service": "web",
"message": "Service restarted successfully"
}
```
**Implementation Notes:**
- For "start"/"stop": Use Portainer stack start/stop API
- For "restart": Stop then start the stack
- For "scale": Update stack with new replica count
- This may require updating stack compose file
---
### 2.3 Service Status
**Endpoint:** `GET /v1/infrastructure/services/{service}/status`
**Path Parameters:**
- `service`: Service/stack name
**Response:**
```json
{
"name": "web",
"status": "active",
"stack_id": 5,
"containers": [
{
"name": "web_app_1",
"status": "running",
"health": "healthy",
"uptime": "2 days"
}
],
"replica_status": "3/3 running",
"resources": {
"memory_total": "1.2 GB",
"cpu_usage": "15%"
},
"recent_events": [
{"time": "2024-12-04T09:00:00Z", "action": "container_start", "container": "web_app_3"}
]
}
```
**Implementation Notes:**
- Get stack details from Portainer
- Get individual container statuses
- Calculate aggregate resource usage
- May require querying Docker events API for recent events
---
## Cluster 3: Monitoring & Resources
### 3.1 System Resources
**Endpoint:** `GET /v1/infrastructure/resources/system`
**Response:**
```json
{
"cpu": {
"cores": 8,
"usage_percent": 45.2,
"load_average": [2.5, 2.3, 2.1]
},
"memory": {
"total_bytes": 16777216000,
"used_bytes": 8388608000,
"available_bytes": 8388608000,
"usage_percent": 50.0
},
"disk": {
"total_bytes": 500000000000,
"used_bytes": 250000000000,
"available_bytes": 250000000000,
"usage_percent": 50.0
},
"network": {
"interfaces": {
"eth0": {
"rx_bytes": 1000000000,
"tx_bytes": 500000000
}
}
}
}
```
**Implementation Notes:**
- Use Docker system info API: `GET /v1.41/system/df`
- May also use `GET /v1.41/info` for system-wide stats
- Calculate percentages and format nicely
- Include load averages from system stats
---
### 3.2 Container Resources
**Endpoint:** `GET /v1/infrastructure/resources/containers`
**Query Parameters:**
- `container` (optional): Specific container name/ID (if omitted, return all)
**Response:**
```json
[
{
"name": "nginx",
"cpu_percent": 5.2,
"memory_usage_bytes": 45000000,
"memory_limit_bytes": 100000000,
"memory_percent": 45.0,
"network_rx_bytes": 50000000,
"network_tx_bytes": 25000000,
"block_read_bytes": 10000000,
"block_write_bytes": 5000000
}
]
```
**Implementation Notes:**
- Use Docker stats API: `GET /v1.41/containers/{id}/stats?stream=false`
- If `container` param provided: return single container stats
- If omitted: return stats for all running containers
- Calculate percentages where applicable
- Stats API returns real-time metrics (one-time snapshot, not streaming)
---
## Implementation Priority
**Phase 1 (Needed immediately for core-ai):**
1. `GET /v1/infrastructure/containers` - List containers
2. `POST /v1/infrastructure/containers/{container}/{action}` - Manage containers
3. `GET /v1/infrastructure/containers/{container}` - Inspect container
4. `GET /v1/infrastructure/containers/{container}/logs` - Container logs
**Phase 2 (Needed for full infrastructure tools):**
5. `POST /v1/infrastructure/services/{service}/{action}` - Manage services
6. `GET /v1/infrastructure/services/{service}/status` - Service status
7. `GET /v1/infrastructure/resources/system` - System resources
8. `GET /v1/infrastructure/resources/containers` - Container resources
---
## Security & Access Control
All endpoints should:
- Log all requests (especially write operations)
- Support OIDC authentication when enabled
- Require admin privileges for destructive operations (remove, scale)
- Rate limit to prevent abuse
- Validate input parameters
- Return sanitized errors (no sensitive data in error messages)
---
## Error Handling
Standard error response format:
```json
{
"error": "ContainerNotFound",
"message": "Container 'nginx2' not found",
"details": {
"container": "nginx2",
"available_containers": ["nginx", "core-ai", "ollama"]
}
}
```
Common error codes:
- `400` - Bad Request (invalid parameters)
- `404` - Not Found (container/service doesn't exist)
- `409` - Conflict (invalid state transition)
- `500` - Internal Server Error (Portainer/Docker API failed)
- `503` - Service Unavailable (Portainer/Docker not accessible)
---
## Testing
Each endpoint should have:
- Unit tests (mock Portainer client)
- Integration tests (real Portainer/Docker)
- Error case tests (not found, permission denied, etc.)
- Performance tests (ensure response times < 2s)
---
## Questions / Decisions Needed
1. **Authentication**: Should container management require admin role, or allow read-only for all users?
2. **Rate Limiting**: What limits should be applied to prevent abuse?
3. **Caching**: Should container lists be cached? (TTL: 5s?)
4. **Async**: Should heavy operations (like logs) be async with job IDs?
5. **Webhooks**: Should operations emit events for monitoring?
---
## Notes
- All endpoints follow RESTful conventions
- Use existing PortainerClient methods where available
- Fall back to Docker socket when Portainer doesn't have data
- Log all operations with timestamps, user, and outcome
- Consider adding `/v1/infrastructure/containers/search` for fuzzy name matching
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "core-api" name = "core-api"
version = "1.2.0" version = "1.3.1"
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"
+168 -112
View File
@@ -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
@@ -292,70 +438,14 @@ class PortainerClient:
return True return True
# ======================================================================== # ========================================================================
# Docker Socket Fallback (for containers not managed by Portainer) # Helper methods for agent tools (auto-detect endpoint)
# ========================================================================
async def _list_containers_via_socket(self, all_containers: bool = True) -> List[Dict[str, Any]]:
"""
Fallback: List containers directly via Docker socket
Used when Portainer API doesn't return complete data (e.g., containers
started outside Portainer, AMP game servers, etc.)
Args:
all_containers: Include stopped containers
Returns:
List of container details in Docker API format
"""
try:
# Docker socket is mounted at /var/run/docker.sock
# Use httpx with unix socket transport
transport = httpx.AsyncHTTPTransport(uds="/var/run/docker.sock")
async with httpx.AsyncClient(transport=transport, timeout=10) as client:
params = {"all": 1 if all_containers else 0}
response = await client.get(
"http://localhost/v1.41/containers/json",
params=params
)
response.raise_for_status()
return response.json()
except Exception as e:
logger.warning(f"Docker socket fallback failed: {e}")
return []
async def _inspect_container_via_socket(self, container_id_or_name: str) -> Optional[Dict[str, Any]]:
"""
Fallback: Inspect container directly via Docker socket
Args:
container_id_or_name: Container ID or name
Returns:
Container details or None
"""
try:
transport = httpx.AsyncHTTPTransport(uds="/var/run/docker.sock")
async with httpx.AsyncClient(transport=transport, timeout=10) as client:
response = await client.get(
f"http://localhost/v1.41/containers/{container_id_or_name}/json"
)
response.raise_for_status()
return response.json()
except Exception as e:
logger.warning(f"Docker socket inspect fallback failed for '{container_id_or_name}': {e}")
return None
# ========================================================================
# Helper methods for agent tools (auto-detect endpoint + fallback)
# ======================================================================== # ========================================================================
async def list_containers(self, all_containers: bool = True) -> List[Dict[str, Any]]: async def list_containers(self, all_containers: bool = True) -> List[Dict[str, Any]]:
""" """
List containers using auto-detected endpoint with Docker socket fallback List containers using auto-detected endpoint
This is a convenience wrapper that automatically uses the first/default endpoint. This is a convenience wrapper that automatically uses the first/default endpoint.
If Portainer doesn't have complete data, falls back to Docker socket.
Args: Args:
all_containers: Include stopped containers (default: True) all_containers: Include stopped containers (default: True)
@@ -363,34 +453,18 @@ class PortainerClient:
Returns: Returns:
List of container details List of container details
""" """
try: endpoints = await self.get_endpoints()
# Try Portainer first if not endpoints:
endpoints = await self.get_endpoints() raise RuntimeError("No Portainer endpoints available")
if endpoints:
endpoint_id = endpoints[0]["Id"]
containers = await self.get_containers(endpoint_id, all_containers)
if containers:
return containers
# Fallback to Docker socket endpoint_id = endpoints[0]["Id"]
logger.info("Portainer returned no containers, trying Docker socket fallback...") return await self.get_containers(endpoint_id, all_containers)
return await self._list_containers_via_socket(all_containers)
except Exception as e:
logger.error(f"Error listing containers: {e}")
# Try fallback even on exception
try:
return await self._list_containers_via_socket(all_containers)
except Exception as fallback_error:
logger.error(f"Fallback also failed: {fallback_error}")
return []
async def inspect_container(self, container_name: str) -> Optional[Dict[str, Any]]: async def inspect_container(self, container_name: str) -> Optional[Dict[str, Any]]:
""" """
Inspect a container by name using auto-detected endpoint with Docker socket fallback Inspect a container by name using auto-detected endpoint
This is a convenience wrapper that automatically uses the first/default endpoint. This is a convenience wrapper that automatically uses the first/default endpoint.
If Portainer doesn't find the container, falls back to Docker socket.
Args: Args:
container_name: Container name (e.g., "jellyfin", "ollama") container_name: Container name (e.g., "jellyfin", "ollama")
@@ -398,44 +472,26 @@ class PortainerClient:
Returns: Returns:
Container details or None if not found Container details or None if not found
""" """
try: endpoints = await self.get_endpoints()
# Try Portainer first if not endpoints:
endpoints = await self.get_endpoints() raise RuntimeError("No Portainer endpoints available")
if endpoints:
endpoint_id = endpoints[0]["Id"]
# First list all containers to find the one matching the name endpoint_id = endpoints[0]["Id"]
all_containers = await self.get_containers(endpoint_id, all_containers=True)
matching_container = None # List all containers to find the one matching the name
for container in all_containers: all_containers = await self.get_containers(endpoint_id, all_containers=True)
# Container names come as array like ['/jellyfin']
names = container.get('Names', [])
for name in names:
clean_name = name.lstrip('/')
if clean_name == container_name or clean_name.lower() == container_name.lower():
matching_container = container
break
if matching_container:
break
if matching_container: for container in all_containers:
# Container names come as array like ['/jellyfin']
names = container.get('Names', [])
for name in names:
clean_name = name.lstrip('/')
if clean_name == container_name or clean_name.lower() == container_name.lower():
# Get detailed info using container ID # Get detailed info using container ID
container_id = matching_container['Id'] container_id = container['Id']
return await self.get_container(endpoint_id, container_id) return await self.get_container(endpoint_id, container_id)
# Not found in Portainer, try Docker socket fallback return None
logger.info(f"Container '{container_name}' not found in Portainer, trying Docker socket fallback...")
return await self._inspect_container_via_socket(container_name)
except Exception as e:
logger.error(f"Error inspecting container '{container_name}': {e}")
# Try fallback even on exception
try:
return await self._inspect_container_via_socket(container_name)
except Exception as fallback_error:
logger.error(f"Fallback also failed: {fallback_error}")
return None
# Singleton instance # Singleton instance
+15 -38
View File
@@ -1,5 +1,8 @@
""" """
Global configuration for Core Code API Global configuration for Core Code API
All configuration is loaded from environment variables or .env file.
See .env.example for available settings.
""" """
import tomllib import tomllib
from pathlib import Path from pathlib import Path
@@ -20,29 +23,6 @@ def _get_version_from_pyproject() -> str:
__version__ = _get_version_from_pyproject() __version__ = _get_version_from_pyproject()
# Import infrastructure credentials from gitignored module
try:
from src.credentials import (
PORTAINER_URL, PORTAINER_API_KEY,
NPM_URL, NPM_EMAIL, NPM_PASSWORD,
BRAVE_SEARCH_API_KEY,
GOOGLE_SEARCH_API_KEY, GOOGLE_SEARCH_ENGINE_ID,
HOMEASSISTANT_URL, HOMEASSISTANT_TOKEN
)
except ImportError:
# Fallback to empty strings if credentials.py doesn't exist
# (e.g., fresh clone before credentials setup)
PORTAINER_URL = "http://localhost:8001"
PORTAINER_API_KEY = ""
NPM_URL = "http://localhost:81"
NPM_EMAIL = ""
NPM_PASSWORD = ""
BRAVE_SEARCH_API_KEY = ""
GOOGLE_SEARCH_API_KEY = ""
GOOGLE_SEARCH_ENGINE_ID = ""
HOMEASSISTANT_URL = "http://localhost:8123"
HOMEASSISTANT_TOKEN = ""
class Settings(BaseSettings): class Settings(BaseSettings):
"""Global application settings""" """Global application settings"""
@@ -109,25 +89,21 @@ class Settings(BaseSettings):
embedding_batch_size: int = 32 embedding_batch_size: int = 32
# Search Configuration # Search Configuration
search_provider: str = "google" # Options: google, brave, searxng, duckduckgo search_provider: str = "searxng"
searxng_url: str = "http://searxng:8080" # For future self-hosted SearxNG searxng_url: str = "http://searxng:8080"
# Search API Keys (from credentials.py) # Infrastructure Management (Portainer)
brave_search_api_key: str = BRAVE_SEARCH_API_KEY # https://brave.com/search/api/ portainer_url: str = "http://portainer:9000"
google_search_api_key: str = GOOGLE_SEARCH_API_KEY # https://console.cloud.google.com/ portainer_api_key: str = ""
google_search_engine_id: str = GOOGLE_SEARCH_ENGINE_ID # Custom Search Engine ID
# Infrastructure Management (from credentials.py) # Infrastructure Management (Nginx Proxy Manager)
portainer_url: str = PORTAINER_URL npm_url: str = "http://npm:81"
portainer_api_key: str = PORTAINER_API_KEY npm_email: str = ""
npm_password: str = ""
npm_url: str = NPM_URL
npm_email: str = NPM_EMAIL
npm_password: str = NPM_PASSWORD
# Home Assistant Configuration # Home Assistant Configuration
homeassistant_url: str = HOMEASSISTANT_URL homeassistant_url: str = "http://homeassistant:8123"
homeassistant_token: str = HOMEASSISTANT_TOKEN homeassistant_token: str = ""
homeassistant_timeout: int = 30 homeassistant_timeout: int = 30
# Core-AI Service (AI performance metrics) # Core-AI Service (AI performance metrics)
@@ -163,6 +139,7 @@ class Settings(BaseSettings):
class Config: class Config:
env_file = ".env" env_file = ".env"
case_sensitive = False case_sensitive = False
extra = "ignore" # Ignore extra env vars not defined in Settings
@lru_cache() @lru_cache()
@@ -4,6 +4,7 @@ Housekeeping Controller
Provides API endpoints for home automation via Home Assistant. Provides API endpoints for home automation via Home Assistant.
Designed for the Tatlock Housekeeper agent and other consumers. Designed for the Tatlock Housekeeper agent and other consumers.
""" """
import asyncio
from fastapi import APIRouter, HTTPException, Query, Depends from fastapi import APIRouter, HTTPException, Query, Depends
from typing import List, Dict, Any, Optional from typing import List, Dict, Any, Optional
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -413,6 +414,9 @@ class HousekeepingController(BaseController):
else: # toggle else: # toggle
await ha.toggle(entity_id) await ha.toggle(entity_id)
# Wait for HA to update state before fetching
await asyncio.sleep(0.3)
# Get new state # Get new state
new_state = await ha.get_state(entity_id) new_state = await ha.get_state(entity_id)
+353 -1
View File
@@ -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
-23
View File
@@ -1,23 +0,0 @@
"""
Infrastructure Credentials Template
INSTRUCTIONS:
1. Copy this file to credentials.py
2. Fill in your actual credentials
3. DO NOT commit credentials.py to version control (it's in .gitignore)
This file should be committed to the repository as a template.
"""
# Portainer Configuration
PORTAINER_URL = "http://localhost:8001"
PORTAINER_API_KEY = "ptr_your_api_token_here" # Create in Portainer UI: User menu → My account → Access tokens
# Nginx Proxy Manager Configuration
NPM_URL = "http://localhost:81"
NPM_EMAIL = "admin@example.com"
NPM_PASSWORD = "your_password_here"
# Home Assistant Configuration
HOMEASSISTANT_URL = "http://192.168.86.149:8123" # Or http://home-assistant:8123 in Docker
HOMEASSISTANT_TOKEN = "your_long_lived_access_token_here" # Create in HA: Profile → Long-Lived Access Tokens
+376
View File
@@ -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
+234 -113
View File
@@ -391,40 +391,12 @@ class TestPortainerClientContainers:
assert result is True assert result is True
class TestPortainerClientDockerSocketFallback:
"""Test Docker socket fallback methods."""
@pytest.mark.asyncio
async def test_list_containers_via_socket_returns_empty_on_error(self):
"""_list_containers_via_socket should return empty list on error."""
client = PortainerClient(base_url="http://portainer:9000", api_key="key")
with patch("httpx.AsyncHTTPTransport") as mock_transport:
mock_transport.side_effect = Exception("Socket not available")
result = await client._list_containers_via_socket()
assert result == []
@pytest.mark.asyncio
async def test_inspect_container_via_socket_returns_none_on_error(self):
"""_inspect_container_via_socket should return None on error."""
client = PortainerClient(base_url="http://portainer:9000", api_key="key")
with patch("httpx.AsyncHTTPTransport") as mock_transport:
mock_transport.side_effect = Exception("Socket not available")
result = await client._inspect_container_via_socket("container_name")
assert result is None
class TestPortainerClientWrapperMethods: class TestPortainerClientWrapperMethods:
"""Test convenience wrapper methods.""" """Test convenience wrapper methods."""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_list_containers_uses_portainer_first(self): async def test_list_containers_uses_portainer(self):
"""list_containers should try Portainer first.""" """list_containers should use Portainer API."""
client = PortainerClient(base_url="http://portainer:9000", api_key="key") client = PortainerClient(base_url="http://portainer:9000", api_key="key")
containers = [{"Id": "abc123", "Names": ["/test"]}] containers = [{"Id": "abc123", "Names": ["/test"]}]
@@ -442,57 +414,19 @@ class TestPortainerClientWrapperMethods:
mock_containers.assert_called_once() mock_containers.assert_called_once()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_list_containers_falls_back_to_socket(self): async def test_list_containers_raises_when_no_endpoints(self):
"""list_containers should fallback to Docker socket if Portainer returns empty.""" """list_containers should raise RuntimeError when no endpoints available."""
client = PortainerClient(base_url="http://portainer:9000", api_key="key") client = PortainerClient(base_url="http://portainer:9000", api_key="key")
with patch.object(client, "get_endpoints", new_callable=AsyncMock) as mock_endpoints: with patch.object(client, "get_endpoints", new_callable=AsyncMock) as mock_endpoints:
mock_endpoints.return_value = [{"Id": 1}] mock_endpoints.return_value = []
with patch.object(client, "get_containers", new_callable=AsyncMock) as mock_containers: with pytest.raises(RuntimeError, match="No Portainer endpoints available"):
mock_containers.return_value = [] await client.list_containers()
with patch.object(client, "_list_containers_via_socket", new_callable=AsyncMock) as mock_socket:
mock_socket.return_value = [{"Id": "from_socket"}]
result = await client.list_containers()
assert result == [{"Id": "from_socket"}]
mock_socket.assert_called_once()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_list_containers_handles_exception_with_fallback(self): async def test_inspect_container_uses_portainer(self):
"""list_containers should try fallback even on exception.""" """inspect_container should use Portainer API."""
client = PortainerClient(base_url="http://portainer:9000", api_key="key")
with patch.object(client, "get_endpoints", new_callable=AsyncMock) as mock_endpoints:
mock_endpoints.side_effect = Exception("API error")
with patch.object(client, "_list_containers_via_socket", new_callable=AsyncMock) as mock_socket:
mock_socket.return_value = [{"Id": "fallback"}]
result = await client.list_containers()
assert result == [{"Id": "fallback"}]
@pytest.mark.asyncio
async def test_list_containers_returns_empty_when_all_fails(self):
"""list_containers should return empty list when everything fails."""
client = PortainerClient(base_url="http://portainer:9000", api_key="key")
with patch.object(client, "get_endpoints", new_callable=AsyncMock) as mock_endpoints:
mock_endpoints.side_effect = Exception("API error")
with patch.object(client, "_list_containers_via_socket", new_callable=AsyncMock) as mock_socket:
mock_socket.side_effect = Exception("Socket error")
result = await client.list_containers()
assert result == []
@pytest.mark.asyncio
async def test_inspect_container_uses_portainer_first(self):
"""inspect_container should try Portainer first."""
client = PortainerClient(base_url="http://portainer:9000", api_key="key") client = PortainerClient(base_url="http://portainer:9000", api_key="key")
container_list = [{"Id": "abc123", "Names": ["/mycontainer"]}] container_list = [{"Id": "abc123", "Names": ["/mycontainer"]}]
@@ -512,54 +446,241 @@ class TestPortainerClientWrapperMethods:
assert result == container_detail assert result == container_detail
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_inspect_container_falls_back_to_socket(self): async def test_inspect_container_returns_none_when_not_found(self):
"""inspect_container should fallback if not found in Portainer.""" """inspect_container should return None if container not found."""
client = PortainerClient(base_url="http://portainer:9000", api_key="key") client = PortainerClient(base_url="http://portainer:9000", api_key="key")
with patch.object(client, "get_endpoints", new_callable=AsyncMock) as mock_endpoints: with patch.object(client, "get_endpoints", new_callable=AsyncMock) as mock_endpoints:
mock_endpoints.return_value = [{"Id": 1}] mock_endpoints.return_value = [{"Id": 1}]
with patch.object(client, "get_containers", new_callable=AsyncMock) as mock_list: with patch.object(client, "get_containers", new_callable=AsyncMock) as mock_list:
mock_list.return_value = [] # Container not found mock_list.return_value = [] # No containers
with patch.object(client, "_inspect_container_via_socket", new_callable=AsyncMock) as mock_socket: result = await client.inspect_container("missing_container")
mock_socket.return_value = {"Id": "from_socket"}
result = await client.inspect_container("missing_container")
assert result == {"Id": "from_socket"}
mock_socket.assert_called_once()
@pytest.mark.asyncio
async def test_inspect_container_handles_exception_with_fallback(self):
"""inspect_container should try fallback even on exception."""
client = PortainerClient(base_url="http://portainer:9000", api_key="key")
with patch.object(client, "get_endpoints", new_callable=AsyncMock) as mock_endpoints:
mock_endpoints.side_effect = Exception("API error")
with patch.object(client, "_inspect_container_via_socket", new_callable=AsyncMock) as mock_socket:
mock_socket.return_value = {"Id": "fallback"}
result = await client.inspect_container("container")
assert result == {"Id": "fallback"}
@pytest.mark.asyncio
async def test_inspect_container_returns_none_when_all_fails(self):
"""inspect_container should return None when everything fails."""
client = PortainerClient(base_url="http://portainer:9000", api_key="key")
with patch.object(client, "get_endpoints", new_callable=AsyncMock) as mock_endpoints:
mock_endpoints.side_effect = Exception("API error")
with patch.object(client, "_inspect_container_via_socket", new_callable=AsyncMock) as mock_socket:
mock_socket.side_effect = Exception("Socket error")
result = await client.inspect_container("container")
assert result is None assert result is None
@pytest.mark.asyncio
async def test_inspect_container_raises_when_no_endpoints(self):
"""inspect_container should raise RuntimeError when no endpoints available."""
client = PortainerClient(base_url="http://portainer:9000", api_key="key")
with patch.object(client, "get_endpoints", new_callable=AsyncMock) as mock_endpoints:
mock_endpoints.return_value = []
with pytest.raises(RuntimeError, match="No Portainer endpoints available"):
await client.inspect_container("container")
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."""
+50
View File
@@ -0,0 +1,50 @@
#!/bin/bash
# Core-API Server Startup Script
set -e
# Colors for output
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m' # No Color
echo -e "${GREEN}Starting Core-API server...${NC}"
# Check if port 8788 is already in use
if lsof -Pi :8788 -sTCP:LISTEN -t >/dev/null 2>&1 ; then
echo -e "${RED}Error: Port 8788 is already in use${NC}"
echo "Run: lsof -i :8788 to see what's using it"
echo "Or run: kill \$(lsof -t -i:8788) to stop it"
exit 1
fi
# Activate virtual environment if not already activated
if [ -z "$VIRTUAL_ENV" ]; then
if [ -d ".venv" ]; then
echo -e "${YELLOW}Activating virtual environment...${NC}"
source .venv/bin/activate
else
echo -e "${RED}Error: Virtual environment not found${NC}"
echo "Run: python -m venv .venv && source .venv/bin/activate && pip install -r requirements.txt"
exit 1
fi
fi
# Create logs directory if it doesn't exist
LOGS_DIR="logs"
mkdir -p "$LOGS_DIR"
# Clear/create log file
LOG_FILE="$LOGS_DIR/server.log"
> "$LOG_FILE"
echo -e "${YELLOW}Logs will be written to: ${LOG_FILE}${NC}"
# Start the server
echo -e "${GREEN}Starting uvicorn server on http://tower-of-joy:8788${NC}"
echo -e "${YELLOW}Press Ctrl+C to stop the server${NC}"
echo ""
uvicorn src.main:app --reload --host 0.0.0.0 --port 8788 2>&1 | tee "$LOG_FILE"