Compare commits

..
14 Commits
Author SHA1 Message Date
jpmschweitzerandClaude Opus 4.5 0d2926dc6b fix: remove hardcoded service URLs, require ENV config
Build and Push / build (release) Successful in 29s
- Remove hardcoded default URLs (portainer, npm, ollama, etc.)
- All external service URLs now required via ENV vars
- Remove obsolete ai_client and ai_controller (core-ai proxy)
- Clean up health_controller obsolete core-ai references
- App fails fast at startup if required config missing

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 11:59:46 +01:00
jpmschweitzerandClaude Opus 4.5 31370f053c fix: clean up root endpoint and disable redoc
Build and Push / build (release) Successful in 28s
- Simplify root endpoint to return minimal status info
- Remove obsolete endpoint listings (moved to /docs)
- Clean up verbose app description
- Disable redoc UI

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 11:46:26 +01:00
jpmschweitzerandClaude Opus 4.5 9370c70311 fix: correct Portainer default port to 8001
Port 9000 is Authentik, Portainer runs on 8001

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 11:29:18 +01:00
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
18 changed files with 1376 additions and 1285 deletions
+46 -12
View File
@@ -1,23 +1,57 @@
# Core Code API Configuration
# Copy to .env and fill in real values
# Application settings
# =============================================================================
# Application
# =============================================================================
APP_NAME="Core Code API"
APP_VERSION="1.0.0"
DEBUG=false
LOG_LEVEL=INFO
# Server settings
# =============================================================================
# Server
# =============================================================================
HOST=0.0.0.0
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"]
# Logging
LOG_LEVEL=INFO
# =============================================================================
# Infrastructure Services
# =============================================================================
# Web Scraper Module
WEB_SCRAPER_REQUEST_TIMEOUT=30
WEB_SCRAPER_MAX_REDIRECTS=5
WEB_SCRAPER_USER_AGENT="Mozilla/5.0 (compatible; CoreCode/1.0)"
WEB_SCRAPER_DEFAULT_MAX_LENGTH=10000
WEB_SCRAPER_MAX_LINKS_TO_EXTRACT=50
# Portainer API (required for container/stack management)
PORTAINER_URL=http://localhost:8001
PORTAINER_API_KEY=ptr_your-api-key-here
# Nginx Proxy Manager API
NPM_URL=http://localhost:81
NPM_EMAIL=admin@example.com
NPM_PASSWORD=your-npm-password
# =============================================================================
# Home Automation
# =============================================================================
# Home Assistant API
HOMEASSISTANT_URL=http://localhost:8123
HOMEASSISTANT_TOKEN=your-long-lived-access-token
# =============================================================================
# AI Services
# =============================================================================
# Ollama API
OLLAMA_BASE_URL=http://localhost:11434
# SearXNG (self-hosted search)
SEARXNG_URL=http://localhost:8080
# =============================================================================
# Vector Database
# =============================================================================
# Qdrant
QDRANT_HOST=qdrant
QDRANT_PORT=6333
-1
View File
@@ -105,7 +105,6 @@ data/
# Credentials and secrets
src/credentials.py
credentials.py
*.pem
*.key
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/),
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
### 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]
name = "core-api"
version = "1.2.0"
version = "1.3.3"
description = "Core Code API - Infrastructure management and tools API"
readme = "README.md"
requires-python = ">=3.12"
-197
View File
@@ -1,197 +0,0 @@
"""
Core-AI HTTP Client
Provides interface to Core-AI service for AI performance metrics.
"""
import httpx
from typing import Optional, Dict, List, Any
from src.logging_config import get_logger
from src.config import get_settings
logger = get_logger(__name__)
settings = get_settings()
class CoreAIClient:
"""
HTTP client for Core-AI service
Provides access to AI performance metrics, tool execution stats,
and memory system monitoring.
"""
def __init__(
self,
base_url: Optional[str] = None,
timeout: int = 10
):
"""
Initialize Core-AI client
Args:
base_url: Core-AI base URL (default from settings)
timeout: Request timeout in seconds
"""
self.base_url = (base_url or getattr(settings, 'core_ai_base_url', 'http://core-ai:8086')).rstrip("/")
self.timeout = timeout
self.client = httpx.AsyncClient(timeout=self.timeout)
async def close(self):
"""Close the HTTP client"""
await self.client.aclose()
async def health_check(self) -> bool:
"""
Check if Core-AI service is accessible
Returns:
True if accessible, False otherwise
"""
try:
response = await self.client.get(f"{self.base_url}/health")
return response.status_code == 200
except Exception as e:
logger.error(f"Core-AI health check failed: {e}")
return False
async def get_metrics(self) -> Dict[str, Any]:
"""
Get comprehensive AI performance metrics
Returns:
Dict with agent performance, tool execution, memory stats
Example:
{
"uptime_seconds": 3600,
"timestamp": "2025-12-03T20:00:00Z",
"agent": {
"total_requests": 100,
"avg_response_time_ms": 1250.5,
"p95_response_time_ms": 3200.0,
...
},
"tools": {
"total_calls": 250,
"success_rate": 0.98,
"top_tools": {...}
},
"memory": {
"tier1_hit_rate": 0.85,
...
},
...
}
"""
try:
response = await self.client.get(f"{self.base_url}/metrics")
response.raise_for_status()
return response.json()
except httpx.HTTPStatusError as e:
logger.error(f"Failed to get metrics: HTTP {e.response.status_code}")
raise
except Exception as e:
logger.error(f"Failed to get metrics: {e}")
raise
async def get_recent_errors(self, limit: int = 20) -> List[Dict[str, Any]]:
"""
Get recent request errors
Args:
limit: Maximum number of errors to return
Returns:
List of error records with timestamps
Example:
[
{
"timestamp": "2025-12-03T19:45:12Z",
"agent_type": "pydantic",
"error": "Connection timeout",
"duration_ms": 5000
},
...
]
"""
try:
response = await self.client.get(
f"{self.base_url}/metrics/errors",
params={"limit": limit}
)
response.raise_for_status()
data = response.json()
return data.get("errors", [])
except Exception as e:
logger.error(f"Failed to get recent errors: {e}")
raise
async def get_tool_failures(self, limit: int = 20) -> List[Dict[str, Any]]:
"""
Get recent tool execution failures
Args:
limit: Maximum number of failures to return
Returns:
List of tool failure records
Example:
[
{
"timestamp": "2025-12-03T19:50:30Z",
"tool_name": "list_containers",
"error": "Connection refused",
"duration_ms": 150
},
...
]
"""
try:
response = await self.client.get(
f"{self.base_url}/metrics/tool-failures",
params={"limit": limit}
)
response.raise_for_status()
data = response.json()
return data.get("failures", [])
except Exception as e:
logger.error(f"Failed to get tool failures: {e}")
raise
async def reset_metrics(self) -> bool:
"""
Reset all metrics (admin operation)
Returns:
True if successful
"""
try:
response = await self.client.post(f"{self.base_url}/metrics/reset")
response.raise_for_status()
logger.info("Successfully reset Core-AI metrics")
return True
except Exception as e:
logger.error(f"Failed to reset metrics: {e}")
raise
async def __aenter__(self):
"""Async context manager entry"""
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
"""Async context manager exit"""
await self.close()
# Singleton instance
_ai_client: Optional[CoreAIClient] = None
def get_ai_client() -> CoreAIClient:
"""Get singleton Core-AI client instance"""
global _ai_client
if _ai_client is None:
_ai_client = CoreAIClient()
return _ai_client
+168 -112
View File
@@ -210,6 +210,152 @@ class PortainerClient:
response.raise_for_status()
return True
async def get_stack_file(self, stack_id: int) -> str:
"""
Get the compose file content for a stack
Args:
stack_id: Stack identifier
Returns:
Docker Compose YAML content as string
"""
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.get(
f"{self.base_url}/api/stacks/{stack_id}/file",
headers=self._get_headers()
)
response.raise_for_status()
data = response.json()
return data.get("StackFileContent", "")
async def redeploy_stack(
self,
stack_id: int,
endpoint_id: int,
pull_image: bool = False
) -> Dict[str, Any]:
"""
Redeploy a stack with its current configuration
Args:
stack_id: Stack identifier
endpoint_id: Portainer endpoint
pull_image: Pull latest images before deployment
Returns:
Updated stack details
"""
# Get current stack file content
stack_content = await self.get_stack_file(stack_id)
# Get current stack to preserve env vars
stack = await self.get_stack(stack_id)
env_vars = stack.get("Env", [])
payload = {
"stackFileContent": stack_content,
"env": env_vars,
"prune": False,
"pullImage": pull_image
}
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.put(
f"{self.base_url}/api/stacks/{stack_id}",
headers=self._get_headers(),
params={"endpointId": endpoint_id},
json=payload
)
response.raise_for_status()
return response.json()
async def update_stack_env(
self,
stack_id: int,
endpoint_id: int,
env_vars: List[Dict[str, str]]
) -> Dict[str, Any]:
"""
Update stack environment variables
Args:
stack_id: Stack identifier
endpoint_id: Portainer endpoint
env_vars: List of {"name": "VAR_NAME", "value": "var_value"} dicts
Returns:
Updated stack details
"""
# Get current stack file content (required for update)
stack_content = await self.get_stack_file(stack_id)
payload = {
"stackFileContent": stack_content,
"env": env_vars,
"prune": False,
"pullImage": False
}
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.put(
f"{self.base_url}/api/stacks/{stack_id}",
headers=self._get_headers(),
params={"endpointId": endpoint_id},
json=payload
)
response.raise_for_status()
return response.json()
async def delete_container(
self,
endpoint_id: int,
container_id: str,
force: bool = False
) -> bool:
"""
Delete a container
Args:
endpoint_id: Portainer endpoint identifier
container_id: Container ID or name
force: Force remove running container
Returns:
True if successful
"""
params = {"force": "true" if force else "false"}
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.delete(
f"{self.base_url}/api/endpoints/{endpoint_id}/docker/containers/{container_id}",
headers=self._get_headers(),
params=params
)
response.raise_for_status()
logger.info(f"Deleted container {container_id}")
return True
async def restart_container(self, endpoint_id: int, container_id: str) -> bool:
"""
Restart a container
Args:
endpoint_id: Portainer endpoint identifier
container_id: Container ID or name
Returns:
True if successful
"""
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.post(
f"{self.base_url}/api/endpoints/{endpoint_id}/docker/containers/{container_id}/restart",
headers=self._get_headers()
)
response.raise_for_status()
logger.info(f"Restarted container {container_id}")
return True
async def get_containers(self, endpoint_id: int, all_containers: bool = True) -> List[Dict[str, Any]]:
"""
List containers on a specific endpoint
@@ -292,70 +438,14 @@ class PortainerClient:
return True
# ========================================================================
# Docker Socket Fallback (for containers not managed by Portainer)
# ========================================================================
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)
# Helper methods for agent tools (auto-detect endpoint)
# ========================================================================
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.
If Portainer doesn't have complete data, falls back to Docker socket.
Args:
all_containers: Include stopped containers (default: True)
@@ -363,34 +453,18 @@ class PortainerClient:
Returns:
List of container details
"""
try:
# Try Portainer first
endpoints = await self.get_endpoints()
if endpoints:
endpoint_id = endpoints[0]["Id"]
containers = await self.get_containers(endpoint_id, all_containers)
if containers:
return containers
endpoints = await self.get_endpoints()
if not endpoints:
raise RuntimeError("No Portainer endpoints available")
# Fallback to Docker socket
logger.info("Portainer returned no containers, trying Docker socket fallback...")
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 []
endpoint_id = endpoints[0]["Id"]
return await self.get_containers(endpoint_id, all_containers)
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.
If Portainer doesn't find the container, falls back to Docker socket.
Args:
container_name: Container name (e.g., "jellyfin", "ollama")
@@ -398,44 +472,26 @@ class PortainerClient:
Returns:
Container details or None if not found
"""
try:
# Try Portainer first
endpoints = await self.get_endpoints()
if endpoints:
endpoint_id = endpoints[0]["Id"]
endpoints = await self.get_endpoints()
if not endpoints:
raise RuntimeError("No Portainer endpoints available")
# First list all containers to find the one matching the name
all_containers = await self.get_containers(endpoint_id, all_containers=True)
endpoint_id = endpoints[0]["Id"]
matching_container = None
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():
matching_container = container
break
if matching_container:
break
# List all containers to find the one matching the name
all_containers = await self.get_containers(endpoint_id, all_containers=True)
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
container_id = matching_container['Id']
container_id = container['Id']
return await self.get_container(endpoint_id, container_id)
# Not found in Portainer, try Docker socket fallback
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
return None
# Singleton instance
+16 -42
View File
@@ -1,5 +1,8 @@
"""
Global configuration for Core Code API
All configuration is loaded from environment variables or .env file.
See .env.example for available settings.
"""
import tomllib
from pathlib import Path
@@ -20,29 +23,6 @@ def _get_version_from_pyproject() -> str:
__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):
"""Global application settings"""
@@ -66,7 +46,7 @@ class Settings(BaseSettings):
log_level: str = "DEBUG"
# Ollama Configuration (for AI orchestration)
ollama_base_url: str = "http://ollama:11434"
ollama_base_url: str # Required - set OLLAMA_BASE_URL in .env
ollama_timeout: int = 300 # 5 minutes
# Model Configuration
@@ -109,30 +89,23 @@ class Settings(BaseSettings):
embedding_batch_size: int = 32
# Search Configuration
search_provider: str = "google" # Options: google, brave, searxng, duckduckgo
searxng_url: str = "http://searxng:8080" # For future self-hosted SearxNG
search_provider: str = "searxng"
searxng_url: str # Required - set SEARXNG_URL in .env
# Search API Keys (from credentials.py)
brave_search_api_key: str = BRAVE_SEARCH_API_KEY # https://brave.com/search/api/
google_search_api_key: str = GOOGLE_SEARCH_API_KEY # https://console.cloud.google.com/
google_search_engine_id: str = GOOGLE_SEARCH_ENGINE_ID # Custom Search Engine ID
# Infrastructure Management (Portainer)
portainer_url: str # Required - set PORTAINER_URL in .env
portainer_api_key: str # Required - set PORTAINER_API_KEY in .env
# Infrastructure Management (from credentials.py)
portainer_url: str = PORTAINER_URL
portainer_api_key: str = PORTAINER_API_KEY
npm_url: str = NPM_URL
npm_email: str = NPM_EMAIL
npm_password: str = NPM_PASSWORD
# Infrastructure Management (Nginx Proxy Manager)
npm_url: str # Required - set NPM_URL in .env
npm_email: str # Required - set NPM_EMAIL in .env
npm_password: str # Required - set NPM_PASSWORD in .env
# Home Assistant Configuration
homeassistant_url: str = HOMEASSISTANT_URL
homeassistant_token: str = HOMEASSISTANT_TOKEN
homeassistant_url: str # Required - set HOMEASSISTANT_URL in .env
homeassistant_token: str # Required - set HOMEASSISTANT_TOKEN in .env
homeassistant_timeout: int = 30
# Core-AI Service (AI performance metrics)
core_ai_base_url: str = "http://core-ai:8086"
# OIDC Authentication (Authentik)
oidc_enabled: bool = False # Set to True to require authentication
oidc_issuer: str = "https://auth.schweitz.net/application/o/core-api/"
@@ -163,6 +136,7 @@ class Settings(BaseSettings):
class Config:
env_file = ".env"
case_sensitive = False
extra = "ignore" # Ignore extra env vars not defined in Settings
@lru_cache()
-219
View File
@@ -1,219 +0,0 @@
"""
AI Metrics Proxy Controller
Provides proxy endpoints to Core-AI service metrics.
Allows external access to AI performance stats via core-api.
"""
from fastapi import APIRouter, HTTPException
from typing import Dict, List, Any
from src.clients.ai_client import get_ai_client
from src.logging_config import get_logger
logger = get_logger(__name__)
# Create router
router = APIRouter(
prefix="/ai",
tags=["AI Metrics"]
)
@router.get(
"/health",
summary="Check Core-AI service health",
description="Verify that the Core-AI service is accessible and responding"
)
async def ai_health_check():
"""
Check if Core-AI service is healthy
Returns:
Health status and availability
"""
try:
ai_client = get_ai_client()
is_healthy = await ai_client.health_check()
return {
"service": "core-ai",
"status": "healthy" if is_healthy else "unhealthy",
"accessible": is_healthy
}
except Exception as e:
logger.error(f"AI health check failed: {e}")
return {
"service": "core-ai",
"status": "error",
"accessible": False,
"error": str(e)
}
@router.get(
"/metrics",
response_model=Dict[str, Any],
summary="Get comprehensive AI performance metrics",
description="Returns detailed metrics including agent performance, tool execution stats, memory system metrics, and user activity"
)
async def get_ai_metrics():
"""
Proxy endpoint for Core-AI metrics
Returns comprehensive AI performance data:
- Agent request statistics (total, by type, response times)
- Response time percentiles (p50, p95, p99)
- Tool execution metrics (calls, success rates, durations)
- Memory system statistics (cache hits, consolidations)
- User activity tracking
- Concurrency metrics
Returns:
Dict with all collected metrics
Raises:
HTTPException: If Core-AI is unreachable or returns error
"""
try:
ai_client = get_ai_client()
metrics = await ai_client.get_metrics()
return metrics
except Exception as e:
logger.error(f"Failed to fetch AI metrics: {e}")
raise HTTPException(
status_code=503,
detail=f"Core-AI service unavailable: {str(e)}"
)
@router.get(
"/metrics/errors",
response_model=Dict[str, Any],
summary="Get recent request errors",
description="Returns recent AI agent request errors with timestamps and details"
)
async def get_ai_errors(limit: int = 20):
"""
Get recent AI request errors
Args:
limit: Maximum number of errors to return (default: 20)
Returns:
Dict with error list and total count
Example response:
{
"errors": [
{
"timestamp": "2025-12-03T19:45:12Z",
"agent_type": "pydantic",
"error": "Connection timeout",
"duration_ms": 5000
}
],
"total": 1
}
"""
try:
ai_client = get_ai_client()
errors = await ai_client.get_recent_errors(limit=limit)
return {
"errors": errors,
"total": len(errors)
}
except Exception as e:
logger.error(f"Failed to fetch AI errors: {e}")
raise HTTPException(
status_code=503,
detail=f"Core-AI service unavailable: {str(e)}"
)
@router.get(
"/metrics/tool-failures",
response_model=Dict[str, Any],
summary="Get recent tool execution failures",
description="Returns recent tool execution failures with error details"
)
async def get_ai_tool_failures(limit: int = 20):
"""
Get recent tool execution failures
Args:
limit: Maximum number of failures to return (default: 20)
Returns:
Dict with failure list and total count
Example response:
{
"failures": [
{
"timestamp": "2025-12-03T19:50:30Z",
"tool_name": "list_containers",
"error": "Connection refused",
"duration_ms": 150
}
],
"total": 1
}
"""
try:
ai_client = get_ai_client()
failures = await ai_client.get_tool_failures(limit=limit)
return {
"failures": failures,
"total": len(failures)
}
except Exception as e:
logger.error(f"Failed to fetch tool failures: {e}")
raise HTTPException(
status_code=503,
detail=f"Core-AI service unavailable: {str(e)}"
)
@router.post(
"/metrics/reset",
summary="Reset all AI metrics (admin)",
description="Clear all collected metrics. This is an administrative operation that resets all counters and history."
)
async def reset_ai_metrics():
"""
Reset all AI metrics (admin operation)
Clears all collected metrics including:
- Request history
- Tool execution stats
- Memory system metrics
- Error logs
Returns:
Success confirmation
Note:
This is an administrative operation that should be used carefully.
All historical data will be lost.
"""
try:
ai_client = get_ai_client()
await ai_client.reset_metrics()
logger.info("AI metrics reset successfully")
return {
"success": True,
"message": "AI metrics reset successfully"
}
except Exception as e:
logger.error(f"Failed to reset AI metrics: {e}")
raise HTTPException(
status_code=503,
detail=f"Core-AI service unavailable: {str(e)}"
)
+3 -38
View File
@@ -11,9 +11,6 @@ from src.config import get_settings
from src.logging_config import get_logger
from src.models.ollama_client import get_ollama_client
# Note: Agent functionality moved to separate core-ai service (Dec 2025)
# This service (core-api) only provides infrastructure management and tools
AGENT_AVAILABLE = False
logger = get_logger(__name__)
@@ -52,20 +49,7 @@ class HealthController(BaseController):
"service": settings.app_name,
"version": settings.app_version,
"status": "healthy",
"documentation": {
"swagger_ui": "/docs",
"redoc": "/redoc",
"openapi_spec": "/openapi.json"
},
"endpoints": {
"chat_completions": "/v1/chat/completions",
"models": "/v1/models",
"conversations": "/v1/conversations",
"dns_lookup": "/tools/dns/lookup",
"infrastructure": "/infrastructure",
"health": "/health",
"health_full": "/health/full"
}
"docs": "/docs"
}
@router.get(
@@ -147,11 +131,6 @@ class HealthController(BaseController):
ollama_error = str(e)
logger.warning(f"Ollama health check failed: {ollama_error}")
# Note: Agent functionality moved to separate core-ai service
# This service only needs Ollama for embeddings (infrastructure tools)
# Agent health is checked separately in core-ai service
# Determine overall status (only Ollama required for core-api)
is_healthy = ollama_healthy
elapsed_ms = int((time.time() - start_time) * 1000)
@@ -170,8 +149,7 @@ class HealthController(BaseController):
"available": False
},
"error": ollama_error
},
"note": "AI agent functionality available in separate core-ai service (port 8086)"
}
}
}
@@ -217,20 +195,7 @@ class HealthController(BaseController):
"error": str(e)
}
# 2. Agent Stack - Moved to separate core-ai service
diagnostics["components"]["agent"] = {
"status": "N/A",
"note": "AI agent functionality moved to separate core-ai service (port 8086)",
"check_url": "http://core-ai:8086/health"
}
# 3. Memory System (Qdrant) - Moved to core-ai service
diagnostics["components"]["qdrant"] = {
"status": "N/A",
"note": "Memory system managed by core-ai service (port 8086)"
}
# 4. Configuration
# 2. Configuration
diagnostics["configuration"] = {
"agent_fallback_enabled": settings.agent_fallback_enabled,
"memory_tier1_max_turns": settings.memory_tier1_max_turns,
@@ -4,6 +4,7 @@ Housekeeping Controller
Provides API endpoints for home automation via Home Assistant.
Designed for the Tatlock Housekeeper agent and other consumers.
"""
import asyncio
from fastapi import APIRouter, HTTPException, Query, Depends
from typing import List, Dict, Any, Optional
from pydantic import BaseModel, Field
@@ -413,6 +414,9 @@ class HousekeepingController(BaseController):
else: # toggle
await ha.toggle(entity_id)
# Wait for HA to update state before fetching
await asyncio.sleep(0.3)
# Get new state
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,
including service deployment, configuration, and monitoring setup.
"""
from fastapi import APIRouter, HTTPException, Depends
from fastapi import APIRouter, HTTPException, Depends, Request
from fastapi.responses import PlainTextResponse
from typing import List, Dict, Any, Optional, Union
from pydantic import BaseModel, field_validator
@@ -1700,6 +1701,357 @@ class InfrastructureController(BaseController):
logger.error(f"Failed to get container resources: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
# ========================================================================
# Container Delete Endpoint (for Tatlock Control Room)
# ========================================================================
@router.delete(
"/containers/{container_id}",
status_code=204,
summary="Delete a container"
)
async def delete_container(
container_id: str,
force: bool = False
):
"""
Delete a Docker container.
Args:
container_id: Full container ID
force: Force remove running container (default: false)
Returns:
204 No Content on success
"""
portainer = get_portainer_client()
logger.info(f"Deleting container '{container_id}' (force={force})")
try:
# Get endpoint
endpoints = await portainer.get_endpoints()
if not endpoints:
raise HTTPException(status_code=500, detail="No Portainer endpoints available")
endpoint_id = endpoints[0]['Id']
# Delete the container
await portainer.delete_container(endpoint_id, container_id, force=force)
logger.info(f"Successfully deleted container '{container_id}'")
return None # 204 No Content
except HTTPException:
raise
except Exception as e:
error_str = str(e).lower()
if "404" in error_str or "no such container" in error_str:
raise HTTPException(status_code=404, detail=f"Container '{container_id}' not found")
elif "409" in error_str or "conflict" in error_str:
raise HTTPException(
status_code=409,
detail=f"Cannot delete container '{container_id}': container is running. Use force=true to force remove."
)
else:
logger.error(f"Failed to delete container '{container_id}': {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
# ========================================================================
# Stack Management Endpoints (for Tatlock Control Room)
# ========================================================================
@router.get(
"/stacks/{stack_id}/compose",
summary="Get stack compose YAML",
response_class=PlainTextResponse
)
async def get_stack_compose(stack_id: str):
"""
Get the Docker Compose YAML for a stack.
Args:
stack_id: Stack name (compose project name)
Returns:
Docker Compose YAML as text/yaml
"""
portainer = get_portainer_client()
logger.info(f"Getting compose file for stack '{stack_id}'")
try:
# Find stack by name
stacks = await portainer.get_stacks()
stack = next(
(s for s in stacks if s.get("Name", "").lower() == stack_id.lower()),
None
)
if not stack:
raise HTTPException(status_code=404, detail=f"Stack '{stack_id}' not found")
# Get compose file content
compose_content = await portainer.get_stack_file(stack["Id"])
return PlainTextResponse(
content=compose_content,
media_type="text/yaml"
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to get compose for stack '{stack_id}': {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.put(
"/stacks/{stack_id}/compose",
status_code=204,
summary="Update stack compose YAML"
)
async def update_stack_compose(stack_id: str, request: Request):
"""
Update the Docker Compose YAML for a stack.
Args:
stack_id: Stack name (compose project name)
request: Raw YAML content in request body
Returns:
204 No Content on success
"""
portainer = get_portainer_client()
logger.info(f"Updating compose file for stack '{stack_id}'")
try:
# Read raw YAML from request body
compose_content = (await request.body()).decode("utf-8")
if not compose_content.strip():
raise HTTPException(status_code=400, detail="Empty compose content")
# Find stack by name
stacks = await portainer.get_stacks()
stack = next(
(s for s in stacks if s.get("Name", "").lower() == stack_id.lower()),
None
)
if not stack:
raise HTTPException(status_code=404, detail=f"Stack '{stack_id}' not found")
stack_int_id = stack["Id"]
endpoint_id = stack.get("EndpointId")
# Update stack with new compose content
await portainer.update_stack(
stack_id=stack_int_id,
stack_file_content=compose_content,
endpoint_id=endpoint_id,
prune=False,
pull_image=False
)
logger.info(f"Successfully updated compose for stack '{stack_id}'")
return None # 204 No Content
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to update compose for stack '{stack_id}': {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get(
"/stacks/{stack_id}/env",
response_model=Dict[str, str],
summary="Get stack environment variables"
)
async def get_stack_env(stack_id: str):
"""
Get environment variables for a stack.
Args:
stack_id: Stack name (compose project name)
Returns:
Dictionary of environment variable name-value pairs
"""
portainer = get_portainer_client()
logger.info(f"Getting env vars for stack '{stack_id}'")
try:
# Find stack by name
stacks = await portainer.get_stacks()
stack = next(
(s for s in stacks if s.get("Name", "").lower() == stack_id.lower()),
None
)
if not stack:
raise HTTPException(status_code=404, detail=f"Stack '{stack_id}' not found")
# Get full stack details including env vars
stack_details = await portainer.get_stack(stack["Id"])
env_list = stack_details.get("Env", [])
# Convert from [{name, value}] to {name: value}
env_dict = {item["name"]: item["value"] for item in env_list}
return env_dict
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to get env for stack '{stack_id}': {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.put(
"/stacks/{stack_id}/env",
status_code=204,
summary="Update stack environment variables"
)
async def update_stack_env(stack_id: str, env_vars: Dict[str, str]):
"""
Update environment variables for a stack.
Args:
stack_id: Stack name (compose project name)
env_vars: Dictionary of environment variable name-value pairs
Returns:
204 No Content on success
"""
portainer = get_portainer_client()
logger.info(f"Updating env vars for stack '{stack_id}'")
try:
# Find stack by name
stacks = await portainer.get_stacks()
stack = next(
(s for s in stacks if s.get("Name", "").lower() == stack_id.lower()),
None
)
if not stack:
raise HTTPException(status_code=404, detail=f"Stack '{stack_id}' not found")
stack_int_id = stack["Id"]
endpoint_id = stack.get("EndpointId")
# Convert from {name: value} to [{name, value}]
env_list = [{"name": k, "value": v} for k, v in env_vars.items()]
# Update stack env vars
await portainer.update_stack_env(
stack_id=stack_int_id,
endpoint_id=endpoint_id,
env_vars=env_list
)
logger.info(f"Successfully updated env vars for stack '{stack_id}'")
return None # 204 No Content
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to update env for stack '{stack_id}': {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.post(
"/stacks/{stack_id}/deploy",
status_code=202,
summary="Deploy stack"
)
async def deploy_stack(stack_id: str):
"""
Redeploy a stack with current YAML and environment variables.
Args:
stack_id: Stack name (compose project name)
Returns:
202 Accepted
"""
portainer = get_portainer_client()
logger.info(f"Deploying stack '{stack_id}'")
try:
# Find stack by name
stacks = await portainer.get_stacks()
stack = next(
(s for s in stacks if s.get("Name", "").lower() == stack_id.lower()),
None
)
if not stack:
raise HTTPException(status_code=404, detail=f"Stack '{stack_id}' not found")
stack_int_id = stack["Id"]
endpoint_id = stack.get("EndpointId")
# Redeploy without pulling new images
await portainer.redeploy_stack(
stack_id=stack_int_id,
endpoint_id=endpoint_id,
pull_image=False
)
logger.info(f"Successfully deployed stack '{stack_id}'")
return None # 202 Accepted
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to deploy stack '{stack_id}': {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.post(
"/stacks/{stack_id}/rebuild",
status_code=202,
summary="Rebuild stack"
)
async def rebuild_stack(stack_id: str):
"""
Pull fresh images and recreate all containers in the stack.
Args:
stack_id: Stack name (compose project name)
Returns:
202 Accepted
"""
portainer = get_portainer_client()
logger.info(f"Rebuilding stack '{stack_id}'")
try:
# Find stack by name
stacks = await portainer.get_stacks()
stack = next(
(s for s in stacks if s.get("Name", "").lower() == stack_id.lower()),
None
)
if not stack:
raise HTTPException(status_code=404, detail=f"Stack '{stack_id}' not found")
stack_int_id = stack["Id"]
endpoint_id = stack.get("EndpointId")
# Redeploy with image pull
await portainer.redeploy_stack(
stack_id=stack_int_id,
endpoint_id=endpoint_id,
pull_image=True
)
logger.info(f"Successfully rebuilt stack '{stack_id}'")
return None # 202 Accepted
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to rebuild stack '{stack_id}': {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
return router
-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
+7 -80
View File
@@ -13,7 +13,6 @@ from src.controllers.infrastructure_controller import infrastructure_controller
from src.controllers.tools_controller import tools_controller
from src.controllers.health_controller import health_controller
from src.controllers.static_controller import static_controller
from src.controllers.ai_controller import router as ai_router
from src.controllers.housekeeping_controller import housekeeping_controller
from src.security import initialize_oidc
@@ -64,89 +63,18 @@ app = FastAPI(
title=settings.app_name,
version=settings.app_version,
description="""
Core Code API provides OpenAPI-compatible functions and AI orchestration for Open WebUI.
Core Code API - Infrastructure management and home automation API.
## Features
## Features
### OpenAI-Compatible API (v1)
- `/v1/chat/completions` - Chat completions with streaming support
- `/v1/models` - List available models
Compatible with OpenAI client libraries and Open WebUI.
- **Infrastructure Management** - Container and stack management via Portainer
- **Home Automation** - Device control via Home Assistant
- **Tools** - DNS lookup and utilities
### Conversation Memory (Phase 2)
- `/v1/conversations/{id}` - Get conversation history
- `/v1/conversations/{id}/search` - Semantic search within conversation
- `/v1/conversations/search` - Search across all conversations
- `/v1/conversations/{id}/stats` - Get conversation statistics
- `/v1/conversations/{id}/consolidate` - Manual consolidation
- `DELETE /v1/conversations/{id}` - Delete conversation
Multi-tier memory system:
- **Tier 1**: Fast in-memory buffer (last 10 turns)
- **Tier 2/3**: Unified Qdrant storage (persistent + semantic search)
### Infrastructure Management
**Read Endpoints:**
- `GET /infrastructure/health` - Check Portainer & NPM connectivity
- `GET /infrastructure/services` - List all deployed services
- `GET /infrastructure/services/{name}` - Get service details
- `GET /infrastructure/ports` - List allocated ports
- `GET /infrastructure/domains` - List configured domains
**Write Endpoints (Admin Only):**
- `POST /infrastructure/services` - Deploy new service from compose YAML
- `PUT /infrastructure/services/{name}` - Update existing service
- `DELETE /infrastructure/services/{name}` - Remove service and stack
- `POST /infrastructure/proxy` - Create proxy host with optional SSL
Automates infrastructure operations via Portainer and Nginx Proxy Manager APIs.
### Home Automation (Housekeeping)
**Read Endpoints:**
- `GET /housekeeping/health` - Check Home Assistant connectivity
- `GET /housekeeping/devices` - List devices (filter by domain, area)
- `GET /housekeeping/devices/{entity_id}` - Get device details
- `GET /housekeeping/areas` - List areas/rooms
- `GET /housekeeping/scenes` - List scenes
- `GET /housekeeping/scripts` - List scripts
- `GET /housekeeping/automations` - List automations
- `GET /housekeeping/history` - Get state history
**Write Endpoints (Admin Only):**
- `POST /housekeeping/devices/{entity_id}/control` - Control device
- `POST /housekeeping/scenes/{scene_id}/activate` - Activate scene
- `POST /housekeeping/scripts/{script_id}/run` - Run script
- `POST /housekeeping/automations/{automation_id}/toggle` - Enable/disable automation
Abstracts Home Assistant for the Tatlock Housekeeper agent and other consumers.
### Web Scraper
Intelligent web scraping with main content extraction.
Perfect for extracting articles, documentation, and blog posts for LLM consumption.
## Authentication
When OIDC authentication is enabled (oidc_enabled=true in config):
- Infrastructure write endpoints require authentication
- Use OAuth2/OIDC bearer token from Authentik
- Admin group membership required for infrastructure operations
## Integration
This API is designed to integrate with:
- **Open WebUI**: Direct OpenAI API compatibility
- **Open WebUI Functions**: Import via OpenAPI spec
- **Open WebUI Pipelines**: Use as data source
- **LangChain**: Compatible with standard HTTP tools
## Documentation
- **OpenAPI Spec**: `/openapi.json`
- **Swagger UI**: `/docs`
- **ReDoc**: `/redoc`
See `/docs` for the full API reference.
""",
docs_url="/docs",
redoc_url="/redoc",
redoc_url=None,
openapi_url="/openapi.json",
lifespan=lifespan,
debug=settings.debug,
@@ -172,7 +100,6 @@ app.include_router(tools_controller.router) # /tools/*
app.include_router(infrastructure_controller.router) # /infrastructure/*
app.include_router(housekeeping_controller.router) # /housekeeping/*
app.include_router(static_controller.router) # /static/*
app.include_router(ai_router) # /ai/*
# Global exception handler
+376
View File
@@ -402,3 +402,379 @@ class TestInfrastructureGetService:
response = client.get("/infrastructure/services/nonexistent")
assert response.status_code == 404
class TestInfrastructureDeleteContainer:
"""Test DELETE /infrastructure/containers/{container_id} endpoint."""
@patch("src.controllers.infrastructure_controller.get_portainer_client")
@patch("src.controllers.infrastructure_controller.get_npm_client")
def test_delete_container_returns_204(self, mock_get_npm, mock_get_portainer, client):
"""Delete container should return 204 on success."""
mock_portainer = AsyncMock()
mock_portainer.get_endpoints.return_value = [{"Id": 1}]
mock_portainer.delete_container.return_value = True
mock_get_portainer.return_value = mock_portainer
mock_npm = AsyncMock()
mock_get_npm.return_value = mock_npm
response = client.delete("/infrastructure/containers/abc123")
assert response.status_code == 204
@patch("src.controllers.infrastructure_controller.get_portainer_client")
@patch("src.controllers.infrastructure_controller.get_npm_client")
def test_delete_container_with_force(self, mock_get_npm, mock_get_portainer, client):
"""Delete container should pass force parameter."""
mock_portainer = AsyncMock()
mock_portainer.get_endpoints.return_value = [{"Id": 1}]
mock_portainer.delete_container.return_value = True
mock_get_portainer.return_value = mock_portainer
mock_npm = AsyncMock()
mock_get_npm.return_value = mock_npm
response = client.delete("/infrastructure/containers/abc123?force=true")
assert response.status_code == 204
mock_portainer.delete_container.assert_called_once_with(1, "abc123", force=True)
@patch("src.controllers.infrastructure_controller.get_portainer_client")
@patch("src.controllers.infrastructure_controller.get_npm_client")
def test_delete_container_returns_404_for_missing(self, mock_get_npm, mock_get_portainer, client):
"""Delete container should return 404 if not found."""
mock_portainer = AsyncMock()
mock_portainer.get_endpoints.return_value = [{"Id": 1}]
mock_portainer.delete_container.side_effect = Exception("404 no such container")
mock_get_portainer.return_value = mock_portainer
mock_npm = AsyncMock()
mock_get_npm.return_value = mock_npm
response = client.delete("/infrastructure/containers/nonexistent")
assert response.status_code == 404
class TestInfrastructureStackCompose:
"""Test /infrastructure/stacks/{stackId}/compose endpoints."""
@patch("src.controllers.infrastructure_controller.get_portainer_client")
@patch("src.controllers.infrastructure_controller.get_npm_client")
def test_get_stack_compose_returns_yaml(self, mock_get_npm, mock_get_portainer, client):
"""Get stack compose should return YAML content."""
mock_portainer = AsyncMock()
mock_portainer.get_stacks.return_value = [
{"Id": 1, "Name": "mystack", "EndpointId": 1}
]
mock_portainer.get_stack_file.return_value = "version: '3'\nservices:\n web:\n image: nginx"
mock_get_portainer.return_value = mock_portainer
mock_npm = AsyncMock()
mock_get_npm.return_value = mock_npm
response = client.get("/infrastructure/stacks/mystack/compose")
assert response.status_code == 200
assert "text/yaml" in response.headers.get("content-type", "")
assert "version:" in response.text
@patch("src.controllers.infrastructure_controller.get_portainer_client")
@patch("src.controllers.infrastructure_controller.get_npm_client")
def test_get_stack_compose_returns_404_for_missing(self, mock_get_npm, mock_get_portainer, client):
"""Get stack compose should return 404 if stack not found."""
mock_portainer = AsyncMock()
mock_portainer.get_stacks.return_value = []
mock_get_portainer.return_value = mock_portainer
mock_npm = AsyncMock()
mock_get_npm.return_value = mock_npm
response = client.get("/infrastructure/stacks/nonexistent/compose")
assert response.status_code == 404
@patch("src.controllers.infrastructure_controller.get_portainer_client")
@patch("src.controllers.infrastructure_controller.get_npm_client")
def test_put_stack_compose_returns_204(self, mock_get_npm, mock_get_portainer, client):
"""Update stack compose should return 204 on success."""
mock_portainer = AsyncMock()
mock_portainer.get_stacks.return_value = [
{"Id": 1, "Name": "mystack", "EndpointId": 1}
]
mock_portainer.update_stack.return_value = {"Id": 1}
mock_get_portainer.return_value = mock_portainer
mock_npm = AsyncMock()
mock_get_npm.return_value = mock_npm
response = client.put(
"/infrastructure/stacks/mystack/compose",
content="version: '3'\nservices:\n web:\n image: nginx:latest",
headers={"Content-Type": "text/yaml"}
)
assert response.status_code == 204
@patch("src.controllers.infrastructure_controller.get_portainer_client")
@patch("src.controllers.infrastructure_controller.get_npm_client")
def test_put_stack_compose_returns_400_for_empty(self, mock_get_npm, mock_get_portainer, client):
"""Update stack compose should return 400 for empty content."""
mock_portainer = AsyncMock()
mock_portainer.get_stacks.return_value = [
{"Id": 1, "Name": "mystack", "EndpointId": 1}
]
mock_get_portainer.return_value = mock_portainer
mock_npm = AsyncMock()
mock_get_npm.return_value = mock_npm
response = client.put(
"/infrastructure/stacks/mystack/compose",
content="",
headers={"Content-Type": "text/yaml"}
)
assert response.status_code == 400
@patch("src.controllers.infrastructure_controller.get_portainer_client")
@patch("src.controllers.infrastructure_controller.get_npm_client")
def test_put_stack_compose_returns_404_for_missing(self, mock_get_npm, mock_get_portainer, client):
"""Update stack compose should return 404 if stack not found."""
mock_portainer = AsyncMock()
mock_portainer.get_stacks.return_value = []
mock_get_portainer.return_value = mock_portainer
mock_npm = AsyncMock()
mock_get_npm.return_value = mock_npm
response = client.put(
"/infrastructure/stacks/nonexistent/compose",
content="version: '3'",
headers={"Content-Type": "text/yaml"}
)
assert response.status_code == 404
class TestInfrastructureStackEnv:
"""Test /infrastructure/stacks/{stackId}/env endpoints."""
@patch("src.controllers.infrastructure_controller.get_portainer_client")
@patch("src.controllers.infrastructure_controller.get_npm_client")
def test_get_stack_env_returns_dict(self, mock_get_npm, mock_get_portainer, client):
"""Get stack env should return environment variables as dict."""
mock_portainer = AsyncMock()
mock_portainer.get_stacks.return_value = [
{"Id": 1, "Name": "mystack", "EndpointId": 1}
]
mock_portainer.get_stack.return_value = {
"Id": 1,
"Name": "mystack",
"Env": [
{"name": "DB_HOST", "value": "localhost"},
{"name": "DB_PORT", "value": "5432"}
]
}
mock_get_portainer.return_value = mock_portainer
mock_npm = AsyncMock()
mock_get_npm.return_value = mock_npm
response = client.get("/infrastructure/stacks/mystack/env")
assert response.status_code == 200
data = response.json()
assert data == {"DB_HOST": "localhost", "DB_PORT": "5432"}
@patch("src.controllers.infrastructure_controller.get_portainer_client")
@patch("src.controllers.infrastructure_controller.get_npm_client")
def test_get_stack_env_returns_empty_dict(self, mock_get_npm, mock_get_portainer, client):
"""Get stack env should return empty dict if no env vars."""
mock_portainer = AsyncMock()
mock_portainer.get_stacks.return_value = [
{"Id": 1, "Name": "mystack", "EndpointId": 1}
]
mock_portainer.get_stack.return_value = {"Id": 1, "Name": "mystack", "Env": []}
mock_get_portainer.return_value = mock_portainer
mock_npm = AsyncMock()
mock_get_npm.return_value = mock_npm
response = client.get("/infrastructure/stacks/mystack/env")
assert response.status_code == 200
assert response.json() == {}
@patch("src.controllers.infrastructure_controller.get_portainer_client")
@patch("src.controllers.infrastructure_controller.get_npm_client")
def test_get_stack_env_returns_404_for_missing(self, mock_get_npm, mock_get_portainer, client):
"""Get stack env should return 404 if stack not found."""
mock_portainer = AsyncMock()
mock_portainer.get_stacks.return_value = []
mock_get_portainer.return_value = mock_portainer
mock_npm = AsyncMock()
mock_get_npm.return_value = mock_npm
response = client.get("/infrastructure/stacks/nonexistent/env")
assert response.status_code == 404
@patch("src.controllers.infrastructure_controller.get_portainer_client")
@patch("src.controllers.infrastructure_controller.get_npm_client")
def test_put_stack_env_returns_204(self, mock_get_npm, mock_get_portainer, client):
"""Update stack env should return 204 on success."""
mock_portainer = AsyncMock()
mock_portainer.get_stacks.return_value = [
{"Id": 1, "Name": "mystack", "EndpointId": 1}
]
mock_portainer.update_stack_env.return_value = {"Id": 1}
mock_get_portainer.return_value = mock_portainer
mock_npm = AsyncMock()
mock_get_npm.return_value = mock_npm
response = client.put(
"/infrastructure/stacks/mystack/env",
json={"DB_HOST": "newhost", "DB_PORT": "5433"}
)
assert response.status_code == 204
@patch("src.controllers.infrastructure_controller.get_portainer_client")
@patch("src.controllers.infrastructure_controller.get_npm_client")
def test_put_stack_env_returns_404_for_missing(self, mock_get_npm, mock_get_portainer, client):
"""Update stack env should return 404 if stack not found."""
mock_portainer = AsyncMock()
mock_portainer.get_stacks.return_value = []
mock_get_portainer.return_value = mock_portainer
mock_npm = AsyncMock()
mock_get_npm.return_value = mock_npm
response = client.put(
"/infrastructure/stacks/nonexistent/env",
json={"KEY": "value"}
)
assert response.status_code == 404
class TestInfrastructureStackDeploy:
"""Test /infrastructure/stacks/{stackId}/deploy endpoint."""
@patch("src.controllers.infrastructure_controller.get_portainer_client")
@patch("src.controllers.infrastructure_controller.get_npm_client")
def test_deploy_stack_returns_202(self, mock_get_npm, mock_get_portainer, client):
"""Deploy stack should return 202 Accepted."""
mock_portainer = AsyncMock()
mock_portainer.get_stacks.return_value = [
{"Id": 1, "Name": "mystack", "EndpointId": 1}
]
mock_portainer.redeploy_stack.return_value = {"Id": 1}
mock_get_portainer.return_value = mock_portainer
mock_npm = AsyncMock()
mock_get_npm.return_value = mock_npm
response = client.post("/infrastructure/stacks/mystack/deploy")
assert response.status_code == 202
@patch("src.controllers.infrastructure_controller.get_portainer_client")
@patch("src.controllers.infrastructure_controller.get_npm_client")
def test_deploy_stack_does_not_pull_images(self, mock_get_npm, mock_get_portainer, client):
"""Deploy stack should not pull images."""
mock_portainer = AsyncMock()
mock_portainer.get_stacks.return_value = [
{"Id": 1, "Name": "mystack", "EndpointId": 1}
]
mock_portainer.redeploy_stack.return_value = {"Id": 1}
mock_get_portainer.return_value = mock_portainer
mock_npm = AsyncMock()
mock_get_npm.return_value = mock_npm
response = client.post("/infrastructure/stacks/mystack/deploy")
assert response.status_code == 202
mock_portainer.redeploy_stack.assert_called_once_with(
stack_id=1,
endpoint_id=1,
pull_image=False
)
@patch("src.controllers.infrastructure_controller.get_portainer_client")
@patch("src.controllers.infrastructure_controller.get_npm_client")
def test_deploy_stack_returns_404_for_missing(self, mock_get_npm, mock_get_portainer, client):
"""Deploy stack should return 404 if stack not found."""
mock_portainer = AsyncMock()
mock_portainer.get_stacks.return_value = []
mock_get_portainer.return_value = mock_portainer
mock_npm = AsyncMock()
mock_get_npm.return_value = mock_npm
response = client.post("/infrastructure/stacks/nonexistent/deploy")
assert response.status_code == 404
class TestInfrastructureStackRebuild:
"""Test /infrastructure/stacks/{stackId}/rebuild endpoint."""
@patch("src.controllers.infrastructure_controller.get_portainer_client")
@patch("src.controllers.infrastructure_controller.get_npm_client")
def test_rebuild_stack_returns_202(self, mock_get_npm, mock_get_portainer, client):
"""Rebuild stack should return 202 Accepted."""
mock_portainer = AsyncMock()
mock_portainer.get_stacks.return_value = [
{"Id": 1, "Name": "mystack", "EndpointId": 1}
]
mock_portainer.redeploy_stack.return_value = {"Id": 1}
mock_get_portainer.return_value = mock_portainer
mock_npm = AsyncMock()
mock_get_npm.return_value = mock_npm
response = client.post("/infrastructure/stacks/mystack/rebuild")
assert response.status_code == 202
@patch("src.controllers.infrastructure_controller.get_portainer_client")
@patch("src.controllers.infrastructure_controller.get_npm_client")
def test_rebuild_stack_pulls_images(self, mock_get_npm, mock_get_portainer, client):
"""Rebuild stack should pull images."""
mock_portainer = AsyncMock()
mock_portainer.get_stacks.return_value = [
{"Id": 1, "Name": "mystack", "EndpointId": 1}
]
mock_portainer.redeploy_stack.return_value = {"Id": 1}
mock_get_portainer.return_value = mock_portainer
mock_npm = AsyncMock()
mock_get_npm.return_value = mock_npm
response = client.post("/infrastructure/stacks/mystack/rebuild")
assert response.status_code == 202
mock_portainer.redeploy_stack.assert_called_once_with(
stack_id=1,
endpoint_id=1,
pull_image=True
)
@patch("src.controllers.infrastructure_controller.get_portainer_client")
@patch("src.controllers.infrastructure_controller.get_npm_client")
def test_rebuild_stack_returns_404_for_missing(self, mock_get_npm, mock_get_portainer, client):
"""Rebuild stack should return 404 if stack not found."""
mock_portainer = AsyncMock()
mock_portainer.get_stacks.return_value = []
mock_get_portainer.return_value = mock_portainer
mock_npm = AsyncMock()
mock_get_npm.return_value = mock_npm
response = client.post("/infrastructure/stacks/nonexistent/rebuild")
assert response.status_code == 404
@patch("src.controllers.infrastructure_controller.get_portainer_client")
@patch("src.controllers.infrastructure_controller.get_npm_client")
def test_rebuild_stack_case_insensitive(self, mock_get_npm, mock_get_portainer, client):
"""Rebuild stack should match stack name case-insensitively."""
mock_portainer = AsyncMock()
mock_portainer.get_stacks.return_value = [
{"Id": 1, "Name": "MyStack", "EndpointId": 1}
]
mock_portainer.redeploy_stack.return_value = {"Id": 1}
mock_get_portainer.return_value = mock_portainer
mock_npm = AsyncMock()
mock_get_npm.return_value = mock_npm
response = client.post("/infrastructure/stacks/mystack/rebuild")
assert response.status_code == 202
+234 -113
View File
@@ -391,40 +391,12 @@ class TestPortainerClientContainers:
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:
"""Test convenience wrapper methods."""
@pytest.mark.asyncio
async def test_list_containers_uses_portainer_first(self):
"""list_containers should try Portainer first."""
async def test_list_containers_uses_portainer(self):
"""list_containers should use Portainer API."""
client = PortainerClient(base_url="http://portainer:9000", api_key="key")
containers = [{"Id": "abc123", "Names": ["/test"]}]
@@ -442,57 +414,19 @@ class TestPortainerClientWrapperMethods:
mock_containers.assert_called_once()
@pytest.mark.asyncio
async def test_list_containers_falls_back_to_socket(self):
"""list_containers should fallback to Docker socket if Portainer returns empty."""
async def test_list_containers_raises_when_no_endpoints(self):
"""list_containers 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 = [{"Id": 1}]
mock_endpoints.return_value = []
with patch.object(client, "get_containers", new_callable=AsyncMock) as mock_containers:
mock_containers.return_value = []
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()
with pytest.raises(RuntimeError, match="No Portainer endpoints available"):
await client.list_containers()
@pytest.mark.asyncio
async def test_list_containers_handles_exception_with_fallback(self):
"""list_containers 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, "_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."""
async def test_inspect_container_uses_portainer(self):
"""inspect_container should use Portainer API."""
client = PortainerClient(base_url="http://portainer:9000", api_key="key")
container_list = [{"Id": "abc123", "Names": ["/mycontainer"]}]
@@ -512,54 +446,241 @@ class TestPortainerClientWrapperMethods:
assert result == container_detail
@pytest.mark.asyncio
async def test_inspect_container_falls_back_to_socket(self):
"""inspect_container should fallback if not found in Portainer."""
async def test_inspect_container_returns_none_when_not_found(self):
"""inspect_container should return None if container not found."""
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 = [{"Id": 1}]
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:
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")
result = await client.inspect_container("missing_container")
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:
"""Test singleton pattern."""
Executable
+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"