Compare commits
+54
-12
@@ -1,23 +1,65 @@
|
||||
# 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
|
||||
|
||||
# =============================================================================
|
||||
# Database (PostgreSQL)
|
||||
# =============================================================================
|
||||
|
||||
POSTGRES_HOST=localhost:5432
|
||||
POSTGRES_USER=core_api
|
||||
POSTGRES_PASSWORD=your-password
|
||||
|
||||
# =============================================================================
|
||||
# Vector Database
|
||||
# =============================================================================
|
||||
|
||||
# Qdrant
|
||||
QDRANT_HOST=qdrant
|
||||
QDRANT_PORT=6333
|
||||
@@ -105,7 +105,6 @@ data/
|
||||
|
||||
# Credentials and secrets
|
||||
src/credentials.py
|
||||
credentials.py
|
||||
*.pem
|
||||
*.key
|
||||
secrets/
|
||||
|
||||
@@ -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
|
||||
+102
@@ -5,6 +5,108 @@ 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.4.0] - 2026-01-01
|
||||
|
||||
### Added
|
||||
|
||||
- **Authentication & User Management** - Authentik integration for user synchronization
|
||||
- `GET /auth/me` - Get current authenticated user info
|
||||
- `GET /auth/users` - List all users with search and pagination
|
||||
- `POST /auth/users/sync-from-authentik` - Bulk sync users from Authentik admin API
|
||||
- PostgreSQL database integration with async SQLAlchemy
|
||||
- Alembic database migrations for schema management
|
||||
- Database models: User, Role, UserPreferences, ApiKey
|
||||
- Token validation via Authentik userinfo endpoint
|
||||
- Role synchronization from Authentik groups
|
||||
- Health check now includes database connectivity status
|
||||
|
||||
### Changed
|
||||
|
||||
- Authentik configuration now uses username/password for admin API access
|
||||
- Health endpoint includes database status in diagnostics
|
||||
|
||||
## [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
|
||||
|
||||
- **Housekeeping API** - Home Assistant integration for smart home control
|
||||
- `GET /housekeeping/health` - HA connection status
|
||||
- `GET /housekeeping/devices` - List controllable devices with optional domain/area filtering
|
||||
- `GET /housekeeping/devices/{entity_id}` - Get device details
|
||||
- `POST /housekeeping/devices/{entity_id}/control` - Control devices (turn_on, turn_off, toggle, set_brightness)
|
||||
- `GET /housekeeping/scenes` - List available scenes
|
||||
- `POST /housekeeping/scenes/{scene_id}/activate` - Activate a scene
|
||||
- `GET /housekeeping/scripts` - List available scripts
|
||||
- `POST /housekeeping/scripts/{script_id}/run` - Run a script
|
||||
- `GET /housekeeping/automations` - List automations
|
||||
- `POST /housekeeping/automations/{automation_id}/toggle` - Enable/disable automation
|
||||
- `GET /housekeeping/history` - Query state history
|
||||
- `GET /housekeeping/areas` - List rooms/areas
|
||||
- Home Assistant REST API client (`src/clients/homeassistant_client.py`)
|
||||
- Home Assistant configuration in credentials and settings
|
||||
- Comprehensive test suite with 65% code coverage (285 tests)
|
||||
- Tests for NPM client, Ollama client, AI client, OIDC authentication
|
||||
- Tests for infrastructure, health, tools, and housekeeping endpoints
|
||||
|
||||
### Removed
|
||||
|
||||
- **Web Scraper** - Entire web scraping module removed
|
||||
- `src/web_scraper/` directory deleted
|
||||
- `/web-scraper/scrape` endpoint removed
|
||||
- Trafilatura and BeautifulSoup dependencies removed from scraping use
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated README.md with current architecture and all endpoints
|
||||
- Tools controller now only contains DNS lookup functionality
|
||||
- Health controller endpoints list updated to reflect current features
|
||||
|
||||
## [1.1.2] - 2024-12-14
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,33 +1,96 @@
|
||||
# Core Code API
|
||||
|
||||
OpenAPI-compatible functions for Open WebUI, providing web scraping and data processing capabilities.
|
||||
Central API service providing infrastructure management, home automation, and utility endpoints for the homelab ecosystem.
|
||||
|
||||
## Features
|
||||
|
||||
### Web Scraper
|
||||
- Intelligent content extraction using Trafilatura
|
||||
- BeautifulSoup fallback for complex pages
|
||||
- Configurable content length limits
|
||||
- Optional link extraction
|
||||
- Perfect for feeding webpage content to LLMs
|
||||
### Infrastructure Management
|
||||
- **Portainer Integration**: Stack and container management
|
||||
- **NPM Integration**: Nginx Proxy Manager domain and certificate management
|
||||
- **Service Control**: Start/stop services with container orchestration
|
||||
|
||||
### Home Automation (Housekeeping API)
|
||||
- **Device Control**: Turn on/off, toggle, and set brightness for smart devices
|
||||
- **Scene Activation**: Trigger Home Assistant scenes
|
||||
- **Script Execution**: Run Home Assistant scripts
|
||||
- **Automation Management**: Enable/disable automations
|
||||
- **State History**: Query device state changes over time
|
||||
- **Area Discovery**: List rooms and areas
|
||||
|
||||
### Utilities
|
||||
- **DNS Lookup**: Query DNS records (A, AAAA, MX, TXT, CNAME, NS, SOA, PTR)
|
||||
- **Health Checks**: Comprehensive service health monitoring
|
||||
- **AI Metrics Proxy**: Forward metrics requests to Core-AI service
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
src/
|
||||
├── config.py # Global application settings
|
||||
├── logging_config.py # Logging configuration
|
||||
├── base_schema.py # Base Pydantic models
|
||||
├── main.py # FastAPI application entry point
|
||||
└── web_scraper/ # Web scraper module
|
||||
├── __init__.py
|
||||
├── config.py # Module-specific settings
|
||||
├── schemas.py # Pydantic request/response models
|
||||
├── service.py # Business logic
|
||||
├── router.py # API routes
|
||||
└── exceptions.py # Custom exceptions
|
||||
├── config.py # Global application settings
|
||||
├── logging_config.py # Logging configuration
|
||||
├── base_controller.py # Base controller pattern
|
||||
├── main.py # FastAPI application entry point
|
||||
├── auth/
|
||||
│ └── oidc.py # OIDC authentication
|
||||
├── clients/
|
||||
│ ├── homeassistant_client.py # Home Assistant REST client
|
||||
│ ├── npm_client.py # Nginx Proxy Manager client
|
||||
│ ├── ollama_client.py # Ollama LLM client
|
||||
│ └── portainer_client.py # Portainer API client
|
||||
├── controllers/
|
||||
│ ├── ai_controller.py # AI metrics proxy
|
||||
│ ├── health_controller.py # Health endpoints
|
||||
│ ├── housekeeping_controller.py # Home automation endpoints
|
||||
│ ├── infrastructure_controller.py # Infrastructure management
|
||||
│ ├── static_controller.py # Static file serving
|
||||
│ └── tools_controller.py # DNS and utility tools
|
||||
└── dns/
|
||||
├── service.py # DNS lookup service
|
||||
└── exceptions.py # DNS-specific exceptions
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Health
|
||||
- `GET /` - Service info and documentation links
|
||||
- `GET /health` - Basic health status
|
||||
- `GET /health/full` - Detailed component health
|
||||
- `GET /health/diagnostics` - Full diagnostic information
|
||||
|
||||
### Infrastructure (`/infrastructure`)
|
||||
- `GET /infrastructure/health` - Portainer/NPM connection status
|
||||
- `GET /infrastructure/services` - List all services (stacks)
|
||||
- `GET /infrastructure/services/{name}` - Get service details
|
||||
- `GET /infrastructure/services/{name}/status` - Service status
|
||||
- `POST /infrastructure/services/{name}/start` - Start service
|
||||
- `POST /infrastructure/services/{name}/stop` - Stop service
|
||||
- `GET /infrastructure/containers` - List containers
|
||||
- `GET /infrastructure/containers/{name}` - Container details
|
||||
- `GET /infrastructure/containers/{name}/logs` - Container logs
|
||||
- `GET /infrastructure/ports` - List exposed ports
|
||||
- `GET /infrastructure/domains` - List proxy domains
|
||||
- `GET /infrastructure/widget-data` - Dashboard widget data
|
||||
|
||||
### Housekeeping (`/housekeeping`)
|
||||
- `GET /housekeeping/health` - Home Assistant connection status
|
||||
- `GET /housekeeping/devices` - List controllable devices
|
||||
- `GET /housekeeping/devices/{entity_id}` - Device details
|
||||
- `POST /housekeeping/devices/{entity_id}/control` - Control device
|
||||
- `GET /housekeeping/scenes` - List scenes
|
||||
- `POST /housekeeping/scenes/{scene_id}/activate` - Activate scene
|
||||
- `GET /housekeeping/scripts` - List scripts
|
||||
- `POST /housekeeping/scripts/{script_id}/run` - Run script
|
||||
- `GET /housekeeping/automations` - List automations
|
||||
- `POST /housekeeping/automations/{automation_id}/toggle` - Toggle automation
|
||||
- `GET /housekeeping/history` - State history
|
||||
- `GET /housekeeping/areas` - List areas/rooms
|
||||
|
||||
### Tools (`/tools`)
|
||||
- `POST /tools/dns/lookup` - DNS record lookup
|
||||
|
||||
### AI (`/ai`)
|
||||
- `GET /ai/metrics` - Proxy to Core-AI metrics
|
||||
|
||||
## Development
|
||||
|
||||
### Requirements
|
||||
@@ -40,13 +103,30 @@ src/
|
||||
# Install dependencies
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Copy credentials template
|
||||
cp src/credentials.example.py src/credentials.py
|
||||
# Edit src/credentials.py with your values
|
||||
|
||||
# Run locally
|
||||
uvicorn src.main:app --reload --host 0.0.0.0 --port 8083
|
||||
```
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
pytest
|
||||
|
||||
# Run with coverage
|
||||
pytest --cov=src --cov-report=term-missing
|
||||
|
||||
# Run specific test file
|
||||
pytest tests/test_housekeeping.py -v
|
||||
```
|
||||
|
||||
### Adding New Dependencies
|
||||
|
||||
**Important**: Dependencies use major version pinning (`~=`) for automatic patch updates while preventing breaking changes.
|
||||
Dependencies use major version pinning (`~=`) for automatic patch updates while preventing breaking changes.
|
||||
|
||||
1. Add package to `requirements.txt` with major version constraint:
|
||||
```
|
||||
@@ -58,14 +138,6 @@ uvicorn src.main:app --reload --host 0.0.0.0 --port 8083
|
||||
docker restart core-api
|
||||
```
|
||||
|
||||
The container automatically runs `pip install -r requirements.txt` on every boot, so new dependencies are installed immediately on restart.
|
||||
|
||||
**Version Pinning Best Practices**:
|
||||
- Use `~=` (compatible release) for most packages: `fastapi~=0.115.0`
|
||||
- Use `>=X,<Y` for complex constraints: `langchain-core>=0.3.17,<0.4.0`
|
||||
- Allows automatic security patches without breaking changes
|
||||
- Documented in PEP 440
|
||||
|
||||
### Docker Build
|
||||
|
||||
```bash
|
||||
@@ -88,130 +160,39 @@ docker run -p 8083:8083 core-code:latest
|
||||
|
||||
### Environment Variables
|
||||
|
||||
See `.env.example` for all available configuration options.
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `PORTAINER_URL` | Portainer API URL | `http://localhost:9000` |
|
||||
| `PORTAINER_API_KEY` | Portainer API key | - |
|
||||
| `NPM_URL` | Nginx Proxy Manager URL | `http://localhost:81` |
|
||||
| `NPM_EMAIL` | NPM admin email | - |
|
||||
| `NPM_PASSWORD` | NPM admin password | - |
|
||||
| `HOMEASSISTANT_URL` | Home Assistant URL | `http://localhost:8123` |
|
||||
| `HOMEASSISTANT_TOKEN` | HA long-lived access token | - |
|
||||
| `OLLAMA_URL` | Ollama API URL | `http://localhost:11434` |
|
||||
| `OIDC_ENABLED` | Enable OIDC auth | `false` |
|
||||
| `OIDC_ISSUER` | OIDC issuer URL | - |
|
||||
| `OIDC_AUDIENCE` | OIDC audience | - |
|
||||
|
||||
## API Documentation
|
||||
|
||||
Once deployed, access documentation at:
|
||||
- **Swagger UI**: http://192.168.86.149:8083/docs
|
||||
- **ReDoc**: http://192.168.86.149:8083/redoc
|
||||
- **OpenAPI Spec**: http://192.168.86.149:8083/openapi.json
|
||||
|
||||
## Integration with Open WebUI
|
||||
|
||||
### Method 1: Functions (OpenAPI Import)
|
||||
1. In Open WebUI, navigate to Functions
|
||||
2. Import from OpenAPI spec: `http://192.168.86.149:8083/openapi.json`
|
||||
3. Use functions directly in chat
|
||||
|
||||
### Method 2: Pipelines
|
||||
1. Create a pipeline that calls Core Code API endpoints
|
||||
2. Use as data source for LLM workflows
|
||||
|
||||
### Method 3: Direct API Calls
|
||||
```python
|
||||
import httpx
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
"http://192.168.86.149:8083/web-scraper/scrape",
|
||||
json={
|
||||
"url": "https://example.com",
|
||||
"extract_main_content": True
|
||||
}
|
||||
)
|
||||
data = response.json()
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Web Scraper
|
||||
|
||||
**POST /web-scraper/scrape**
|
||||
|
||||
Scrape and extract content from a website.
|
||||
|
||||
Request:
|
||||
```json
|
||||
{
|
||||
"url": "https://example.com/article",
|
||||
"extract_main_content": true,
|
||||
"include_links": false,
|
||||
"max_length": 10000
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"url": "https://example.com/article",
|
||||
"title": "Article Title",
|
||||
"content": "Extracted article content...",
|
||||
"extracted_at": "2025-11-12T19:30:00Z",
|
||||
"content_length": 5432,
|
||||
"links": null
|
||||
}
|
||||
```
|
||||
|
||||
## Logging
|
||||
|
||||
Logs are written to:
|
||||
- **Console**: stdout (captured by Docker)
|
||||
- **File**: `/app/logs/app.log` (persisted via volume mount)
|
||||
|
||||
Log format:
|
||||
```
|
||||
2025-11-12 19:30:00 | INFO | src.web_scraper.service:scrape_url:45 | Starting scrape for URL: https://example.com
|
||||
```
|
||||
- **Swagger UI**: http://localhost:8083/docs
|
||||
- **ReDoc**: http://localhost:8083/redoc
|
||||
- **OpenAPI Spec**: http://localhost:8083/openapi.json
|
||||
|
||||
## Health Checks
|
||||
|
||||
- **Endpoint**: `GET /health`
|
||||
- **Docker**: Automatic health checks configured
|
||||
- **Response**: `{"status": "healthy"}`
|
||||
- **Basic**: `GET /health` - Returns status and Ollama connection
|
||||
- **Full**: `GET /health/full` - Returns all component statuses (503 if unhealthy)
|
||||
- **Diagnostics**: `GET /health/diagnostics` - Detailed service information
|
||||
|
||||
## Security
|
||||
|
||||
- Runs as non-root user (uid 1000)
|
||||
- No authentication required (internal network only)
|
||||
- OIDC authentication support via Authentik
|
||||
- CORS configured for same-network access
|
||||
- Rate limiting: Not implemented (internal use only)
|
||||
|
||||
## Future Modules
|
||||
|
||||
The architecture supports adding new modules:
|
||||
- Data transformation functions
|
||||
- API integrations
|
||||
- File processing
|
||||
- Database queries
|
||||
|
||||
Each module follows the same structure:
|
||||
```
|
||||
src/
|
||||
└── module_name/
|
||||
├── config.py
|
||||
├── schemas.py
|
||||
├── service.py
|
||||
├── router.py
|
||||
└── exceptions.py
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Container won't start
|
||||
```bash
|
||||
docker logs core-code
|
||||
```
|
||||
|
||||
### API not responding
|
||||
```bash
|
||||
curl http://192.168.86.149:8083/health
|
||||
```
|
||||
|
||||
### Check OpenAPI spec
|
||||
```bash
|
||||
curl http://192.168.86.149:8083/openapi.json | jq
|
||||
```
|
||||
- Admin endpoints require authentication when OIDC enabled
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -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
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
# Alembic Configuration for Core-API
|
||||
#
|
||||
# Database migrations using async SQLAlchemy
|
||||
|
||||
[alembic]
|
||||
# Path to migration scripts
|
||||
script_location = alembic
|
||||
|
||||
# Template for migration file names
|
||||
file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d_%%(rev)s_%%(slug)s
|
||||
|
||||
# Prepend sys.path with the project root
|
||||
prepend_sys_path = .
|
||||
|
||||
# Timezone for revision creation date
|
||||
timezone = UTC
|
||||
|
||||
# Max length of revision identifiers
|
||||
truncate_slug_length = 40
|
||||
|
||||
# Set to 'true' to run in offline mode
|
||||
revision_environment = false
|
||||
|
||||
# Set to 'true' for sqlalchemy.url to be from the environment
|
||||
# We use env.py to get the URL from config.py instead
|
||||
sqlalchemy.url =
|
||||
|
||||
[post_write_hooks]
|
||||
# Black formatting on generated migration files
|
||||
# hooks = black
|
||||
# black.type = console_scripts
|
||||
# black.entrypoint = black
|
||||
# black.options = -l 100 REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Logging configuration
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
@@ -0,0 +1,21 @@
|
||||
Alembic Migrations for Core-API
|
||||
|
||||
This directory contains database migrations managed by Alembic.
|
||||
|
||||
Commands:
|
||||
# Generate a new migration (after changing models)
|
||||
alembic revision --autogenerate -m "description"
|
||||
|
||||
# Apply all pending migrations
|
||||
alembic upgrade head
|
||||
|
||||
# Rollback last migration
|
||||
alembic downgrade -1
|
||||
|
||||
# View migration history
|
||||
alembic history
|
||||
|
||||
# View current revision
|
||||
alembic current
|
||||
|
||||
See https://alembic.sqlalchemy.org for more documentation.
|
||||
@@ -0,0 +1,98 @@
|
||||
"""
|
||||
Alembic Environment Configuration
|
||||
|
||||
Async migration environment for SQLAlchemy 2.0 with asyncpg.
|
||||
"""
|
||||
import asyncio
|
||||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import pool
|
||||
from sqlalchemy.engine import Connection
|
||||
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||
|
||||
from alembic import context
|
||||
|
||||
# Import our models and config
|
||||
from src.config import get_settings
|
||||
from src.db.database import Base
|
||||
|
||||
# Import all models to ensure they're registered with Base.metadata
|
||||
from src.db.models import User, Role, UserRole, UserPreferences, ApiKey # noqa: F401
|
||||
|
||||
# Alembic Config object
|
||||
config = context.config
|
||||
|
||||
# Interpret the config file for Python logging
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
# Model metadata for autogenerate support
|
||||
target_metadata = Base.metadata
|
||||
|
||||
# Get database URL from our settings
|
||||
settings = get_settings()
|
||||
db_url = settings.database_url
|
||||
if db_url.startswith("postgresql://"):
|
||||
db_url = db_url.replace("postgresql://", "postgresql+asyncpg://", 1)
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""
|
||||
Run migrations in 'offline' mode.
|
||||
|
||||
Generates SQL script without connecting to the database.
|
||||
"""
|
||||
context.configure(
|
||||
url=db_url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def do_run_migrations(connection: Connection) -> None:
|
||||
"""
|
||||
Run migrations with the given connection.
|
||||
"""
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def run_async_migrations() -> None:
|
||||
"""
|
||||
Run migrations in 'online' mode with async engine.
|
||||
"""
|
||||
configuration = config.get_section(config.config_ini_section) or {}
|
||||
configuration["sqlalchemy.url"] = db_url
|
||||
|
||||
connectable = async_engine_from_config(
|
||||
configuration,
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
async with connectable.connect() as connection:
|
||||
await connection.run_sync(do_run_migrations)
|
||||
|
||||
await connectable.dispose()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""
|
||||
Run migrations in 'online' mode.
|
||||
"""
|
||||
asyncio.run(run_async_migrations())
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -0,0 +1,25 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Create auth tables
|
||||
|
||||
Revision ID: 001
|
||||
Revises:
|
||||
Create Date: 2026-01-01
|
||||
|
||||
Creates the initial authentication and authorization tables:
|
||||
- users: User accounts synced from Authentik
|
||||
- roles: Domain-scoped permission roles
|
||||
- user_roles: User-Role association table
|
||||
- user_preferences: User settings and preferences
|
||||
- api_keys: API key authentication
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "001"
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Users table
|
||||
op.create_table(
|
||||
"users",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("authentik_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("email", sa.String(255), nullable=False),
|
||||
sa.Column("name", sa.String(255), nullable=False),
|
||||
sa.Column("avatar_url", sa.String(500), nullable=True),
|
||||
sa.Column("api_keys_enabled", sa.Boolean(), nullable=False, server_default="true"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("last_login", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.create_index("ix_users_authentik_id", "users", ["authentik_id"], unique=True)
|
||||
op.create_index("ix_users_email", "users", ["email"], unique=True)
|
||||
|
||||
# Roles table
|
||||
op.create_table(
|
||||
"roles",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("name", sa.String(100), nullable=False, comment="Role name in format domain:action"),
|
||||
sa.Column("domain", sa.String(50), nullable=False, comment="Permission domain"),
|
||||
sa.Column("action", sa.String(20), nullable=False, comment="Permission action"),
|
||||
sa.Column("authentik_group", sa.String(255), nullable=True, comment="Corresponding Authentik group name"),
|
||||
)
|
||||
op.create_index("ix_roles_name", "roles", ["name"], unique=True)
|
||||
op.create_index("ix_roles_domain", "roles", ["domain"])
|
||||
op.create_index("ix_roles_authentik_group", "roles", ["authentik_group"], unique=True)
|
||||
|
||||
# User-Role association table
|
||||
op.create_table(
|
||||
"user_roles",
|
||||
sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), primary_key=True),
|
||||
sa.Column("role_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("roles.id", ondelete="CASCADE"), primary_key=True),
|
||||
)
|
||||
|
||||
# User preferences table
|
||||
op.create_table(
|
||||
"user_preferences",
|
||||
sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), primary_key=True),
|
||||
sa.Column("theme", sa.String(20), nullable=False, server_default="system", comment="Theme preference"),
|
||||
sa.Column("default_room", sa.String(50), nullable=False, server_default="front-hall", comment="Default room"),
|
||||
sa.Column("preferences_json", postgresql.JSONB(), nullable=False, server_default="{}"),
|
||||
)
|
||||
|
||||
# API keys table
|
||||
op.create_table(
|
||||
"api_keys",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("name", sa.String(100), nullable=False, comment="Human-readable key name"),
|
||||
sa.Column("key_hash", sa.String(255), nullable=False, comment="SHA-256 hash of the API key"),
|
||||
sa.Column("key_prefix", sa.String(8), nullable=False, comment="First 8 chars for identification"),
|
||||
sa.Column("scopes", postgresql.ARRAY(sa.String()), nullable=True, comment="Optional scope restriction"),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
op.create_index("ix_api_keys_user_id", "api_keys", ["user_id"])
|
||||
|
||||
# Seed initial roles (domain:action combinations)
|
||||
roles_table = sa.table(
|
||||
"roles",
|
||||
sa.column("id", postgresql.UUID),
|
||||
sa.column("name", sa.String),
|
||||
sa.column("domain", sa.String),
|
||||
sa.column("action", sa.String),
|
||||
sa.column("authentik_group", sa.String),
|
||||
)
|
||||
|
||||
domains = [
|
||||
"control-room",
|
||||
"library",
|
||||
"media",
|
||||
"ai",
|
||||
"housekeeper",
|
||||
"developer",
|
||||
"documents",
|
||||
"gaming",
|
||||
"admin",
|
||||
]
|
||||
actions = ["viewer", "user", "editor", "admin"]
|
||||
|
||||
roles_data = []
|
||||
for domain in domains:
|
||||
for action in actions:
|
||||
role_name = f"{domain}:{action}"
|
||||
authentik_group = f"tatlock-{domain}-{action}"
|
||||
roles_data.append({
|
||||
"id": sa.text("gen_random_uuid()"),
|
||||
"name": role_name,
|
||||
"domain": domain,
|
||||
"action": action,
|
||||
"authentik_group": authentik_group,
|
||||
})
|
||||
|
||||
# Insert roles using raw SQL for UUID generation
|
||||
for role in roles_data:
|
||||
op.execute(
|
||||
f"""
|
||||
INSERT INTO roles (id, name, domain, action, authentik_group)
|
||||
VALUES (gen_random_uuid(), '{role["name"]}', '{role["domain"]}', '{role["action"]}', '{role["authentik_group"]}')
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("api_keys")
|
||||
op.drop_table("user_preferences")
|
||||
op.drop_table("user_roles")
|
||||
op.drop_table("roles")
|
||||
op.drop_table("users")
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "core-api"
|
||||
version = "1.1.2"
|
||||
version = "1.4.0"
|
||||
description = "Core Code API - Infrastructure management and tools API"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
+14
-8
@@ -1,12 +1,13 @@
|
||||
# FastAPI and ASGI server
|
||||
fastapi~=0.115.0
|
||||
uvicorn[standard]>=0.34.0 # Updated for google-adk compatibility
|
||||
pydantic>=2.11.1,<3.0.0 # Required for google-cloud-aiplatform[agent-engines]
|
||||
fastapi>=0.115.0
|
||||
starlette>=0.49.1 # CVE-2025-54121, CVE-2025-62727
|
||||
uvicorn[standard]>=0.34.0
|
||||
pydantic>=2.11.1,<3.0.0
|
||||
pydantic-settings>=2.10.1
|
||||
|
||||
# HTTP client
|
||||
httpx>=0.28.0 # Required for google-adk
|
||||
python-socketio[asyncio_client]~=5.11.0
|
||||
httpx>=0.28.0
|
||||
python-socketio[asyncio_client]>=5.14.0 # CVE-2025-61765
|
||||
|
||||
# Web scraping
|
||||
beautifulsoup4~=4.12.0
|
||||
@@ -22,6 +23,11 @@ pytz~=2024.1
|
||||
dnspython~=2.7.0
|
||||
|
||||
# Authentication & Security
|
||||
PyJWT[crypto]~=2.9.0
|
||||
python-jose[cryptography]~=3.3.0
|
||||
cryptography~=43.0.0
|
||||
PyJWT[crypto]>=2.9.0
|
||||
python-jose[cryptography]>=3.4.0 # CVE PYSEC-2024-232, PYSEC-2024-233
|
||||
cryptography>=44.0.1 # CVE-2024-12797
|
||||
|
||||
# Database
|
||||
sqlalchemy[asyncio]~=2.0.0
|
||||
asyncpg>=0.30.0
|
||||
alembic~=1.13.0
|
||||
|
||||
@@ -3,3 +3,25 @@ Authentication module for core-api
|
||||
|
||||
Provides OIDC/OAuth2 authentication via Authentik
|
||||
"""
|
||||
from src.auth.oidc import (
|
||||
get_current_user,
|
||||
get_admin_user,
|
||||
get_optional_user,
|
||||
get_forward_auth_user,
|
||||
get_forward_auth_admin,
|
||||
oidc_config,
|
||||
)
|
||||
from src.auth.service import AuthService, get_auth_service
|
||||
from src.auth.controller import auth_controller
|
||||
|
||||
__all__ = [
|
||||
"get_current_user",
|
||||
"get_admin_user",
|
||||
"get_optional_user",
|
||||
"get_forward_auth_user",
|
||||
"get_forward_auth_admin",
|
||||
"oidc_config",
|
||||
"AuthService",
|
||||
"get_auth_service",
|
||||
"auth_controller",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
"""
|
||||
Authentication Controller
|
||||
|
||||
Provides authentication endpoints for OIDC token sync and user management.
|
||||
"""
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.controllers.base import BaseController
|
||||
from src.logging_config import get_logger
|
||||
from src.db import get_async_session
|
||||
from src.auth.schemas import AuthSyncRequest, AuthSyncResponse, UsersListResponse, BulkSyncResultSchema
|
||||
from src.auth.service import AuthService
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class AuthController(BaseController):
|
||||
"""
|
||||
Controller for authentication operations
|
||||
|
||||
Provides endpoints for:
|
||||
- Token synchronization (login)
|
||||
- User profile retrieval
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(prefix="/auth", tags=["Authentication"])
|
||||
|
||||
def create_router(self) -> APIRouter:
|
||||
"""Create and configure the router"""
|
||||
router = APIRouter(prefix=self.prefix, tags=self.tags)
|
||||
|
||||
@router.post(
|
||||
"/sync",
|
||||
summary="Sync user from OIDC token",
|
||||
response_model=AuthSyncResponse,
|
||||
responses={
|
||||
200: {"description": "User synced successfully"},
|
||||
401: {"description": "Invalid or expired token"},
|
||||
503: {"description": "Authentication service unavailable"},
|
||||
},
|
||||
)
|
||||
async def sync_user(
|
||||
request: AuthSyncRequest,
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
) -> AuthSyncResponse:
|
||||
"""
|
||||
Synchronize user from OIDC access token
|
||||
|
||||
This endpoint should be called after the client obtains an access token
|
||||
from Authentik. It:
|
||||
1. Validates the token via Authentik's userinfo endpoint
|
||||
2. Creates or updates the user in the database
|
||||
3. Syncs roles from Authentik groups
|
||||
4. Returns the user profile with roles and preferences
|
||||
|
||||
The client should store the returned user info for local use.
|
||||
"""
|
||||
service = AuthService(session)
|
||||
|
||||
try:
|
||||
# Validate token with Authentik
|
||||
token_info = await service.validate_token(request.access_token)
|
||||
except ValueError as e:
|
||||
logger.warning(f"Token validation failed: {e}")
|
||||
raise HTTPException(status_code=401, detail=str(e))
|
||||
|
||||
# Sync user to database
|
||||
user, is_new = await service.sync_user(token_info)
|
||||
|
||||
# Sync roles from groups
|
||||
roles = await service.sync_roles(user, token_info.groups)
|
||||
|
||||
# Commit the transaction
|
||||
await session.commit()
|
||||
|
||||
# Refresh to get relationships
|
||||
await session.refresh(user, ["preferences"])
|
||||
|
||||
# Build response
|
||||
return AuthSyncResponse(
|
||||
user=service.user_to_schema(user),
|
||||
roles=service.roles_to_schema(roles),
|
||||
preferences=service.preferences_to_schema(user.preferences),
|
||||
is_new_user=is_new,
|
||||
)
|
||||
|
||||
@router.get(
|
||||
"/users",
|
||||
summary="List all users",
|
||||
response_model=UsersListResponse,
|
||||
responses={
|
||||
200: {"description": "List of users"},
|
||||
},
|
||||
)
|
||||
async def list_users(
|
||||
search: Optional[str] = Query(None, description="Search by name or email"),
|
||||
offset: int = Query(0, ge=0, description="Number of records to skip"),
|
||||
limit: int = Query(50, ge=1, le=100, description="Maximum records to return"),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
) -> UsersListResponse:
|
||||
"""
|
||||
List all users who have logged in via Authentik
|
||||
|
||||
Returns paginated list of users with their roles.
|
||||
Supports search filtering by name or email.
|
||||
"""
|
||||
service = AuthService(session)
|
||||
items, total = await service.list_users(
|
||||
search=search,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
)
|
||||
return UsersListResponse(items=items, total=total)
|
||||
|
||||
@router.post(
|
||||
"/users/sync-from-authentik",
|
||||
summary="Bulk sync users from Authentik",
|
||||
response_model=BulkSyncResultSchema,
|
||||
responses={
|
||||
200: {"description": "Sync completed"},
|
||||
401: {"description": "Authentik API token invalid"},
|
||||
503: {"description": "Authentik service unavailable"},
|
||||
},
|
||||
)
|
||||
async def sync_users_from_authentik(
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
) -> BulkSyncResultSchema:
|
||||
"""
|
||||
Fetch all users from Authentik and sync to local database
|
||||
|
||||
This endpoint uses the Authentik admin API to fetch all users
|
||||
and create/update them in the local database. Requires
|
||||
AUTHENTIK_CORE_API_TOKEN to be configured.
|
||||
|
||||
Use this to initially populate users or to re-sync after
|
||||
changes in Authentik.
|
||||
"""
|
||||
service = AuthService(session)
|
||||
|
||||
try:
|
||||
result = await service.bulk_sync_from_authentik()
|
||||
logger.info(
|
||||
f"Bulk sync completed: {result.created} created, "
|
||||
f"{result.updated} updated, {result.failed} failed"
|
||||
)
|
||||
return result
|
||||
except ValueError as e:
|
||||
logger.error(f"Bulk sync failed: {e}")
|
||||
raise HTTPException(status_code=401, detail=str(e))
|
||||
|
||||
@router.get(
|
||||
"/me",
|
||||
summary="Get current user profile",
|
||||
response_model=AuthSyncResponse,
|
||||
responses={
|
||||
200: {"description": "User profile"},
|
||||
401: {"description": "Not authenticated"},
|
||||
},
|
||||
)
|
||||
async def get_me(
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
) -> JSONResponse:
|
||||
"""
|
||||
Get the current authenticated user's profile
|
||||
|
||||
Note: This endpoint requires a valid session or API key.
|
||||
For now, returns 501 Not Implemented until session management is added.
|
||||
"""
|
||||
# TODO: Implement with get_current_user dependency
|
||||
raise HTTPException(
|
||||
status_code=501,
|
||||
detail="Not implemented - use /auth/sync with access token",
|
||||
)
|
||||
|
||||
return router
|
||||
|
||||
|
||||
# Create controller instance
|
||||
auth_controller = AuthController()
|
||||
@@ -0,0 +1,110 @@
|
||||
"""
|
||||
Authentication Schemas
|
||||
|
||||
Pydantic models for auth request/response payloads.
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from pydantic import Field
|
||||
|
||||
from src.base_schema import BaseSchema
|
||||
|
||||
|
||||
class AuthSyncRequest(BaseSchema):
|
||||
"""
|
||||
Request payload for POST /auth/sync
|
||||
|
||||
The client sends this after obtaining an OIDC token from Authentik.
|
||||
The access_token is validated against Authentik's userinfo endpoint.
|
||||
"""
|
||||
|
||||
access_token: str = Field(
|
||||
...,
|
||||
description="OIDC access token from Authentik",
|
||||
)
|
||||
|
||||
|
||||
class RoleSchema(BaseSchema):
|
||||
"""Role information in domain:action format"""
|
||||
|
||||
name: str = Field(..., description="Role name (e.g., 'control-room:admin')")
|
||||
domain: str = Field(..., description="Permission domain (e.g., 'control-room')")
|
||||
action: str = Field(..., description="Permission action (e.g., 'admin')")
|
||||
|
||||
|
||||
class UserPreferencesSchema(BaseSchema):
|
||||
"""User preferences"""
|
||||
|
||||
theme: str = Field(default="system", description="Theme preference: system, light, dark")
|
||||
default_room: str = Field(default="front-hall", description="Default room for housekeeping")
|
||||
preferences_json: dict = Field(default_factory=dict, description="Extended preferences")
|
||||
|
||||
|
||||
class UserSchema(BaseSchema):
|
||||
"""User information returned from sync"""
|
||||
|
||||
id: uuid.UUID = Field(..., description="Internal user ID")
|
||||
authentik_id: uuid.UUID = Field(..., description="Authentik user ID")
|
||||
email: str = Field(..., description="User email")
|
||||
name: str = Field(..., description="Display name")
|
||||
avatar_url: Optional[str] = Field(None, description="Profile picture URL")
|
||||
created_at: datetime = Field(..., description="Account creation timestamp")
|
||||
last_login: Optional[datetime] = Field(None, description="Last login timestamp")
|
||||
|
||||
|
||||
class AuthSyncResponse(BaseSchema):
|
||||
"""
|
||||
Response from POST /auth/sync
|
||||
|
||||
Contains the synced user profile, roles, and preferences.
|
||||
"""
|
||||
|
||||
user: UserSchema = Field(..., description="User profile")
|
||||
roles: list[RoleSchema] = Field(..., description="User's permission roles")
|
||||
preferences: UserPreferencesSchema = Field(..., description="User preferences")
|
||||
is_new_user: bool = Field(..., description="True if user was just created")
|
||||
|
||||
|
||||
class TokenInfoSchema(BaseSchema):
|
||||
"""
|
||||
Token information from Authentik userinfo endpoint
|
||||
|
||||
This is what Authentik returns when validating an access token.
|
||||
"""
|
||||
|
||||
sub: str = Field(..., description="Subject (Authentik user ID)")
|
||||
email: str = Field(..., description="User email")
|
||||
name: Optional[str] = Field(None, description="Display name")
|
||||
preferred_username: Optional[str] = Field(None, description="Username")
|
||||
groups: list[str] = Field(default_factory=list, description="Group memberships")
|
||||
picture: Optional[str] = Field(None, description="Profile picture URL")
|
||||
|
||||
|
||||
class UserListItemSchema(BaseSchema):
|
||||
"""User item for list display"""
|
||||
|
||||
id: uuid.UUID = Field(..., description="Internal user ID")
|
||||
email: str = Field(..., description="User email")
|
||||
name: str = Field(..., description="Display name")
|
||||
avatar_url: Optional[str] = Field(None, description="Profile picture URL")
|
||||
created_at: datetime = Field(..., description="Account creation timestamp")
|
||||
last_login: Optional[datetime] = Field(None, description="Last login timestamp")
|
||||
roles: list[str] = Field(default_factory=list, description="Role names")
|
||||
|
||||
|
||||
class UsersListResponse(BaseSchema):
|
||||
"""Response from GET /auth/users"""
|
||||
|
||||
items: list[UserListItemSchema] = Field(..., description="List of users")
|
||||
total: int = Field(..., description="Total count of users")
|
||||
|
||||
|
||||
class BulkSyncResultSchema(BaseSchema):
|
||||
"""Result from bulk sync operation"""
|
||||
|
||||
created: int = Field(..., description="Number of users created")
|
||||
updated: int = Field(..., description="Number of users updated")
|
||||
failed: int = Field(..., description="Number of users that failed to sync")
|
||||
total_in_authentik: int = Field(..., description="Total users in Authentik")
|
||||
errors: list[str] = Field(default_factory=list, description="Error messages for failed syncs")
|
||||
@@ -0,0 +1,453 @@
|
||||
"""
|
||||
Authentication Service
|
||||
|
||||
Business logic for user synchronization from Authentik.
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from src.config import get_settings
|
||||
from src.logging_config import get_logger
|
||||
from src.db.models import User, Role, UserPreferences
|
||||
from src.auth.schemas import TokenInfoSchema, UserSchema, RoleSchema, UserPreferencesSchema, UserListItemSchema, BulkSyncResultSchema
|
||||
|
||||
logger = get_logger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class AuthService:
|
||||
"""
|
||||
Service for authentication and user synchronization
|
||||
|
||||
Handles:
|
||||
- Token validation via Authentik userinfo endpoint
|
||||
- User creation/update from OIDC claims
|
||||
- Role synchronization from Authentik groups
|
||||
"""
|
||||
|
||||
def __init__(self, session: AsyncSession):
|
||||
"""
|
||||
Initialize auth service
|
||||
|
||||
Args:
|
||||
session: Async database session
|
||||
"""
|
||||
self.session = session
|
||||
self.userinfo_url = f"{settings.authentik_url}/application/o/userinfo/"
|
||||
|
||||
async def validate_token(self, access_token: str) -> TokenInfoSchema:
|
||||
"""
|
||||
Validate access token via Authentik userinfo endpoint
|
||||
|
||||
Args:
|
||||
access_token: OIDC access token
|
||||
|
||||
Returns:
|
||||
Token info containing user claims
|
||||
|
||||
Raises:
|
||||
ValueError: If token is invalid or expired
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(
|
||||
self.userinfo_url,
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
)
|
||||
|
||||
if response.status_code == 401:
|
||||
raise ValueError("Invalid or expired token")
|
||||
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
logger.debug(f"Userinfo response: {data}")
|
||||
|
||||
return TokenInfoSchema(
|
||||
sub=data.get("sub"),
|
||||
email=data.get("email"),
|
||||
name=data.get("name") or data.get("preferred_username"),
|
||||
preferred_username=data.get("preferred_username"),
|
||||
groups=data.get("groups", []),
|
||||
picture=data.get("picture"),
|
||||
)
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.error(f"Authentik userinfo request failed: {e}")
|
||||
raise ValueError(f"Token validation failed: {e.response.status_code}")
|
||||
except httpx.RequestError as e:
|
||||
logger.error(f"Authentik userinfo request error: {e}")
|
||||
raise ValueError("Authentication service unavailable")
|
||||
|
||||
async def sync_user(self, token_info: TokenInfoSchema) -> tuple[User, bool]:
|
||||
"""
|
||||
Create or update user from OIDC token info
|
||||
|
||||
Args:
|
||||
token_info: Validated token information
|
||||
|
||||
Returns:
|
||||
Tuple of (User, is_new_user)
|
||||
"""
|
||||
authentik_id = uuid.UUID(token_info.sub)
|
||||
|
||||
# Try to find existing user
|
||||
stmt = (
|
||||
select(User)
|
||||
.options(selectinload(User.roles), selectinload(User.preferences))
|
||||
.where(User.authentik_id == authentik_id)
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
is_new = user is None
|
||||
|
||||
if is_new:
|
||||
# Create new user
|
||||
user = User(
|
||||
authentik_id=authentik_id,
|
||||
email=token_info.email,
|
||||
name=token_info.name or token_info.email,
|
||||
avatar_url=token_info.picture,
|
||||
last_login=datetime.now(timezone.utc),
|
||||
)
|
||||
self.session.add(user)
|
||||
await self.session.flush() # Get the user ID
|
||||
|
||||
# Create default preferences
|
||||
preferences = UserPreferences(user_id=user.id)
|
||||
self.session.add(preferences)
|
||||
|
||||
logger.info(f"Created new user: {token_info.email}")
|
||||
else:
|
||||
# Update existing user
|
||||
user.email = token_info.email
|
||||
user.name = token_info.name or token_info.email
|
||||
user.avatar_url = token_info.picture
|
||||
user.last_login = datetime.now(timezone.utc)
|
||||
|
||||
logger.info(f"Updated existing user: {token_info.email}")
|
||||
|
||||
await self.session.flush()
|
||||
return user, is_new
|
||||
|
||||
async def sync_roles(self, user: User, groups: list[str]) -> list[Role]:
|
||||
"""
|
||||
Synchronize user roles from Authentik groups
|
||||
|
||||
Maps Authentik groups (e.g., 'tatlock-control-room-admin')
|
||||
to application roles (e.g., 'control-room:admin').
|
||||
|
||||
Args:
|
||||
user: User to sync roles for
|
||||
groups: List of Authentik group names
|
||||
|
||||
Returns:
|
||||
List of synced Role objects
|
||||
"""
|
||||
# Get all roles that match the user's Authentik groups
|
||||
stmt = select(Role).where(Role.authentik_group.in_(groups))
|
||||
result = await self.session.execute(stmt)
|
||||
matching_roles = list(result.scalars().all())
|
||||
|
||||
# Clear existing roles and set new ones
|
||||
user.roles = matching_roles
|
||||
|
||||
role_names = [r.name for r in matching_roles]
|
||||
logger.info(f"Synced roles for {user.email}: {role_names}")
|
||||
|
||||
return matching_roles
|
||||
|
||||
def user_to_schema(self, user: User) -> UserSchema:
|
||||
"""Convert User model to schema"""
|
||||
return UserSchema(
|
||||
id=user.id,
|
||||
authentik_id=user.authentik_id,
|
||||
email=user.email,
|
||||
name=user.name,
|
||||
avatar_url=user.avatar_url,
|
||||
created_at=user.created_at,
|
||||
last_login=user.last_login,
|
||||
)
|
||||
|
||||
def roles_to_schema(self, roles: list[Role]) -> list[RoleSchema]:
|
||||
"""Convert Role models to schemas"""
|
||||
return [
|
||||
RoleSchema(name=r.name, domain=r.domain, action=r.action)
|
||||
for r in roles
|
||||
]
|
||||
|
||||
def preferences_to_schema(self, preferences: Optional[UserPreferences]) -> UserPreferencesSchema:
|
||||
"""Convert UserPreferences model to schema"""
|
||||
if preferences is None:
|
||||
return UserPreferencesSchema()
|
||||
|
||||
return UserPreferencesSchema(
|
||||
theme=preferences.theme,
|
||||
default_room=preferences.default_room,
|
||||
preferences_json=preferences.preferences_json or {},
|
||||
)
|
||||
|
||||
async def list_users(
|
||||
self,
|
||||
search: Optional[str] = None,
|
||||
offset: int = 0,
|
||||
limit: int = 50,
|
||||
) -> tuple[list[UserListItemSchema], int]:
|
||||
"""
|
||||
List all users with optional search and pagination
|
||||
|
||||
Args:
|
||||
search: Optional search query (matches name or email)
|
||||
offset: Number of records to skip
|
||||
limit: Maximum number of records to return
|
||||
|
||||
Returns:
|
||||
Tuple of (list of user schemas, total count)
|
||||
"""
|
||||
from sqlalchemy import func
|
||||
|
||||
# Base query with roles loaded
|
||||
base_query = select(User).options(selectinload(User.roles))
|
||||
|
||||
# Apply search filter if provided
|
||||
if search:
|
||||
search_filter = f"%{search}%"
|
||||
base_query = base_query.where(
|
||||
(User.name.ilike(search_filter)) | (User.email.ilike(search_filter))
|
||||
)
|
||||
|
||||
# Get total count
|
||||
count_query = select(func.count()).select_from(base_query.subquery())
|
||||
total_result = await self.session.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
# Apply pagination and ordering
|
||||
query = base_query.order_by(User.name).offset(offset).limit(limit)
|
||||
result = await self.session.execute(query)
|
||||
users = list(result.scalars().all())
|
||||
|
||||
# Convert to schemas
|
||||
items = [
|
||||
UserListItemSchema(
|
||||
id=user.id,
|
||||
email=user.email,
|
||||
name=user.name,
|
||||
avatar_url=user.avatar_url,
|
||||
created_at=user.created_at,
|
||||
last_login=user.last_login,
|
||||
roles=[role.name for role in user.roles],
|
||||
)
|
||||
for user in users
|
||||
]
|
||||
|
||||
return items, total
|
||||
|
||||
def _get_csrf_token(self, client: httpx.AsyncClient) -> str:
|
||||
"""Extract CSRF token from cookies"""
|
||||
for cookie in client.cookies.jar:
|
||||
if cookie.name == "authentik_csrf":
|
||||
return cookie.value
|
||||
return ""
|
||||
|
||||
async def _authentik_session_login(self, client: httpx.AsyncClient) -> None:
|
||||
"""
|
||||
Authenticate with Authentik using the flow API to establish a session
|
||||
|
||||
Authentik's flow API requires:
|
||||
1. Cookie persistence between requests
|
||||
2. X-authentik-CSRF header set to the authentik_csrf cookie value
|
||||
3. Multi-stage flow handling (identification -> password -> done)
|
||||
|
||||
Args:
|
||||
client: httpx client with cookie persistence
|
||||
|
||||
Raises:
|
||||
ValueError: If authentication fails
|
||||
"""
|
||||
flow_url = f"{settings.authentik_url}/api/v3/flows/executor/default-authentication-flow/"
|
||||
|
||||
# Step 1: Get the initial flow challenge (this sets the session and csrf cookies)
|
||||
resp = await client.get(flow_url, headers={"Accept": "application/json"})
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
logger.debug(f"Flow initial response: component={data.get('component')}, type={data.get('type')}")
|
||||
|
||||
# Get CSRF token for subsequent requests
|
||||
csrf_token = self._get_csrf_token(client)
|
||||
logger.debug(f"CSRF token obtained: {bool(csrf_token)}")
|
||||
|
||||
# Build headers with CSRF token
|
||||
headers = {
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
if csrf_token:
|
||||
headers["X-authentik-CSRF"] = csrf_token
|
||||
|
||||
# Step 2: Handle identification stage - submit username
|
||||
if data.get("component") == "ak-stage-identification":
|
||||
resp = await client.post(
|
||||
flow_url,
|
||||
json={"uid_field": settings.authentik_admin_user},
|
||||
headers=headers,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
logger.debug(f"After username: component={data.get('component')}, type={data.get('type')}")
|
||||
|
||||
# Update CSRF token (might change between stages)
|
||||
csrf_token = self._get_csrf_token(client)
|
||||
if csrf_token:
|
||||
headers["X-authentik-CSRF"] = csrf_token
|
||||
|
||||
# Step 3: Handle password stage if required
|
||||
if data.get("component") == "ak-stage-password":
|
||||
resp = await client.post(
|
||||
flow_url,
|
||||
json={"password": settings.authentik_admin_password},
|
||||
headers=headers,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
logger.debug(f"After password: component={data.get('component')}, type={data.get('type')}")
|
||||
|
||||
# Check for access denied
|
||||
if data.get("component") == "ak-stage-access-denied":
|
||||
raise ValueError("Authentik authentication failed: access denied")
|
||||
|
||||
# Check for redirect (successful auth)
|
||||
if data.get("type") == "redirect" or data.get("to"):
|
||||
logger.info("Successfully authenticated with Authentik via flow")
|
||||
return
|
||||
|
||||
# If we're still in identification stage, the username might be wrong
|
||||
if data.get("component") == "ak-stage-identification":
|
||||
response_errors = data.get("response_errors", {})
|
||||
raise ValueError(f"Authentication stuck at identification stage: {response_errors}")
|
||||
|
||||
logger.info(f"Authentik flow completed with component: {data.get('component')}")
|
||||
|
||||
async def bulk_sync_from_authentik(self) -> BulkSyncResultSchema:
|
||||
"""
|
||||
Fetch all users from Authentik admin API and sync to local database
|
||||
|
||||
Returns:
|
||||
BulkSyncResultSchema with counts of created/updated/failed users
|
||||
"""
|
||||
if not settings.authentik_admin_user or not settings.authentik_admin_password:
|
||||
raise ValueError("AUTHENTIK_ADMIN_USER and AUTHENTIK_ADMIN_PASSWORD must be configured")
|
||||
|
||||
created = 0
|
||||
updated = 0
|
||||
failed = 0
|
||||
errors = []
|
||||
total_in_authentik = 0
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
|
||||
# Authenticate with Authentik to get session
|
||||
await self._authentik_session_login(client)
|
||||
|
||||
# Fetch users from Authentik admin API using session
|
||||
response = await client.get(
|
||||
f"{settings.authentik_url}/api/v3/core/users/",
|
||||
params={"page_size": 500},
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
|
||||
if response.status_code == 401:
|
||||
raise ValueError("Authentik API token is invalid or expired")
|
||||
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
authentik_users = data.get("results", [])
|
||||
total_in_authentik = data.get("pagination", {}).get("count", len(authentik_users))
|
||||
|
||||
for auth_user in authentik_users:
|
||||
try:
|
||||
# Skip service accounts and inactive users
|
||||
if auth_user.get("type") == "service_account":
|
||||
continue
|
||||
if not auth_user.get("is_active", True):
|
||||
continue
|
||||
|
||||
# Extract user data from Authentik
|
||||
authentik_id = uuid.UUID(auth_user["pk"])
|
||||
email = auth_user.get("email") or f"{auth_user['username']}@local"
|
||||
name = auth_user.get("name") or auth_user.get("username", "Unknown")
|
||||
avatar_url = auth_user.get("avatar")
|
||||
|
||||
# Get user's groups for role mapping
|
||||
groups = []
|
||||
groups_summary = auth_user.get("groups_obj", [])
|
||||
for group in groups_summary:
|
||||
groups.append(group.get("name", ""))
|
||||
|
||||
# Check if user exists
|
||||
stmt = select(User).where(User.authentik_id == authentik_id)
|
||||
result = await self.session.execute(stmt)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user is None:
|
||||
# Create new user
|
||||
user = User(
|
||||
authentik_id=authentik_id,
|
||||
email=email,
|
||||
name=name,
|
||||
avatar_url=avatar_url,
|
||||
)
|
||||
self.session.add(user)
|
||||
await self.session.flush()
|
||||
|
||||
# Create default preferences
|
||||
preferences = UserPreferences(user_id=user.id)
|
||||
self.session.add(preferences)
|
||||
created += 1
|
||||
logger.info(f"Created user from Authentik: {email}")
|
||||
else:
|
||||
# Update existing user
|
||||
user.email = email
|
||||
user.name = name
|
||||
user.avatar_url = avatar_url
|
||||
updated += 1
|
||||
logger.info(f"Updated user from Authentik: {email}")
|
||||
|
||||
# Sync roles from groups
|
||||
await self.sync_roles(user, groups)
|
||||
|
||||
except Exception as e:
|
||||
failed += 1
|
||||
error_msg = f"Failed to sync user {auth_user.get('username', 'unknown')}: {str(e)}"
|
||||
errors.append(error_msg)
|
||||
logger.warning(error_msg)
|
||||
|
||||
# Commit all changes
|
||||
await self.session.commit()
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
raise ValueError(f"Authentik API error: {e.response.status_code}")
|
||||
except httpx.RequestError as e:
|
||||
raise ValueError(f"Failed to connect to Authentik: {str(e)}")
|
||||
|
||||
return BulkSyncResultSchema(
|
||||
created=created,
|
||||
updated=updated,
|
||||
failed=failed,
|
||||
total_in_authentik=total_in_authentik,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
|
||||
# Factory function for dependency injection
|
||||
def get_auth_service(session: AsyncSession) -> AuthService:
|
||||
"""Create AuthService instance with database session"""
|
||||
return AuthService(session)
|
||||
@@ -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
|
||||
@@ -0,0 +1,409 @@
|
||||
"""
|
||||
Home Assistant REST API Client
|
||||
|
||||
Provides interface to Home Assistant REST API for home automation control.
|
||||
Uses long-lived access token authentication.
|
||||
API Reference: https://developers.home-assistant.io/docs/api/rest/
|
||||
"""
|
||||
import httpx
|
||||
import json
|
||||
from typing import Optional, Dict, List, Any
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from src.logging_config import get_logger
|
||||
from src.config import get_settings
|
||||
|
||||
logger = get_logger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class HomeAssistantClient:
|
||||
"""
|
||||
HTTP client for Home Assistant REST API
|
||||
|
||||
Uses long-lived access token authentication via Bearer token.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: Optional[str] = None,
|
||||
token: Optional[str] = None,
|
||||
timeout: int = 30
|
||||
):
|
||||
"""
|
||||
Initialize Home Assistant client
|
||||
|
||||
Args:
|
||||
base_url: Home Assistant base URL (default from settings)
|
||||
token: Long-lived access token (default from settings)
|
||||
timeout: Request timeout in seconds
|
||||
"""
|
||||
self.base_url = (base_url or settings.homeassistant_url).rstrip("/")
|
||||
self.token = token or settings.homeassistant_token
|
||||
self.timeout = timeout
|
||||
|
||||
if not self.token:
|
||||
logger.warning("Home Assistant token not configured")
|
||||
|
||||
def _get_headers(self) -> Dict[str, str]:
|
||||
"""Get request headers with Bearer token authentication"""
|
||||
return {
|
||||
"Authorization": f"Bearer {self.token}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
# ========================================================================
|
||||
# Health & Discovery
|
||||
# ========================================================================
|
||||
|
||||
async def health_check(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Check Home Assistant API connectivity and get version info
|
||||
|
||||
HA Endpoint: GET /api/
|
||||
|
||||
Returns:
|
||||
Dict with connected status, platform name, and version
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.get(
|
||||
f"{self.base_url}/api/",
|
||||
headers=self._get_headers()
|
||||
)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
return {
|
||||
"status": "healthy",
|
||||
"connected": True,
|
||||
"platform": "home_assistant",
|
||||
"version": data.get("version", "unknown")
|
||||
}
|
||||
return {
|
||||
"status": "unhealthy",
|
||||
"connected": False,
|
||||
"platform": "home_assistant",
|
||||
"error": f"HTTP {response.status_code}"
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Home Assistant health check failed: {e}")
|
||||
return {
|
||||
"status": "unhealthy",
|
||||
"connected": False,
|
||||
"platform": "home_assistant",
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
async def get_states(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get all entity states
|
||||
|
||||
HA Endpoint: GET /api/states
|
||||
|
||||
Returns:
|
||||
List of all entity states
|
||||
"""
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.get(
|
||||
f"{self.base_url}/api/states",
|
||||
headers=self._get_headers()
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def get_state(self, entity_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get state of a specific entity
|
||||
|
||||
HA Endpoint: GET /api/states/<entity_id>
|
||||
|
||||
Args:
|
||||
entity_id: Entity ID (e.g., "light.living_room")
|
||||
|
||||
Returns:
|
||||
Entity state dict or None if not found
|
||||
"""
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.get(
|
||||
f"{self.base_url}/api/states/{entity_id}",
|
||||
headers=self._get_headers()
|
||||
)
|
||||
if response.status_code == 404:
|
||||
return None
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def get_config(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get Home Assistant configuration (includes areas)
|
||||
|
||||
HA Endpoint: GET /api/config
|
||||
|
||||
Returns:
|
||||
Configuration dict including components, location, etc.
|
||||
"""
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.get(
|
||||
f"{self.base_url}/api/config",
|
||||
headers=self._get_headers()
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
# ========================================================================
|
||||
# Device Control
|
||||
# ========================================================================
|
||||
|
||||
async def call_service(
|
||||
self,
|
||||
domain: str,
|
||||
service: str,
|
||||
entity_id: Optional[str] = None,
|
||||
service_data: Optional[Dict[str, Any]] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Call a Home Assistant service
|
||||
|
||||
HA Endpoint: POST /api/services/<domain>/<service>
|
||||
|
||||
Args:
|
||||
domain: Service domain (e.g., "light", "switch", "scene")
|
||||
service: Service name (e.g., "turn_on", "turn_off", "toggle")
|
||||
entity_id: Target entity ID (optional for some services)
|
||||
service_data: Additional service data/attributes
|
||||
|
||||
Returns:
|
||||
List of changed states
|
||||
"""
|
||||
payload = service_data.copy() if service_data else {}
|
||||
if entity_id:
|
||||
payload["entity_id"] = entity_id
|
||||
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.post(
|
||||
f"{self.base_url}/api/services/{domain}/{service}",
|
||||
headers=self._get_headers(),
|
||||
json=payload
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def turn_on(
|
||||
self,
|
||||
entity_id: str,
|
||||
**attributes
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Turn on an entity with optional attributes
|
||||
|
||||
Args:
|
||||
entity_id: Entity ID (e.g., "light.living_room")
|
||||
**attributes: Additional attributes (brightness, color_temp, etc.)
|
||||
|
||||
Returns:
|
||||
List of changed states
|
||||
"""
|
||||
domain = entity_id.split(".")[0]
|
||||
return await self.call_service(
|
||||
domain=domain,
|
||||
service="turn_on",
|
||||
entity_id=entity_id,
|
||||
service_data=attributes if attributes else None
|
||||
)
|
||||
|
||||
async def turn_off(self, entity_id: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Turn off an entity
|
||||
|
||||
Args:
|
||||
entity_id: Entity ID
|
||||
|
||||
Returns:
|
||||
List of changed states
|
||||
"""
|
||||
domain = entity_id.split(".")[0]
|
||||
return await self.call_service(
|
||||
domain=domain,
|
||||
service="turn_off",
|
||||
entity_id=entity_id
|
||||
)
|
||||
|
||||
async def toggle(self, entity_id: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Toggle an entity
|
||||
|
||||
Args:
|
||||
entity_id: Entity ID
|
||||
|
||||
Returns:
|
||||
List of changed states
|
||||
"""
|
||||
domain = entity_id.split(".")[0]
|
||||
return await self.call_service(
|
||||
domain=domain,
|
||||
service="toggle",
|
||||
entity_id=entity_id
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
# Scenes
|
||||
# ========================================================================
|
||||
|
||||
async def activate_scene(self, scene_id: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Activate a scene
|
||||
|
||||
Args:
|
||||
scene_id: Scene entity ID (e.g., "scene.movie_night")
|
||||
|
||||
Returns:
|
||||
List of changed states
|
||||
"""
|
||||
return await self.call_service(
|
||||
domain="scene",
|
||||
service="turn_on",
|
||||
entity_id=scene_id
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
# Scripts
|
||||
# ========================================================================
|
||||
|
||||
async def run_script(
|
||||
self,
|
||||
script_id: str,
|
||||
variables: Optional[Dict[str, Any]] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Execute a script with optional variables
|
||||
|
||||
Args:
|
||||
script_id: Script entity ID (e.g., "script.bedtime_routine")
|
||||
variables: Script variables
|
||||
|
||||
Returns:
|
||||
List of changed states
|
||||
"""
|
||||
service_data = {"variables": variables} if variables else None
|
||||
return await self.call_service(
|
||||
domain="script",
|
||||
service="turn_on",
|
||||
entity_id=script_id,
|
||||
service_data=service_data
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
# Automations
|
||||
# ========================================================================
|
||||
|
||||
async def enable_automation(self, automation_id: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Enable an automation
|
||||
|
||||
Args:
|
||||
automation_id: Automation entity ID
|
||||
|
||||
Returns:
|
||||
List of changed states
|
||||
"""
|
||||
return await self.call_service(
|
||||
domain="automation",
|
||||
service="turn_on",
|
||||
entity_id=automation_id
|
||||
)
|
||||
|
||||
async def disable_automation(self, automation_id: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Disable an automation
|
||||
|
||||
Args:
|
||||
automation_id: Automation entity ID
|
||||
|
||||
Returns:
|
||||
List of changed states
|
||||
"""
|
||||
return await self.call_service(
|
||||
domain="automation",
|
||||
service="turn_off",
|
||||
entity_id=automation_id
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
# History
|
||||
# ========================================================================
|
||||
|
||||
async def get_history(
|
||||
self,
|
||||
entity_id: str,
|
||||
hours: int = 24
|
||||
) -> List[List[Dict[str, Any]]]:
|
||||
"""
|
||||
Get state history for an entity
|
||||
|
||||
HA Endpoint: GET /api/history/period/<timestamp>
|
||||
|
||||
Args:
|
||||
entity_id: Entity ID to get history for
|
||||
hours: Number of hours of history (default 24)
|
||||
|
||||
Returns:
|
||||
List of state history entries
|
||||
"""
|
||||
start_time = datetime.now(timezone.utc) - timedelta(hours=hours)
|
||||
timestamp = start_time.isoformat()
|
||||
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.get(
|
||||
f"{self.base_url}/api/history/period/{timestamp}",
|
||||
headers=self._get_headers(),
|
||||
params={
|
||||
"filter_entity_id": entity_id,
|
||||
"minimal_response": "true"
|
||||
}
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
# ========================================================================
|
||||
# Areas (via template API)
|
||||
# ========================================================================
|
||||
|
||||
async def get_areas(self) -> List[Dict[str, str]]:
|
||||
"""
|
||||
Get all areas/rooms
|
||||
|
||||
Note: The REST API doesn't have a direct areas endpoint.
|
||||
This uses the template API to render area data.
|
||||
|
||||
HA Endpoint: POST /api/template
|
||||
|
||||
Returns:
|
||||
List of area dicts with id and name
|
||||
"""
|
||||
template = """
|
||||
{% set areas_list = [] %}
|
||||
{% for area in areas() %}
|
||||
{% set areas_list = areas_list + [{"id": area, "name": area_name(area)}] %}
|
||||
{% endfor %}
|
||||
{{ areas_list | tojson }}
|
||||
"""
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.post(
|
||||
f"{self.base_url}/api/template",
|
||||
headers=self._get_headers(),
|
||||
json={"template": template}
|
||||
)
|
||||
response.raise_for_status()
|
||||
# Response is rendered template as string
|
||||
return json.loads(response.text)
|
||||
|
||||
|
||||
# Singleton instance
|
||||
_homeassistant_client: Optional[HomeAssistantClient] = None
|
||||
|
||||
|
||||
def get_homeassistant_client() -> HomeAssistantClient:
|
||||
"""Get singleton Home Assistant client instance"""
|
||||
global _homeassistant_client
|
||||
if _homeassistant_client is None:
|
||||
_homeassistant_client = HomeAssistantClient()
|
||||
return _homeassistant_client
|
||||
+168
-112
@@ -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
|
||||
|
||||
+36
-40
@@ -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,26 +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
|
||||
)
|
||||
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 = ""
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Global application settings"""
|
||||
@@ -63,15 +46,13 @@ 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
|
||||
default_model: str = "mistral-tools:7b"
|
||||
agent_model: str = "gemma2:9b-instruct-q5_K_M" # Must support tool calling with ADK (~4GB VRAM)
|
||||
lightweight_models: str = "gemma3-tools:1b,phi3:mini"
|
||||
heavy_models: str = "mistral:7b,gemma2:9b,gemma3:12b,mixtral:8x7b"
|
||||
code_models: str = "codestral:latest,codegemma:latest"
|
||||
default_model: str = "mistral-nemo-large:latest"
|
||||
agent_model: str = "mistral-nemo-large:latest" # Must support tool calling with ADK (~4GB VRAM)
|
||||
code_models: str = "mistral-nemo-large:latest"
|
||||
# Previous config (gemma3:12b used ~10GB VRAM)
|
||||
# default_model: str = "gemma3:12b"
|
||||
# agent_model: str = "gemma3:12b"
|
||||
@@ -106,30 +87,44 @@ 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
|
||||
# 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
|
||||
|
||||
npm_url: str = NPM_URL
|
||||
npm_email: str = NPM_EMAIL
|
||||
npm_password: str = NPM_PASSWORD
|
||||
# Home Assistant Configuration
|
||||
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"
|
||||
# PostgreSQL Database
|
||||
postgres_host: str # Required - set POSTGRES_HOST in .env (e.g., localhost:5432)
|
||||
postgres_user: str = "core_api"
|
||||
postgres_password: str # Required - set POSTGRES_PASSWORD in .env
|
||||
postgres_database: str = "core_api"
|
||||
|
||||
@property
|
||||
def database_url(self) -> str:
|
||||
"""Construct database URL from components"""
|
||||
return f"postgresql://{self.postgres_user}:{self.postgres_password}@{self.postgres_host}/{self.postgres_database}"
|
||||
|
||||
# OIDC Authentication (Authentik)
|
||||
oidc_enabled: bool = False # Set to True to require authentication
|
||||
oidc_issuer: str = "https://auth.schweitz.net/application/o/core-api/"
|
||||
oidc_audience: str = "core-api"
|
||||
|
||||
# Authentik API (for token validation and user management)
|
||||
authentik_url: str = "http://192.168.86.149:9000" # Authentik base URL
|
||||
authentik_admin_user: str = "" # Admin username for API access
|
||||
authentik_admin_password: str = "" # Admin password for API access
|
||||
|
||||
@property
|
||||
def model_aliases(self) -> dict:
|
||||
"""Computed property for model aliases"""
|
||||
@@ -155,6 +150,7 @@ class Settings(BaseSettings):
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
case_sensitive = False
|
||||
extra = "ignore" # Ignore extra env vars not defined in Settings
|
||||
|
||||
|
||||
@lru_cache()
|
||||
|
||||
@@ -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)}"
|
||||
)
|
||||
@@ -10,10 +10,8 @@ from src.controllers.base import BaseController
|
||||
from src.config import get_settings
|
||||
from src.logging_config import get_logger
|
||||
from src.models.ollama_client import get_ollama_client
|
||||
from src.db import get_database
|
||||
|
||||
# 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 +50,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",
|
||||
"web_scraper": "/web-scraper/scrape",
|
||||
"infrastructure": "/infrastructure",
|
||||
"health": "/health",
|
||||
"health_full": "/health/full"
|
||||
}
|
||||
"docs": "/docs"
|
||||
}
|
||||
|
||||
@router.get(
|
||||
@@ -75,18 +60,15 @@ class HealthController(BaseController):
|
||||
)
|
||||
async def health_check():
|
||||
"""
|
||||
Simple health check endpoint for container orchestration
|
||||
Fast health check endpoint for container orchestration
|
||||
|
||||
Returns a 200 OK status when the service is running properly.
|
||||
Used by Docker, Kubernetes, and load balancers.
|
||||
Returns a 200 OK immediately if the service is running.
|
||||
Does NOT check backend connectivity (use /health/full for that).
|
||||
Used by Docker, Kubernetes, and load balancers for liveness probes.
|
||||
"""
|
||||
ollama_client = get_ollama_client()
|
||||
ollama_healthy = await ollama_client.health_check()
|
||||
|
||||
return {
|
||||
"status": "healthy",
|
||||
"version": settings.app_version,
|
||||
"ollama_connected": ollama_healthy
|
||||
"version": settings.app_version
|
||||
}
|
||||
|
||||
@router.get(
|
||||
@@ -147,12 +129,18 @@ 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
|
||||
# Check 2: Database connection
|
||||
database = get_database()
|
||||
db_healthy = False
|
||||
db_error = None
|
||||
|
||||
# Determine overall status (only Ollama required for core-api)
|
||||
is_healthy = ollama_healthy
|
||||
try:
|
||||
db_healthy = await database.health_check()
|
||||
except Exception as e:
|
||||
db_error = str(e)
|
||||
logger.warning(f"Database health check failed: {db_error}")
|
||||
|
||||
is_healthy = ollama_healthy and db_healthy
|
||||
|
||||
elapsed_ms = int((time.time() - start_time) * 1000)
|
||||
status_code = 200 if is_healthy else 503
|
||||
@@ -171,7 +159,10 @@ class HealthController(BaseController):
|
||||
},
|
||||
"error": ollama_error
|
||||
},
|
||||
"note": "AI agent functionality available in separate core-ai service (port 8086)"
|
||||
"database": {
|
||||
"status": "✅ healthy" if db_healthy else "❌ unhealthy",
|
||||
"error": db_error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,20 +208,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,
|
||||
|
||||
@@ -0,0 +1,748 @@
|
||||
"""
|
||||
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
|
||||
|
||||
from src.controllers.base import BaseController
|
||||
from src.clients.homeassistant_client import get_homeassistant_client
|
||||
from src.logging_config import get_logger
|
||||
from src.auth.oidc import get_admin_user
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
# ========================================================================
|
||||
# Pydantic Schemas
|
||||
# ========================================================================
|
||||
|
||||
class Device(BaseModel):
|
||||
"""Device/entity information"""
|
||||
entity_id: str
|
||||
name: str
|
||||
domain: str
|
||||
area: Optional[str] = None
|
||||
state: str
|
||||
attributes: Dict[str, Any] = {}
|
||||
last_changed: Optional[str] = None
|
||||
|
||||
|
||||
class DeviceListResponse(BaseModel):
|
||||
"""Response for device listing"""
|
||||
devices: List[Device]
|
||||
|
||||
|
||||
class DeviceDetailResponse(Device):
|
||||
"""Detailed device response"""
|
||||
pass
|
||||
|
||||
|
||||
class Area(BaseModel):
|
||||
"""Area/room information"""
|
||||
id: str
|
||||
name: str
|
||||
|
||||
|
||||
class AreaListResponse(BaseModel):
|
||||
"""Response for area listing"""
|
||||
areas: List[Area]
|
||||
|
||||
|
||||
class DeviceControlRequest(BaseModel):
|
||||
"""Request to control a device"""
|
||||
action: str = Field(..., description="Action: turn_on, turn_off, or toggle")
|
||||
brightness: Optional[int] = Field(None, ge=0, le=255)
|
||||
color_temp: Optional[int] = None
|
||||
rgb_color: Optional[List[int]] = None
|
||||
|
||||
class Config:
|
||||
extra = "allow" # Allow additional attributes
|
||||
|
||||
|
||||
class DeviceControlResponse(BaseModel):
|
||||
"""Response from device control"""
|
||||
success: bool
|
||||
entity_id: str
|
||||
new_state: Optional[str] = None
|
||||
message: str
|
||||
|
||||
|
||||
class Scene(BaseModel):
|
||||
"""Scene information"""
|
||||
id: str
|
||||
name: str
|
||||
|
||||
|
||||
class SceneListResponse(BaseModel):
|
||||
"""Response for scene listing"""
|
||||
scenes: List[Scene]
|
||||
|
||||
|
||||
class SceneActivateResponse(BaseModel):
|
||||
"""Response from scene activation"""
|
||||
success: bool
|
||||
scene_id: str
|
||||
message: str
|
||||
|
||||
|
||||
class Script(BaseModel):
|
||||
"""Script information"""
|
||||
id: str
|
||||
name: str
|
||||
|
||||
|
||||
class ScriptListResponse(BaseModel):
|
||||
"""Response for script listing"""
|
||||
scripts: List[Script]
|
||||
|
||||
|
||||
class ScriptRunRequest(BaseModel):
|
||||
"""Request to run a script"""
|
||||
variables: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class ScriptRunResponse(BaseModel):
|
||||
"""Response from script execution"""
|
||||
success: bool
|
||||
script_id: str
|
||||
message: str
|
||||
|
||||
|
||||
class Automation(BaseModel):
|
||||
"""Automation information"""
|
||||
id: str
|
||||
name: str
|
||||
enabled: bool
|
||||
|
||||
|
||||
class AutomationListResponse(BaseModel):
|
||||
"""Response for automation listing"""
|
||||
automations: List[Automation]
|
||||
|
||||
|
||||
class AutomationToggleRequest(BaseModel):
|
||||
"""Request to toggle automation"""
|
||||
enabled: bool
|
||||
|
||||
|
||||
class AutomationToggleResponse(BaseModel):
|
||||
"""Response from automation toggle"""
|
||||
success: bool
|
||||
automation_id: str
|
||||
enabled: bool
|
||||
message: str
|
||||
|
||||
|
||||
class HistoryEntry(BaseModel):
|
||||
"""Single history entry"""
|
||||
state: str
|
||||
timestamp: str
|
||||
attributes: Dict[str, Any] = {}
|
||||
|
||||
|
||||
class HistoryResponse(BaseModel):
|
||||
"""Response for history query"""
|
||||
entity_id: str
|
||||
history: List[HistoryEntry]
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
"""Health check response"""
|
||||
status: str
|
||||
connected: bool
|
||||
platform: str
|
||||
version: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class ErrorResponse(BaseModel):
|
||||
"""Standard error response"""
|
||||
error: bool = True
|
||||
code: str
|
||||
message: str
|
||||
|
||||
|
||||
# ========================================================================
|
||||
# Controller
|
||||
# ========================================================================
|
||||
|
||||
class HousekeepingController(BaseController):
|
||||
"""
|
||||
Controller for home automation operations
|
||||
|
||||
Provides endpoints for:
|
||||
- Device discovery and control
|
||||
- Scene activation
|
||||
- Script execution
|
||||
- Automation management
|
||||
- State history
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(prefix="/housekeeping", tags=["Housekeeping"])
|
||||
|
||||
def create_router(self) -> APIRouter:
|
||||
"""Create and configure the router"""
|
||||
router = APIRouter(prefix=self.prefix, tags=self.tags)
|
||||
|
||||
# ====================================================================
|
||||
# Health
|
||||
# ====================================================================
|
||||
|
||||
@router.get(
|
||||
"/health",
|
||||
response_model=HealthResponse,
|
||||
summary="Home automation health check"
|
||||
)
|
||||
async def get_health():
|
||||
"""
|
||||
Check Home Assistant connection health
|
||||
|
||||
Returns connection status and HA version.
|
||||
"""
|
||||
ha = get_homeassistant_client()
|
||||
return await ha.health_check()
|
||||
|
||||
# ====================================================================
|
||||
# Device Discovery
|
||||
# ====================================================================
|
||||
|
||||
@router.get(
|
||||
"/devices",
|
||||
response_model=DeviceListResponse,
|
||||
summary="List available devices"
|
||||
)
|
||||
async def list_devices(
|
||||
domain: Optional[str] = Query(None, description="Filter by domain (light, switch, climate, etc.)"),
|
||||
area: Optional[str] = Query(None, description="Filter by area/room name")
|
||||
):
|
||||
"""
|
||||
List all available devices with optional filtering
|
||||
|
||||
Query Parameters:
|
||||
- domain: Filter by device type (light, switch, climate, media_player, etc.)
|
||||
- area: Filter by area/room name
|
||||
"""
|
||||
ha = get_homeassistant_client()
|
||||
|
||||
try:
|
||||
states = await ha.get_states()
|
||||
|
||||
# Non-controllable domains to filter out
|
||||
excluded_domains = {
|
||||
"zone", "person", "device_tracker", "sun", "weather",
|
||||
"persistent_notification", "update", "binary_sensor", "sensor",
|
||||
"conversation", "calendar", "button", "number", "select",
|
||||
"text", "time", "date", "datetime", "image", "tts", "stt"
|
||||
}
|
||||
|
||||
devices = []
|
||||
for state in states:
|
||||
entity_id = state.get("entity_id", "")
|
||||
entity_domain = entity_id.split(".")[0] if "." in entity_id else ""
|
||||
|
||||
# Skip non-controllable entities
|
||||
if entity_domain in excluded_domains:
|
||||
continue
|
||||
|
||||
# Apply domain filter
|
||||
if domain and entity_domain != domain:
|
||||
continue
|
||||
|
||||
# Get area from attributes
|
||||
device_area = state.get("attributes", {}).get("area_id")
|
||||
|
||||
# Apply area filter
|
||||
if area and device_area and area.lower() not in device_area.lower():
|
||||
continue
|
||||
|
||||
device = Device(
|
||||
entity_id=entity_id,
|
||||
name=state.get("attributes", {}).get("friendly_name", entity_id),
|
||||
domain=entity_domain,
|
||||
area=device_area,
|
||||
state=state.get("state", "unknown"),
|
||||
attributes=state.get("attributes", {}),
|
||||
last_changed=state.get("last_changed")
|
||||
)
|
||||
devices.append(device)
|
||||
|
||||
return DeviceListResponse(devices=devices)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list devices: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail={"error": True, "code": "CONNECTION_ERROR", "message": str(e)}
|
||||
)
|
||||
|
||||
@router.get(
|
||||
"/devices/{entity_id:path}",
|
||||
response_model=DeviceDetailResponse,
|
||||
responses={404: {"model": ErrorResponse}}
|
||||
)
|
||||
async def get_device(entity_id: str):
|
||||
"""
|
||||
Get detailed state of a specific device
|
||||
|
||||
Args:
|
||||
entity_id: Entity ID (e.g., light.living_room)
|
||||
"""
|
||||
ha = get_homeassistant_client()
|
||||
|
||||
try:
|
||||
state = await ha.get_state(entity_id)
|
||||
|
||||
if not state:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail={"error": True, "code": "DEVICE_NOT_FOUND",
|
||||
"message": f"Device {entity_id} not found"}
|
||||
)
|
||||
|
||||
entity_domain = entity_id.split(".")[0] if "." in entity_id else ""
|
||||
|
||||
return DeviceDetailResponse(
|
||||
entity_id=entity_id,
|
||||
name=state.get("attributes", {}).get("friendly_name", entity_id),
|
||||
domain=entity_domain,
|
||||
area=state.get("attributes", {}).get("area_id"),
|
||||
state=state.get("state", "unknown"),
|
||||
attributes=state.get("attributes", {}),
|
||||
last_changed=state.get("last_changed")
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get device {entity_id}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail={"error": True, "code": "CONNECTION_ERROR", "message": str(e)}
|
||||
)
|
||||
|
||||
@router.get(
|
||||
"/areas",
|
||||
response_model=AreaListResponse,
|
||||
summary="List areas/rooms"
|
||||
)
|
||||
async def list_areas():
|
||||
"""
|
||||
List all configured areas/rooms in Home Assistant
|
||||
"""
|
||||
ha = get_homeassistant_client()
|
||||
|
||||
try:
|
||||
areas = await ha.get_areas()
|
||||
return AreaListResponse(
|
||||
areas=[Area(id=a["id"], name=a["name"]) for a in areas]
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list areas: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail={"error": True, "code": "CONNECTION_ERROR", "message": str(e)}
|
||||
)
|
||||
|
||||
# ====================================================================
|
||||
# Device Control
|
||||
# ====================================================================
|
||||
|
||||
@router.post(
|
||||
"/devices/{entity_id:path}/control",
|
||||
response_model=DeviceControlResponse,
|
||||
responses={404: {"model": ErrorResponse}, 400: {"model": ErrorResponse}}
|
||||
)
|
||||
async def control_device(
|
||||
entity_id: str,
|
||||
request: DeviceControlRequest,
|
||||
user: Dict = Depends(get_admin_user)
|
||||
):
|
||||
"""
|
||||
Control a device (turn on, turn off, toggle, or set attributes)
|
||||
|
||||
Args:
|
||||
entity_id: Entity ID (e.g., light.living_room)
|
||||
request: Control request with action and optional attributes
|
||||
"""
|
||||
ha = get_homeassistant_client()
|
||||
|
||||
# Validate action
|
||||
valid_actions = ["turn_on", "turn_off", "toggle"]
|
||||
if request.action not in valid_actions:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={"error": True, "code": "INVALID_ACTION",
|
||||
"message": f"Invalid action '{request.action}'. Must be one of: {', '.join(valid_actions)}"}
|
||||
)
|
||||
|
||||
try:
|
||||
# Check device exists first
|
||||
current_state = await ha.get_state(entity_id)
|
||||
if not current_state:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail={"error": True, "code": "DEVICE_NOT_FOUND",
|
||||
"message": f"Device {entity_id} not found"}
|
||||
)
|
||||
|
||||
# Build attributes dict from request
|
||||
attributes = {}
|
||||
if request.brightness is not None:
|
||||
attributes["brightness"] = request.brightness
|
||||
if request.color_temp is not None:
|
||||
attributes["color_temp"] = request.color_temp
|
||||
if request.rgb_color is not None:
|
||||
attributes["rgb_color"] = request.rgb_color
|
||||
|
||||
# Add any extra attributes from request
|
||||
extra_fields = request.model_dump(exclude={"action", "brightness", "color_temp", "rgb_color"})
|
||||
for key, value in extra_fields.items():
|
||||
if value is not None:
|
||||
attributes[key] = value
|
||||
|
||||
# Execute action
|
||||
if request.action == "turn_on":
|
||||
await ha.turn_on(entity_id, **attributes)
|
||||
elif request.action == "turn_off":
|
||||
await ha.turn_off(entity_id)
|
||||
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)
|
||||
|
||||
logger.info(f"Device {entity_id} controlled: {request.action} by {user.get('preferred_username', 'unknown')}")
|
||||
|
||||
return DeviceControlResponse(
|
||||
success=True,
|
||||
entity_id=entity_id,
|
||||
new_state=new_state.get("state") if new_state else None,
|
||||
message=f"Device {request.action} successful"
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to control device {entity_id}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail={"error": True, "code": "CONNECTION_ERROR", "message": str(e)}
|
||||
)
|
||||
|
||||
# ====================================================================
|
||||
# Scenes
|
||||
# ====================================================================
|
||||
|
||||
@router.get(
|
||||
"/scenes",
|
||||
response_model=SceneListResponse,
|
||||
summary="List available scenes"
|
||||
)
|
||||
async def list_scenes():
|
||||
"""
|
||||
List all available scenes in Home Assistant
|
||||
"""
|
||||
ha = get_homeassistant_client()
|
||||
|
||||
try:
|
||||
states = await ha.get_states()
|
||||
|
||||
scenes = [
|
||||
Scene(
|
||||
id=s["entity_id"],
|
||||
name=s.get("attributes", {}).get("friendly_name", s["entity_id"])
|
||||
)
|
||||
for s in states
|
||||
if s["entity_id"].startswith("scene.")
|
||||
]
|
||||
|
||||
return SceneListResponse(scenes=scenes)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list scenes: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail={"error": True, "code": "CONNECTION_ERROR", "message": str(e)}
|
||||
)
|
||||
|
||||
@router.post(
|
||||
"/scenes/{scene_id:path}/activate",
|
||||
response_model=SceneActivateResponse,
|
||||
responses={404: {"model": ErrorResponse}}
|
||||
)
|
||||
async def activate_scene(
|
||||
scene_id: str,
|
||||
user: Dict = Depends(get_admin_user)
|
||||
):
|
||||
"""
|
||||
Activate a scene
|
||||
|
||||
Args:
|
||||
scene_id: Scene entity ID (e.g., scene.movie_night)
|
||||
"""
|
||||
ha = get_homeassistant_client()
|
||||
|
||||
try:
|
||||
# Verify scene exists
|
||||
state = await ha.get_state(scene_id)
|
||||
if not state:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail={"error": True, "code": "SCENE_NOT_FOUND",
|
||||
"message": f"Scene {scene_id} not found"}
|
||||
)
|
||||
|
||||
await ha.activate_scene(scene_id)
|
||||
|
||||
logger.info(f"Scene {scene_id} activated by {user.get('preferred_username', 'unknown')}")
|
||||
|
||||
return SceneActivateResponse(
|
||||
success=True,
|
||||
scene_id=scene_id,
|
||||
message="Scene activated"
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to activate scene {scene_id}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail={"error": True, "code": "CONNECTION_ERROR", "message": str(e)}
|
||||
)
|
||||
|
||||
# ====================================================================
|
||||
# Scripts
|
||||
# ====================================================================
|
||||
|
||||
@router.get(
|
||||
"/scripts",
|
||||
response_model=ScriptListResponse,
|
||||
summary="List available scripts"
|
||||
)
|
||||
async def list_scripts():
|
||||
"""
|
||||
List all available scripts/sequences in Home Assistant
|
||||
"""
|
||||
ha = get_homeassistant_client()
|
||||
|
||||
try:
|
||||
states = await ha.get_states()
|
||||
|
||||
scripts = [
|
||||
Script(
|
||||
id=s["entity_id"],
|
||||
name=s.get("attributes", {}).get("friendly_name", s["entity_id"])
|
||||
)
|
||||
for s in states
|
||||
if s["entity_id"].startswith("script.")
|
||||
]
|
||||
|
||||
return ScriptListResponse(scripts=scripts)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list scripts: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail={"error": True, "code": "CONNECTION_ERROR", "message": str(e)}
|
||||
)
|
||||
|
||||
@router.post(
|
||||
"/scripts/{script_id:path}/run",
|
||||
response_model=ScriptRunResponse,
|
||||
responses={404: {"model": ErrorResponse}}
|
||||
)
|
||||
async def run_script(
|
||||
script_id: str,
|
||||
request: Optional[ScriptRunRequest] = None,
|
||||
user: Dict = Depends(get_admin_user)
|
||||
):
|
||||
"""
|
||||
Execute a script with optional variables
|
||||
|
||||
Args:
|
||||
script_id: Script entity ID (e.g., script.bedtime_routine)
|
||||
request: Optional variables for the script
|
||||
"""
|
||||
ha = get_homeassistant_client()
|
||||
|
||||
try:
|
||||
# Verify script exists
|
||||
state = await ha.get_state(script_id)
|
||||
if not state:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail={"error": True, "code": "SCRIPT_NOT_FOUND",
|
||||
"message": f"Script {script_id} not found"}
|
||||
)
|
||||
|
||||
variables = request.variables if request else None
|
||||
await ha.run_script(script_id, variables)
|
||||
|
||||
logger.info(f"Script {script_id} executed by {user.get('preferred_username', 'unknown')}")
|
||||
|
||||
return ScriptRunResponse(
|
||||
success=True,
|
||||
script_id=script_id,
|
||||
message="Script executed"
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to run script {script_id}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail={"error": True, "code": "CONNECTION_ERROR", "message": str(e)}
|
||||
)
|
||||
|
||||
# ====================================================================
|
||||
# Automations
|
||||
# ====================================================================
|
||||
|
||||
@router.get(
|
||||
"/automations",
|
||||
response_model=AutomationListResponse,
|
||||
summary="List automations"
|
||||
)
|
||||
async def list_automations():
|
||||
"""
|
||||
List all automations with their enabled/disabled status
|
||||
"""
|
||||
ha = get_homeassistant_client()
|
||||
|
||||
try:
|
||||
states = await ha.get_states()
|
||||
|
||||
automations = [
|
||||
Automation(
|
||||
id=s["entity_id"],
|
||||
name=s.get("attributes", {}).get("friendly_name", s["entity_id"]),
|
||||
enabled=s.get("state") == "on"
|
||||
)
|
||||
for s in states
|
||||
if s["entity_id"].startswith("automation.")
|
||||
]
|
||||
|
||||
return AutomationListResponse(automations=automations)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list automations: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail={"error": True, "code": "CONNECTION_ERROR", "message": str(e)}
|
||||
)
|
||||
|
||||
@router.post(
|
||||
"/automations/{automation_id:path}/toggle",
|
||||
response_model=AutomationToggleResponse,
|
||||
responses={404: {"model": ErrorResponse}}
|
||||
)
|
||||
async def toggle_automation(
|
||||
automation_id: str,
|
||||
request: AutomationToggleRequest,
|
||||
user: Dict = Depends(get_admin_user)
|
||||
):
|
||||
"""
|
||||
Enable or disable an automation
|
||||
|
||||
Args:
|
||||
automation_id: Automation entity ID (e.g., automation.motion_lights)
|
||||
request: Contains enabled boolean
|
||||
"""
|
||||
ha = get_homeassistant_client()
|
||||
|
||||
try:
|
||||
# Verify automation exists
|
||||
state = await ha.get_state(automation_id)
|
||||
if not state:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail={"error": True, "code": "AUTOMATION_NOT_FOUND",
|
||||
"message": f"Automation {automation_id} not found"}
|
||||
)
|
||||
|
||||
if request.enabled:
|
||||
await ha.enable_automation(automation_id)
|
||||
else:
|
||||
await ha.disable_automation(automation_id)
|
||||
|
||||
logger.info(f"Automation {automation_id} {'enabled' if request.enabled else 'disabled'} by {user.get('preferred_username', 'unknown')}")
|
||||
|
||||
return AutomationToggleResponse(
|
||||
success=True,
|
||||
automation_id=automation_id,
|
||||
enabled=request.enabled,
|
||||
message=f"Automation {'enabled' if request.enabled else 'disabled'}"
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to toggle automation {automation_id}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail={"error": True, "code": "CONNECTION_ERROR", "message": str(e)}
|
||||
)
|
||||
|
||||
# ====================================================================
|
||||
# History
|
||||
# ====================================================================
|
||||
|
||||
@router.get(
|
||||
"/history",
|
||||
response_model=HistoryResponse,
|
||||
responses={400: {"model": ErrorResponse}}
|
||||
)
|
||||
async def get_history(
|
||||
entity_id: str = Query(..., description="Entity ID to get history for"),
|
||||
hours: int = Query(24, ge=1, le=168, description="Hours of history (1-168)")
|
||||
):
|
||||
"""
|
||||
Get state history for a device
|
||||
|
||||
Query Parameters:
|
||||
- entity_id: Device entity ID (required)
|
||||
- hours: Number of hours of history (default 24, max 168/1 week)
|
||||
"""
|
||||
ha = get_homeassistant_client()
|
||||
|
||||
try:
|
||||
history_data = await ha.get_history(entity_id, hours)
|
||||
|
||||
# Transform HA history format to our format
|
||||
history_entries = []
|
||||
if history_data and len(history_data) > 0:
|
||||
for entry in history_data[0]: # First array is our entity
|
||||
history_entries.append(HistoryEntry(
|
||||
state=entry.get("state", "unknown"),
|
||||
timestamp=entry.get("last_changed", ""),
|
||||
attributes=entry.get("attributes", {})
|
||||
))
|
||||
|
||||
return HistoryResponse(
|
||||
entity_id=entity_id,
|
||||
history=history_entries
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get history for {entity_id}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail={"error": True, "code": "CONNECTION_ERROR", "message": str(e)}
|
||||
)
|
||||
|
||||
return router
|
||||
|
||||
|
||||
# Create controller instance
|
||||
housekeeping_controller = HousekeepingController()
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -2,16 +2,12 @@
|
||||
Tools Controller
|
||||
|
||||
Provides utility tool endpoints including:
|
||||
- Web scraping and content extraction
|
||||
- DNS lookups
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
|
||||
from src.controllers.base import BaseController
|
||||
from src.logging_config import get_logger
|
||||
from src.web_scraper.schemas import WebScraperRequest, WebScraperResponse
|
||||
from src.web_scraper.service import WebScraperService
|
||||
from src.web_scraper.exceptions import FetchError, ScrapingError
|
||||
from src.dns.schemas import DNSLookupRequest, DNSLookupResponse
|
||||
from src.dns.service import DNSService
|
||||
from src.dns.exceptions import DNSQueryError
|
||||
@@ -24,79 +20,17 @@ class ToolsController(BaseController):
|
||||
Controller for utility tools
|
||||
|
||||
Provides endpoints for:
|
||||
- Web scraping and content extraction
|
||||
- DNS lookups
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(prefix="/tools", tags=["Tools"])
|
||||
# Initialize services (could be dependency injected for testing)
|
||||
self.scraper_service = WebScraperService()
|
||||
self.dns_service = DNSService()
|
||||
|
||||
def create_router(self) -> APIRouter:
|
||||
"""Create and configure the router"""
|
||||
router = APIRouter(prefix=self.prefix, tags=self.tags)
|
||||
|
||||
@router.post(
|
||||
"/scrape",
|
||||
response_model=WebScraperResponse,
|
||||
status_code=status.HTTP_200_OK,
|
||||
summary="Scrape website content",
|
||||
description="""
|
||||
Scrape and extract main content from a website.
|
||||
|
||||
Uses trafilatura for intelligent content extraction (articles, blog posts, documentation),
|
||||
with BeautifulSoup as fallback. Perfect for feeding webpage content to LLMs.
|
||||
|
||||
**Features:**
|
||||
- Intelligent main content extraction
|
||||
- Removes navigation, ads, footers
|
||||
- Optional link extraction
|
||||
- Configurable content length limits
|
||||
|
||||
**Rate Limiting:** None (internal network use only)
|
||||
"""
|
||||
)
|
||||
async def scrape_website(request: WebScraperRequest) -> WebScraperResponse:
|
||||
"""
|
||||
Scrape a website and extract its main content
|
||||
|
||||
Args:
|
||||
request: Scraping request with URL and options
|
||||
|
||||
Returns:
|
||||
Extracted content with metadata
|
||||
|
||||
Raises:
|
||||
HTTPException: 400 for fetch errors, 500 for processing errors
|
||||
"""
|
||||
try:
|
||||
logger.info(f"Received scrape request for: {request.url}")
|
||||
result = await self.scraper_service.scrape_url(request)
|
||||
return result
|
||||
|
||||
except FetchError as e:
|
||||
logger.warning(f"Fetch failed: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Failed to fetch URL: {str(e)}"
|
||||
)
|
||||
|
||||
except ScrapingError as e:
|
||||
logger.error(f"Scraping failed: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to extract content: {str(e)}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error: {str(e)}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="An unexpected error occurred"
|
||||
)
|
||||
|
||||
@router.post(
|
||||
"/dns/lookup",
|
||||
response_model=DNSLookupResponse,
|
||||
|
||||
@@ -1,19 +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"
|
||||
@@ -0,0 +1,16 @@
|
||||
"""
|
||||
Database package for Core-API
|
||||
|
||||
Provides async PostgreSQL database connectivity using SQLAlchemy 2.0.
|
||||
"""
|
||||
from src.db.database import (
|
||||
get_async_session,
|
||||
get_database,
|
||||
Database,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"get_async_session",
|
||||
"get_database",
|
||||
"Database",
|
||||
]
|
||||
@@ -0,0 +1,179 @@
|
||||
"""
|
||||
Database Connection Module
|
||||
|
||||
Provides async PostgreSQL connectivity using SQLAlchemy 2.0 with asyncpg driver.
|
||||
Follows the existing singleton pattern used throughout core-api.
|
||||
"""
|
||||
from typing import AsyncGenerator, Optional
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncSession,
|
||||
AsyncEngine,
|
||||
create_async_engine,
|
||||
async_sessionmaker,
|
||||
)
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from src.config import get_settings
|
||||
from src.logging_config import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""
|
||||
SQLAlchemy declarative base for all models
|
||||
|
||||
All database models should inherit from this class.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class Database:
|
||||
"""
|
||||
Async database connection manager
|
||||
|
||||
Provides async engine and session factory for PostgreSQL connections.
|
||||
Uses asyncpg driver for optimal async performance.
|
||||
"""
|
||||
|
||||
def __init__(self, database_url: Optional[str] = None):
|
||||
"""
|
||||
Initialize database connection manager
|
||||
|
||||
Args:
|
||||
database_url: PostgreSQL connection URL (default from settings)
|
||||
"""
|
||||
# Convert postgresql:// to postgresql+asyncpg:// for async driver
|
||||
url = database_url or settings.database_url
|
||||
if url.startswith("postgresql://"):
|
||||
url = url.replace("postgresql://", "postgresql+asyncpg://", 1)
|
||||
|
||||
self._url = url
|
||||
self._engine: Optional[AsyncEngine] = None
|
||||
self._session_factory: Optional[async_sessionmaker[AsyncSession]] = None
|
||||
|
||||
@property
|
||||
def engine(self) -> AsyncEngine:
|
||||
"""
|
||||
Get or create the async database engine
|
||||
|
||||
Returns:
|
||||
AsyncEngine instance
|
||||
"""
|
||||
if self._engine is None:
|
||||
self._engine = create_async_engine(
|
||||
self._url,
|
||||
echo=settings.debug, # Log SQL in debug mode
|
||||
poolclass=NullPool, # Disable connection pooling for serverless compatibility
|
||||
)
|
||||
logger.info(f"Database engine created for {self._url.split('@')[-1]}")
|
||||
return self._engine
|
||||
|
||||
@property
|
||||
def session_factory(self) -> async_sessionmaker[AsyncSession]:
|
||||
"""
|
||||
Get or create the async session factory
|
||||
|
||||
Returns:
|
||||
Session factory for creating database sessions
|
||||
"""
|
||||
if self._session_factory is None:
|
||||
self._session_factory = async_sessionmaker(
|
||||
bind=self.engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
autocommit=False,
|
||||
autoflush=False,
|
||||
)
|
||||
return self._session_factory
|
||||
|
||||
async def create_tables(self) -> None:
|
||||
"""
|
||||
Create all database tables
|
||||
|
||||
Should only be used for development/testing.
|
||||
Use Alembic migrations for production.
|
||||
"""
|
||||
async with self.engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
logger.info("Database tables created")
|
||||
|
||||
async def drop_tables(self) -> None:
|
||||
"""
|
||||
Drop all database tables
|
||||
|
||||
WARNING: Destroys all data. Use with caution.
|
||||
"""
|
||||
async with self.engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
logger.warning("Database tables dropped")
|
||||
|
||||
async def health_check(self) -> bool:
|
||||
"""
|
||||
Check if database connection is healthy
|
||||
|
||||
Returns:
|
||||
True if connection successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
async with self.session_factory() as session:
|
||||
await session.execute(text("SELECT 1"))
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Database health check failed: {e}")
|
||||
return False
|
||||
|
||||
async def close(self) -> None:
|
||||
"""
|
||||
Close database connections and dispose of engine
|
||||
"""
|
||||
if self._engine is not None:
|
||||
await self._engine.dispose()
|
||||
self._engine = None
|
||||
self._session_factory = None
|
||||
logger.info("Database connections closed")
|
||||
|
||||
|
||||
# Singleton instance
|
||||
_database: Optional[Database] = None
|
||||
|
||||
|
||||
def get_database() -> Database:
|
||||
"""
|
||||
Get singleton database instance
|
||||
|
||||
Returns:
|
||||
Database instance
|
||||
"""
|
||||
global _database
|
||||
if _database is None:
|
||||
_database = Database()
|
||||
return _database
|
||||
|
||||
|
||||
async def get_async_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""
|
||||
FastAPI dependency for database sessions
|
||||
|
||||
Yields an async session that is automatically closed after the request.
|
||||
|
||||
Usage:
|
||||
@router.get("/items")
|
||||
async def get_items(session: AsyncSession = Depends(get_async_session)):
|
||||
result = await session.execute(select(Item))
|
||||
return result.scalars().all()
|
||||
|
||||
Yields:
|
||||
AsyncSession instance
|
||||
"""
|
||||
database = get_database()
|
||||
async with database.session_factory() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
@@ -0,0 +1,17 @@
|
||||
"""
|
||||
SQLAlchemy Models for Core-API
|
||||
|
||||
Database models for authentication, authorization, and user management.
|
||||
"""
|
||||
from src.db.models.user import User
|
||||
from src.db.models.role import Role, UserRole
|
||||
from src.db.models.user_preferences import UserPreferences
|
||||
from src.db.models.api_key import ApiKey
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
"Role",
|
||||
"UserRole",
|
||||
"UserPreferences",
|
||||
"ApiKey",
|
||||
]
|
||||
@@ -0,0 +1,98 @@
|
||||
"""
|
||||
API Key Model
|
||||
|
||||
Provides API key authentication as fallback for OIDC.
|
||||
Keys are tied to user accounts and inherit user permissions.
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import String, ForeignKey, DateTime, func
|
||||
from sqlalchemy.dialects.postgresql import UUID, ARRAY
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from src.db.database import Base
|
||||
|
||||
from typing import TYPE_CHECKING, List
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.db.models.user import User
|
||||
|
||||
|
||||
class ApiKey(Base):
|
||||
"""
|
||||
API Key model for programmatic access
|
||||
|
||||
API keys provide an alternative to OIDC for:
|
||||
- Local development without SSO
|
||||
- Service-to-service communication
|
||||
- Scripts and automation
|
||||
|
||||
Keys inherit the user's roles but can optionally
|
||||
be restricted to a subset of scopes.
|
||||
"""
|
||||
|
||||
__tablename__ = "api_keys"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4,
|
||||
)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
name: Mapped[str] = mapped_column(
|
||||
String(100),
|
||||
nullable=False,
|
||||
comment="Human-readable key name (e.g., 'Dev Laptop', 'CI/CD')",
|
||||
)
|
||||
key_hash: Mapped[str] = mapped_column(
|
||||
String(255),
|
||||
nullable=False,
|
||||
comment="SHA-256 hash of the API key",
|
||||
)
|
||||
key_prefix: Mapped[str] = mapped_column(
|
||||
String(8),
|
||||
nullable=False,
|
||||
comment="First 8 chars of key for identification (e.g., 'cak_abc1')",
|
||||
)
|
||||
scopes: Mapped[List[str] | None] = mapped_column(
|
||||
ARRAY(String),
|
||||
nullable=True,
|
||||
comment="Optional scope restriction (subset of user roles)",
|
||||
)
|
||||
expires_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
comment="Optional expiration timestamp",
|
||||
)
|
||||
last_used_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
comment="Last time this key was used",
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# Relationships
|
||||
user: Mapped["User"] = relationship(
|
||||
"User",
|
||||
back_populates="api_keys",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<ApiKey {self.key_prefix}... ({self.name})>"
|
||||
|
||||
@property
|
||||
def is_expired(self) -> bool:
|
||||
"""Check if the API key has expired"""
|
||||
if self.expires_at is None:
|
||||
return False
|
||||
return datetime.now(self.expires_at.tzinfo) > self.expires_at
|
||||
@@ -0,0 +1,93 @@
|
||||
"""
|
||||
Role Models
|
||||
|
||||
Defines domain-scoped permissions mapped from Authentik groups.
|
||||
Format: {domain}:{action} (e.g., control-room:admin, media:viewer)
|
||||
"""
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING, List
|
||||
|
||||
from sqlalchemy import String, ForeignKey
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from src.db.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.db.models.user import User
|
||||
|
||||
|
||||
class Role(Base):
|
||||
"""
|
||||
Role model for domain-scoped permissions
|
||||
|
||||
Roles are seeded from configuration, not user-editable.
|
||||
Each role maps to an Authentik group (e.g., tatlock-control-room-admin).
|
||||
|
||||
Domains: control-room, library, media, ai, housekeeper, developer, documents, gaming, admin
|
||||
Actions: viewer, user, editor, admin (hierarchical)
|
||||
"""
|
||||
|
||||
__tablename__ = "roles"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4,
|
||||
)
|
||||
name: Mapped[str] = mapped_column(
|
||||
String(100),
|
||||
unique=True,
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="Role name in format domain:action (e.g., control-room:admin)",
|
||||
)
|
||||
domain: Mapped[str] = mapped_column(
|
||||
String(50),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="Permission domain (e.g., control-room, media, ai)",
|
||||
)
|
||||
action: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
nullable=False,
|
||||
comment="Permission action (viewer, user, editor, admin)",
|
||||
)
|
||||
authentik_group: Mapped[str | None] = mapped_column(
|
||||
String(255),
|
||||
nullable=True,
|
||||
unique=True,
|
||||
comment="Corresponding Authentik group name (e.g., tatlock-control-room-admin)",
|
||||
)
|
||||
|
||||
# Relationships
|
||||
users: Mapped[List["User"]] = relationship(
|
||||
"User",
|
||||
secondary="user_roles",
|
||||
back_populates="roles",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Role {self.name}>"
|
||||
|
||||
|
||||
class UserRole(Base):
|
||||
"""
|
||||
Association table for User-Role many-to-many relationship
|
||||
|
||||
Synced from Authentik groups during user authentication.
|
||||
"""
|
||||
|
||||
__tablename__ = "user_roles"
|
||||
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
)
|
||||
role_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("roles.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
)
|
||||
@@ -0,0 +1,94 @@
|
||||
"""
|
||||
User Model
|
||||
|
||||
Represents users synced from Authentik SSO.
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, List
|
||||
|
||||
from sqlalchemy import String, Boolean, DateTime, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from src.db.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.db.models.role import Role
|
||||
from src.db.models.user_preferences import UserPreferences
|
||||
from src.db.models.api_key import ApiKey
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""
|
||||
User model synced from Authentik
|
||||
|
||||
Users are created/updated when they authenticate via OIDC.
|
||||
The authentik_id links to the Authentik user record.
|
||||
"""
|
||||
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4,
|
||||
)
|
||||
authentik_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
unique=True,
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
email: Mapped[str] = mapped_column(
|
||||
String(255),
|
||||
unique=True,
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
name: Mapped[str] = mapped_column(
|
||||
String(255),
|
||||
nullable=False,
|
||||
)
|
||||
avatar_url: Mapped[str | None] = mapped_column(
|
||||
String(500),
|
||||
nullable=True,
|
||||
)
|
||||
api_keys_enabled: Mapped[bool] = mapped_column(
|
||||
Boolean,
|
||||
default=True,
|
||||
nullable=False,
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
last_login: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
# Relationships
|
||||
roles: Mapped[List["Role"]] = relationship(
|
||||
"Role",
|
||||
secondary="user_roles",
|
||||
back_populates="users",
|
||||
lazy="selectin",
|
||||
)
|
||||
preferences: Mapped["UserPreferences"] = relationship(
|
||||
"UserPreferences",
|
||||
back_populates="user",
|
||||
uselist=False,
|
||||
lazy="selectin",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
api_keys: Mapped[List["ApiKey"]] = relationship(
|
||||
"ApiKey",
|
||||
back_populates="user",
|
||||
lazy="selectin",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<User {self.email}>"
|
||||
@@ -0,0 +1,61 @@
|
||||
"""
|
||||
User Preferences Model
|
||||
|
||||
Stores user-specific settings like theme and default room.
|
||||
"""
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import String, ForeignKey
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from src.db.database import Base
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.db.models.user import User
|
||||
|
||||
|
||||
class UserPreferences(Base):
|
||||
"""
|
||||
User preferences model
|
||||
|
||||
Stores user-specific settings that persist across sessions.
|
||||
Extended settings stored in preferences_json for flexibility.
|
||||
"""
|
||||
|
||||
__tablename__ = "user_preferences"
|
||||
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
)
|
||||
theme: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
default="system",
|
||||
nullable=False,
|
||||
comment="Theme preference: system, light, dark",
|
||||
)
|
||||
default_room: Mapped[str] = mapped_column(
|
||||
String(50),
|
||||
default="front-hall",
|
||||
nullable=False,
|
||||
comment="Default room for housekeeping features",
|
||||
)
|
||||
preferences_json: Mapped[dict] = mapped_column(
|
||||
JSONB,
|
||||
default=dict,
|
||||
nullable=False,
|
||||
comment="Extended preferences as JSON",
|
||||
)
|
||||
|
||||
# Relationships
|
||||
user: Mapped["User"] = relationship(
|
||||
"User",
|
||||
back_populates="preferences",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<UserPreferences user_id={self.user_id}>"
|
||||
+22
-62
@@ -9,11 +9,13 @@ from contextlib import asynccontextmanager
|
||||
from src.config import get_settings
|
||||
from src.logging_config import setup_logging, get_logger
|
||||
from src.models.ollama_client import get_ollama_client, close_ollama_client
|
||||
from src.db import get_database
|
||||
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.auth.controller import auth_controller
|
||||
from src.security import initialize_oidc
|
||||
|
||||
# Initialize settings
|
||||
@@ -48,6 +50,14 @@ async def lifespan(app: FastAPI):
|
||||
else:
|
||||
logger.warning("✗ Ollama connection failed - AI features may not work")
|
||||
|
||||
# Check database connectivity
|
||||
database = get_database()
|
||||
db_healthy = await database.health_check()
|
||||
if db_healthy:
|
||||
logger.info("✓ Database connection successful")
|
||||
else:
|
||||
logger.warning("✗ Database connection failed - auth features may not work")
|
||||
|
||||
# Initialize security (OIDC authentication)
|
||||
initialize_oidc(settings)
|
||||
|
||||
@@ -56,6 +66,7 @@ async def lifespan(app: FastAPI):
|
||||
# Shutdown
|
||||
logger.info("Shutting down application")
|
||||
await close_ollama_client()
|
||||
await database.close()
|
||||
|
||||
|
||||
# Create FastAPI application
|
||||
@@ -63,70 +74,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.
|
||||
|
||||
### 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,
|
||||
@@ -148,10 +107,11 @@ app.add_middleware(
|
||||
|
||||
# Include controller routers
|
||||
app.include_router(health_controller.router) # / and /health
|
||||
app.include_router(tools_controller.router) # /web-scraper/scrape
|
||||
app.include_router(auth_controller.router) # /auth/*
|
||||
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
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
"""
|
||||
Web scraper module for extracting content from websites
|
||||
"""
|
||||
from src.web_scraper.router import router
|
||||
from src.web_scraper.schemas import WebScraperRequest, WebScraperResponse
|
||||
from src.web_scraper.service import WebScraperService
|
||||
|
||||
__all__ = [
|
||||
"router",
|
||||
"WebScraperRequest",
|
||||
"WebScraperResponse",
|
||||
"WebScraperService",
|
||||
]
|
||||
@@ -1,32 +0,0 @@
|
||||
"""
|
||||
Configuration for web scraper module
|
||||
"""
|
||||
from pydantic_settings import BaseSettings
|
||||
from functools import lru_cache
|
||||
|
||||
|
||||
class WebScraperSettings(BaseSettings):
|
||||
"""Web scraper specific settings"""
|
||||
|
||||
# HTTP client configuration
|
||||
request_timeout: int = 30
|
||||
max_redirects: int = 5
|
||||
user_agent: str = "Mozilla/5.0 (compatible; CoreCode/1.0)"
|
||||
|
||||
# Content extraction
|
||||
default_max_length: int = 10000
|
||||
max_links_to_extract: int = 50
|
||||
|
||||
# Rate limiting (future use)
|
||||
rate_limit_enabled: bool = False
|
||||
requests_per_minute: int = 60
|
||||
|
||||
class Config:
|
||||
env_prefix = "WEB_SCRAPER_"
|
||||
case_sensitive = False
|
||||
|
||||
|
||||
@lru_cache()
|
||||
def get_web_scraper_settings() -> WebScraperSettings:
|
||||
"""Cached web scraper settings instance"""
|
||||
return WebScraperSettings()
|
||||
@@ -1,18 +0,0 @@
|
||||
"""
|
||||
Custom exceptions for web scraper module
|
||||
"""
|
||||
|
||||
|
||||
class WebScraperException(Exception):
|
||||
"""Base exception for web scraper module"""
|
||||
pass
|
||||
|
||||
|
||||
class FetchError(WebScraperException):
|
||||
"""Raised when URL fetch fails"""
|
||||
pass
|
||||
|
||||
|
||||
class ScrapingError(WebScraperException):
|
||||
"""Raised when content extraction fails"""
|
||||
pass
|
||||
@@ -1,79 +0,0 @@
|
||||
"""
|
||||
API routes for web scraper module
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
|
||||
from src.logging_config import get_logger
|
||||
from src.web_scraper.schemas import WebScraperRequest, WebScraperResponse
|
||||
from src.web_scraper.service import WebScraperService
|
||||
from src.web_scraper.exceptions import FetchError, ScrapingError
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/web-scraper",
|
||||
tags=["Web Scraper"]
|
||||
)
|
||||
|
||||
# Initialize service (could be dependency injected for testing)
|
||||
scraper_service = WebScraperService()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/scrape",
|
||||
response_model=WebScraperResponse,
|
||||
status_code=status.HTTP_200_OK,
|
||||
summary="Scrape website content",
|
||||
description="""
|
||||
Scrape and extract main content from a website.
|
||||
|
||||
Uses trafilatura for intelligent content extraction (articles, blog posts, documentation),
|
||||
with BeautifulSoup as fallback. Perfect for feeding webpage content to LLMs.
|
||||
|
||||
**Features:**
|
||||
- Intelligent main content extraction
|
||||
- Removes navigation, ads, footers
|
||||
- Optional link extraction
|
||||
- Configurable content length limits
|
||||
|
||||
**Rate Limiting:** None (internal network use only)
|
||||
"""
|
||||
)
|
||||
async def scrape_website(request: WebScraperRequest) -> WebScraperResponse:
|
||||
"""
|
||||
Scrape a website and extract its main content
|
||||
|
||||
Args:
|
||||
request: Scraping request with URL and options
|
||||
|
||||
Returns:
|
||||
Extracted content with metadata
|
||||
|
||||
Raises:
|
||||
HTTPException: 400 for fetch errors, 500 for processing errors
|
||||
"""
|
||||
try:
|
||||
logger.info(f"Received scrape request for: {request.url}")
|
||||
result = await scraper_service.scrape_url(request)
|
||||
return result
|
||||
|
||||
except FetchError as e:
|
||||
logger.warning(f"Fetch failed: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Failed to fetch URL: {str(e)}"
|
||||
)
|
||||
|
||||
except ScrapingError as e:
|
||||
logger.error(f"Scraping failed: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to extract content: {str(e)}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error: {str(e)}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="An unexpected error occurred"
|
||||
)
|
||||
@@ -1,69 +0,0 @@
|
||||
"""
|
||||
Pydantic schemas for web scraper module
|
||||
"""
|
||||
from pydantic import HttpUrl, Field
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
from src.base_schema import BaseSchema
|
||||
|
||||
|
||||
class WebScraperRequest(BaseSchema):
|
||||
"""Request model for web scraping"""
|
||||
|
||||
url: HttpUrl = Field(
|
||||
...,
|
||||
description="The URL to scrape",
|
||||
examples=["https://example.com/article"]
|
||||
)
|
||||
|
||||
extract_main_content: bool = Field(
|
||||
default=True,
|
||||
description="Use intelligent content extraction (trafilatura) vs raw HTML parsing"
|
||||
)
|
||||
|
||||
include_links: bool = Field(
|
||||
default=False,
|
||||
description="Include list of links found on the page"
|
||||
)
|
||||
|
||||
max_length: Optional[int] = Field(
|
||||
default=10000,
|
||||
ge=100,
|
||||
le=100000,
|
||||
description="Maximum content length to return (100-100000 chars)"
|
||||
)
|
||||
|
||||
|
||||
class WebScraperResponse(BaseSchema):
|
||||
"""Response model for web scraping"""
|
||||
|
||||
url: str = Field(
|
||||
...,
|
||||
description="The scraped URL"
|
||||
)
|
||||
|
||||
title: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Page title extracted from <title> tag"
|
||||
)
|
||||
|
||||
content: str = Field(
|
||||
...,
|
||||
description="Extracted page content"
|
||||
)
|
||||
|
||||
extracted_at: datetime = Field(
|
||||
...,
|
||||
description="UTC timestamp when content was extracted"
|
||||
)
|
||||
|
||||
content_length: int = Field(
|
||||
...,
|
||||
ge=0,
|
||||
description="Length of extracted content in characters"
|
||||
)
|
||||
|
||||
links: Optional[list[str]] = Field(
|
||||
default=None,
|
||||
description="List of HTTP(S) links found on the page (max 50)"
|
||||
)
|
||||
@@ -1,213 +0,0 @@
|
||||
"""
|
||||
Business logic for web scraper module
|
||||
"""
|
||||
import httpx
|
||||
from bs4 import BeautifulSoup
|
||||
import trafilatura
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from src.logging_config import get_logger
|
||||
from src.web_scraper.config import get_web_scraper_settings
|
||||
from src.web_scraper.schemas import WebScraperRequest, WebScraperResponse
|
||||
from src.web_scraper.exceptions import ScrapingError, FetchError
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class WebScraperService:
|
||||
"""Service class for web scraping operations"""
|
||||
|
||||
def __init__(self):
|
||||
self.settings = get_web_scraper_settings()
|
||||
|
||||
async def scrape_url(self, request: WebScraperRequest) -> WebScraperResponse:
|
||||
"""
|
||||
Scrape and extract content from a URL
|
||||
|
||||
Args:
|
||||
request: Scraping request parameters
|
||||
|
||||
Returns:
|
||||
Extracted content with metadata
|
||||
|
||||
Raises:
|
||||
FetchError: If URL cannot be fetched
|
||||
ScrapingError: If content extraction fails
|
||||
"""
|
||||
url_str = str(request.url)
|
||||
logger.info(f"Starting scrape for URL: {url_str}")
|
||||
|
||||
try:
|
||||
# Fetch the webpage
|
||||
html_content = await self._fetch_url(url_str)
|
||||
|
||||
# Extract content based on settings
|
||||
if request.extract_main_content:
|
||||
content = self._extract_main_content(html_content, request.include_links)
|
||||
else:
|
||||
content = self._extract_basic_content(html_content)
|
||||
|
||||
# Extract metadata
|
||||
title = self._extract_title(html_content)
|
||||
links = self._extract_links(html_content) if request.include_links else None
|
||||
|
||||
# Clean and truncate content
|
||||
content = self._clean_content(content)
|
||||
if request.max_length and len(content) > request.max_length:
|
||||
content = content[:request.max_length] + "\n\n[Content truncated...]"
|
||||
logger.debug(f"Content truncated to {request.max_length} characters")
|
||||
|
||||
logger.info(f"Successfully scraped {len(content)} characters from {url_str}")
|
||||
|
||||
return WebScraperResponse(
|
||||
url=url_str,
|
||||
title=title,
|
||||
content=content,
|
||||
extracted_at=datetime.now(timezone.utc),
|
||||
content_length=len(content),
|
||||
links=links
|
||||
)
|
||||
|
||||
except FetchError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Scraping failed for {url_str}: {str(e)}", exc_info=True)
|
||||
raise ScrapingError(f"Failed to scrape content: {str(e)}")
|
||||
|
||||
async def _fetch_url(self, url: str) -> str:
|
||||
"""
|
||||
Fetch HTML content from URL
|
||||
|
||||
Args:
|
||||
url: URL to fetch
|
||||
|
||||
Returns:
|
||||
HTML content as string
|
||||
|
||||
Raises:
|
||||
FetchError: If fetch fails
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=self.settings.request_timeout,
|
||||
follow_redirects=True,
|
||||
max_redirects=self.settings.max_redirects
|
||||
) as client:
|
||||
logger.debug(f"Fetching URL: {url}")
|
||||
response = await client.get(
|
||||
url,
|
||||
headers={"User-Agent": self.settings.user_agent}
|
||||
)
|
||||
response.raise_for_status()
|
||||
logger.debug(f"Fetched {len(response.text)} bytes from {url}")
|
||||
return response.text
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.error(f"HTTP error {e.response.status_code} for {url}")
|
||||
raise FetchError(f"HTTP {e.response.status_code}: {e.response.reason_phrase}")
|
||||
except httpx.RequestError as e:
|
||||
logger.error(f"Request error for {url}: {str(e)}")
|
||||
raise FetchError(f"Failed to fetch URL: {str(e)}")
|
||||
|
||||
def _extract_main_content(self, html: str, include_links: bool = False) -> str:
|
||||
"""
|
||||
Extract main content using trafilatura (intelligent extraction)
|
||||
|
||||
Args:
|
||||
html: Raw HTML content
|
||||
include_links: Whether to preserve links in output
|
||||
|
||||
Returns:
|
||||
Extracted content
|
||||
"""
|
||||
logger.debug("Extracting main content with trafilatura")
|
||||
content = trafilatura.extract(
|
||||
html,
|
||||
include_links=include_links,
|
||||
include_images=False,
|
||||
output_format='txt',
|
||||
no_fallback=False
|
||||
)
|
||||
|
||||
# Fallback to BeautifulSoup if trafilatura fails
|
||||
if not content:
|
||||
logger.debug("Trafilatura extraction failed, falling back to BeautifulSoup")
|
||||
content = self._extract_basic_content(html)
|
||||
|
||||
return content
|
||||
|
||||
def _extract_basic_content(self, html: str) -> str:
|
||||
"""
|
||||
Extract content using basic BeautifulSoup parsing
|
||||
|
||||
Args:
|
||||
html: Raw HTML content
|
||||
|
||||
Returns:
|
||||
Extracted text content
|
||||
"""
|
||||
logger.debug("Extracting content with BeautifulSoup")
|
||||
soup = BeautifulSoup(html, 'html.parser')
|
||||
|
||||
# Remove unwanted elements
|
||||
for element in soup(["script", "style", "nav", "footer", "header", "aside"]):
|
||||
element.decompose()
|
||||
|
||||
# Extract text
|
||||
text = soup.get_text(separator='\n', strip=True)
|
||||
return text
|
||||
|
||||
def _extract_title(self, html: str) -> Optional[str]:
|
||||
"""
|
||||
Extract page title from HTML
|
||||
|
||||
Args:
|
||||
html: Raw HTML content
|
||||
|
||||
Returns:
|
||||
Page title or None
|
||||
"""
|
||||
soup = BeautifulSoup(html, 'html.parser')
|
||||
title = soup.title.string if soup.title else None
|
||||
if title:
|
||||
title = title.strip()
|
||||
logger.debug(f"Extracted title: {title}")
|
||||
return title
|
||||
|
||||
def _extract_links(self, html: str) -> list[str]:
|
||||
"""
|
||||
Extract HTTP(S) links from HTML
|
||||
|
||||
Args:
|
||||
html: Raw HTML content
|
||||
|
||||
Returns:
|
||||
List of absolute HTTP(S) URLs
|
||||
"""
|
||||
soup = BeautifulSoup(html, 'html.parser')
|
||||
links = [
|
||||
a.get('href')
|
||||
for a in soup.find_all('a', href=True)
|
||||
if a.get('href', '').startswith('http')
|
||||
]
|
||||
|
||||
# Limit number of links
|
||||
links = links[:self.settings.max_links_to_extract]
|
||||
logger.debug(f"Extracted {len(links)} links")
|
||||
return links
|
||||
|
||||
def _clean_content(self, content: str) -> str:
|
||||
"""
|
||||
Clean and normalize extracted content
|
||||
|
||||
Args:
|
||||
content: Raw extracted content
|
||||
|
||||
Returns:
|
||||
Cleaned content
|
||||
"""
|
||||
# Remove empty lines and normalize whitespace
|
||||
lines = [line.strip() for line in content.split('\n') if line.strip()]
|
||||
cleaned = '\n'.join(lines)
|
||||
return cleaned
|
||||
@@ -0,0 +1,300 @@
|
||||
"""Tests for Core-AI client."""
|
||||
import pytest
|
||||
from unittest.mock import patch, AsyncMock, MagicMock
|
||||
import httpx
|
||||
|
||||
from src.clients.ai_client import CoreAIClient, get_ai_client
|
||||
|
||||
|
||||
class TestCoreAIClientInit:
|
||||
"""Test CoreAIClient initialization."""
|
||||
|
||||
@patch("src.clients.ai_client.settings")
|
||||
def test_uses_settings_defaults(self, mock_settings):
|
||||
"""Client should use settings for defaults."""
|
||||
mock_settings.core_ai_base_url = "http://core-ai:8086"
|
||||
|
||||
client = CoreAIClient()
|
||||
|
||||
assert client.base_url == "http://core-ai:8086"
|
||||
assert client.timeout == 10
|
||||
|
||||
def test_accepts_custom_url(self):
|
||||
"""Client should accept custom URL."""
|
||||
client = CoreAIClient(base_url="http://custom:9000")
|
||||
|
||||
assert client.base_url == "http://custom:9000"
|
||||
|
||||
def test_accepts_custom_timeout(self):
|
||||
"""Client should accept custom timeout."""
|
||||
client = CoreAIClient(base_url="http://test:8086", timeout=30)
|
||||
|
||||
assert client.timeout == 30
|
||||
|
||||
def test_strips_trailing_slash_from_url(self):
|
||||
"""Client should strip trailing slash from URL."""
|
||||
client = CoreAIClient(base_url="http://core-ai:8086/")
|
||||
|
||||
assert client.base_url == "http://core-ai:8086"
|
||||
|
||||
def test_creates_http_client(self):
|
||||
"""Client should create httpx AsyncClient."""
|
||||
client = CoreAIClient(base_url="http://test:8086")
|
||||
|
||||
assert client.client is not None
|
||||
|
||||
|
||||
class TestCoreAIClientClose:
|
||||
"""Test client close functionality."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_close_closes_client(self):
|
||||
"""close should close the HTTP client."""
|
||||
client = CoreAIClient(base_url="http://test:8086")
|
||||
|
||||
with patch.object(client.client, "aclose", new_callable=AsyncMock) as mock_close:
|
||||
await client.close()
|
||||
mock_close.assert_called_once()
|
||||
|
||||
|
||||
class TestCoreAIClientContextManager:
|
||||
"""Test async context manager."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_context_manager_enters(self):
|
||||
"""Context manager should return client on enter."""
|
||||
client = CoreAIClient(base_url="http://test:8086")
|
||||
|
||||
with patch.object(client.client, "aclose", new_callable=AsyncMock):
|
||||
async with client as ctx:
|
||||
assert ctx is client
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_context_manager_closes_on_exit(self):
|
||||
"""Context manager should close client on exit."""
|
||||
client = CoreAIClient(base_url="http://test:8086")
|
||||
|
||||
with patch.object(client, "close", new_callable=AsyncMock) as mock_close:
|
||||
async with client:
|
||||
pass
|
||||
mock_close.assert_called_once()
|
||||
|
||||
|
||||
class TestCoreAIClientHealthCheck:
|
||||
"""Test health check functionality."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_check_returns_true_on_200(self):
|
||||
"""Health check should return True when service responds 200."""
|
||||
client = CoreAIClient(base_url="http://test:8086")
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
|
||||
with patch.object(client.client, "get", new_callable=AsyncMock) as mock_get:
|
||||
mock_get.return_value = mock_response
|
||||
result = await client.health_check()
|
||||
|
||||
assert result is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_check_returns_false_on_error(self):
|
||||
"""Health check should return False on connection error."""
|
||||
client = CoreAIClient(base_url="http://test:8086")
|
||||
|
||||
with patch.object(client.client, "get", new_callable=AsyncMock) as mock_get:
|
||||
mock_get.side_effect = Exception("Connection refused")
|
||||
result = await client.health_check()
|
||||
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_check_returns_false_on_non_200(self):
|
||||
"""Health check should return False on non-200 status."""
|
||||
client = CoreAIClient(base_url="http://test:8086")
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 500
|
||||
|
||||
with patch.object(client.client, "get", new_callable=AsyncMock) as mock_get:
|
||||
mock_get.return_value = mock_response
|
||||
result = await client.health_check()
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
class TestCoreAIClientGetMetrics:
|
||||
"""Test get metrics functionality."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_metrics_returns_dict(self):
|
||||
"""get_metrics should return metrics dict."""
|
||||
client = CoreAIClient(base_url="http://test:8086")
|
||||
|
||||
metrics_data = {
|
||||
"uptime_seconds": 3600,
|
||||
"agent": {"total_requests": 100},
|
||||
"tools": {"total_calls": 250}
|
||||
}
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = metrics_data
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
with patch.object(client.client, "get", new_callable=AsyncMock) as mock_get:
|
||||
mock_get.return_value = mock_response
|
||||
result = await client.get_metrics()
|
||||
|
||||
assert result == metrics_data
|
||||
assert result["uptime_seconds"] == 3600
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_metrics_raises_on_http_error(self):
|
||||
"""get_metrics should raise on HTTP error."""
|
||||
client = CoreAIClient(base_url="http://test:8086")
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 500
|
||||
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
|
||||
"Server Error", request=MagicMock(), response=mock_response
|
||||
)
|
||||
|
||||
with patch.object(client.client, "get", new_callable=AsyncMock) as mock_get:
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
with pytest.raises(httpx.HTTPStatusError):
|
||||
await client.get_metrics()
|
||||
|
||||
|
||||
class TestCoreAIClientGetRecentErrors:
|
||||
"""Test get recent errors functionality."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_recent_errors_returns_list(self):
|
||||
"""get_recent_errors should return list of errors."""
|
||||
client = CoreAIClient(base_url="http://test:8086")
|
||||
|
||||
errors_data = {
|
||||
"errors": [
|
||||
{"timestamp": "2025-12-03T19:45:12Z", "error": "Timeout"},
|
||||
{"timestamp": "2025-12-03T19:46:00Z", "error": "Connection refused"}
|
||||
]
|
||||
}
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = errors_data
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
with patch.object(client.client, "get", new_callable=AsyncMock) as mock_get:
|
||||
mock_get.return_value = mock_response
|
||||
result = await client.get_recent_errors()
|
||||
|
||||
assert len(result) == 2
|
||||
assert result[0]["error"] == "Timeout"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_recent_errors_passes_limit(self):
|
||||
"""get_recent_errors should pass limit parameter."""
|
||||
client = CoreAIClient(base_url="http://test:8086")
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {"errors": []}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
with patch.object(client.client, "get", new_callable=AsyncMock) as mock_get:
|
||||
mock_get.return_value = mock_response
|
||||
await client.get_recent_errors(limit=5)
|
||||
|
||||
call_args = mock_get.call_args
|
||||
assert call_args[1]["params"]["limit"] == 5
|
||||
|
||||
|
||||
class TestCoreAIClientGetToolFailures:
|
||||
"""Test get tool failures functionality."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_tool_failures_returns_list(self):
|
||||
"""get_tool_failures should return list of failures."""
|
||||
client = CoreAIClient(base_url="http://test:8086")
|
||||
|
||||
failures_data = {
|
||||
"failures": [
|
||||
{"tool_name": "list_containers", "error": "Connection refused"}
|
||||
]
|
||||
}
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = failures_data
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
with patch.object(client.client, "get", new_callable=AsyncMock) as mock_get:
|
||||
mock_get.return_value = mock_response
|
||||
result = await client.get_tool_failures()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0]["tool_name"] == "list_containers"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_tool_failures_passes_limit(self):
|
||||
"""get_tool_failures should pass limit parameter."""
|
||||
client = CoreAIClient(base_url="http://test:8086")
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {"failures": []}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
with patch.object(client.client, "get", new_callable=AsyncMock) as mock_get:
|
||||
mock_get.return_value = mock_response
|
||||
await client.get_tool_failures(limit=10)
|
||||
|
||||
call_args = mock_get.call_args
|
||||
assert call_args[1]["params"]["limit"] == 10
|
||||
|
||||
|
||||
class TestCoreAIClientResetMetrics:
|
||||
"""Test reset metrics functionality."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reset_metrics_returns_true_on_success(self):
|
||||
"""reset_metrics should return True on success."""
|
||||
client = CoreAIClient(base_url="http://test:8086")
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
with patch.object(client.client, "post", new_callable=AsyncMock) as mock_post:
|
||||
mock_post.return_value = mock_response
|
||||
result = await client.reset_metrics()
|
||||
|
||||
assert result is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reset_metrics_raises_on_error(self):
|
||||
"""reset_metrics should raise on error."""
|
||||
client = CoreAIClient(base_url="http://test:8086")
|
||||
|
||||
with patch.object(client.client, "post", new_callable=AsyncMock) as mock_post:
|
||||
mock_post.side_effect = Exception("Connection refused")
|
||||
|
||||
with pytest.raises(Exception):
|
||||
await client.reset_metrics()
|
||||
|
||||
|
||||
class TestCoreAIClientSingleton:
|
||||
"""Test singleton pattern."""
|
||||
|
||||
def test_get_ai_client_returns_same_instance(self):
|
||||
"""get_ai_client should return singleton."""
|
||||
import src.clients.ai_client as module
|
||||
module._ai_client = None
|
||||
|
||||
client1 = get_ai_client()
|
||||
client2 = get_ai_client()
|
||||
|
||||
assert client1 is client2
|
||||
@@ -0,0 +1,264 @@
|
||||
"""Tests for AI controller."""
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from unittest.mock import patch, AsyncMock, MagicMock
|
||||
|
||||
from src.main import app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
"""Create a test client."""
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_ai_client():
|
||||
"""Create a mock AI client."""
|
||||
mock = AsyncMock()
|
||||
return mock
|
||||
|
||||
|
||||
class TestAIHealth:
|
||||
"""Test /ai/health endpoint."""
|
||||
|
||||
@patch("src.controllers.ai_controller.get_ai_client")
|
||||
def test_health_returns_200(self, mock_get_client, client):
|
||||
"""AI health should return 200."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.health_check.return_value = True
|
||||
mock_get_client.return_value = mock_client
|
||||
|
||||
response = client.get("/ai/health")
|
||||
assert response.status_code == 200
|
||||
|
||||
@patch("src.controllers.ai_controller.get_ai_client")
|
||||
def test_health_returns_healthy_status(self, mock_get_client, client):
|
||||
"""AI health should return healthy status when service is up."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.health_check.return_value = True
|
||||
mock_get_client.return_value = mock_client
|
||||
|
||||
response = client.get("/ai/health")
|
||||
data = response.json()
|
||||
|
||||
assert data["service"] == "core-ai"
|
||||
assert data["status"] == "healthy"
|
||||
assert data["accessible"] is True
|
||||
|
||||
@patch("src.controllers.ai_controller.get_ai_client")
|
||||
def test_health_returns_unhealthy_status(self, mock_get_client, client):
|
||||
"""AI health should return unhealthy status when service is down."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.health_check.return_value = False
|
||||
mock_get_client.return_value = mock_client
|
||||
|
||||
response = client.get("/ai/health")
|
||||
data = response.json()
|
||||
|
||||
assert data["status"] == "unhealthy"
|
||||
assert data["accessible"] is False
|
||||
|
||||
@patch("src.controllers.ai_controller.get_ai_client")
|
||||
def test_health_handles_exception(self, mock_get_client, client):
|
||||
"""AI health should handle exceptions gracefully."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.health_check.side_effect = Exception("Connection refused")
|
||||
mock_get_client.return_value = mock_client
|
||||
|
||||
response = client.get("/ai/health")
|
||||
data = response.json()
|
||||
|
||||
assert data["status"] == "error"
|
||||
assert data["accessible"] is False
|
||||
assert "error" in data
|
||||
|
||||
|
||||
class TestAIMetrics:
|
||||
"""Test /ai/metrics endpoint."""
|
||||
|
||||
@patch("src.controllers.ai_controller.get_ai_client")
|
||||
def test_metrics_returns_200(self, mock_get_client, client):
|
||||
"""AI metrics should return 200."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get_metrics.return_value = {
|
||||
"uptime_seconds": 3600,
|
||||
"agent": {"total_requests": 100}
|
||||
}
|
||||
mock_get_client.return_value = mock_client
|
||||
|
||||
response = client.get("/ai/metrics")
|
||||
assert response.status_code == 200
|
||||
|
||||
@patch("src.controllers.ai_controller.get_ai_client")
|
||||
def test_metrics_returns_data(self, mock_get_client, client):
|
||||
"""AI metrics should return metrics data."""
|
||||
metrics_data = {
|
||||
"uptime_seconds": 3600,
|
||||
"agent": {"total_requests": 100},
|
||||
"tools": {"total_calls": 250}
|
||||
}
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get_metrics.return_value = metrics_data
|
||||
mock_get_client.return_value = mock_client
|
||||
|
||||
response = client.get("/ai/metrics")
|
||||
data = response.json()
|
||||
|
||||
assert data["uptime_seconds"] == 3600
|
||||
assert data["agent"]["total_requests"] == 100
|
||||
|
||||
@patch("src.controllers.ai_controller.get_ai_client")
|
||||
def test_metrics_returns_503_on_error(self, mock_get_client, client):
|
||||
"""AI metrics should return 503 when service unavailable."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get_metrics.side_effect = Exception("Service unavailable")
|
||||
mock_get_client.return_value = mock_client
|
||||
|
||||
response = client.get("/ai/metrics")
|
||||
assert response.status_code == 503
|
||||
|
||||
|
||||
class TestAIErrors:
|
||||
"""Test /ai/metrics/errors endpoint."""
|
||||
|
||||
@patch("src.controllers.ai_controller.get_ai_client")
|
||||
def test_errors_returns_200(self, mock_get_client, client):
|
||||
"""AI errors should return 200."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get_recent_errors.return_value = []
|
||||
mock_get_client.return_value = mock_client
|
||||
|
||||
response = client.get("/ai/metrics/errors")
|
||||
assert response.status_code == 200
|
||||
|
||||
@patch("src.controllers.ai_controller.get_ai_client")
|
||||
def test_errors_returns_error_list(self, mock_get_client, client):
|
||||
"""AI errors should return list of errors."""
|
||||
errors = [
|
||||
{"timestamp": "2025-12-03T19:45:12Z", "error": "Timeout"},
|
||||
{"timestamp": "2025-12-03T19:46:00Z", "error": "Connection refused"}
|
||||
]
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get_recent_errors.return_value = errors
|
||||
mock_get_client.return_value = mock_client
|
||||
|
||||
response = client.get("/ai/metrics/errors")
|
||||
data = response.json()
|
||||
|
||||
assert "errors" in data
|
||||
assert "total" in data
|
||||
assert data["total"] == 2
|
||||
|
||||
@patch("src.controllers.ai_controller.get_ai_client")
|
||||
def test_errors_accepts_limit_parameter(self, mock_get_client, client):
|
||||
"""AI errors should accept limit parameter."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get_recent_errors.return_value = []
|
||||
mock_get_client.return_value = mock_client
|
||||
|
||||
response = client.get("/ai/metrics/errors?limit=5")
|
||||
assert response.status_code == 200
|
||||
mock_client.get_recent_errors.assert_called_with(limit=5)
|
||||
|
||||
@patch("src.controllers.ai_controller.get_ai_client")
|
||||
def test_errors_returns_503_on_error(self, mock_get_client, client):
|
||||
"""AI errors should return 503 when service unavailable."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get_recent_errors.side_effect = Exception("Service unavailable")
|
||||
mock_get_client.return_value = mock_client
|
||||
|
||||
response = client.get("/ai/metrics/errors")
|
||||
assert response.status_code == 503
|
||||
|
||||
|
||||
class TestAIToolFailures:
|
||||
"""Test /ai/metrics/tool-failures endpoint."""
|
||||
|
||||
@patch("src.controllers.ai_controller.get_ai_client")
|
||||
def test_tool_failures_returns_200(self, mock_get_client, client):
|
||||
"""Tool failures should return 200."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get_tool_failures.return_value = []
|
||||
mock_get_client.return_value = mock_client
|
||||
|
||||
response = client.get("/ai/metrics/tool-failures")
|
||||
assert response.status_code == 200
|
||||
|
||||
@patch("src.controllers.ai_controller.get_ai_client")
|
||||
def test_tool_failures_returns_failure_list(self, mock_get_client, client):
|
||||
"""Tool failures should return list of failures."""
|
||||
failures = [
|
||||
{"tool_name": "list_containers", "error": "Connection refused"}
|
||||
]
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get_tool_failures.return_value = failures
|
||||
mock_get_client.return_value = mock_client
|
||||
|
||||
response = client.get("/ai/metrics/tool-failures")
|
||||
data = response.json()
|
||||
|
||||
assert "failures" in data
|
||||
assert "total" in data
|
||||
assert data["total"] == 1
|
||||
|
||||
@patch("src.controllers.ai_controller.get_ai_client")
|
||||
def test_tool_failures_accepts_limit_parameter(self, mock_get_client, client):
|
||||
"""Tool failures should accept limit parameter."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get_tool_failures.return_value = []
|
||||
mock_get_client.return_value = mock_client
|
||||
|
||||
response = client.get("/ai/metrics/tool-failures?limit=10")
|
||||
assert response.status_code == 200
|
||||
mock_client.get_tool_failures.assert_called_with(limit=10)
|
||||
|
||||
@patch("src.controllers.ai_controller.get_ai_client")
|
||||
def test_tool_failures_returns_503_on_error(self, mock_get_client, client):
|
||||
"""Tool failures should return 503 when service unavailable."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get_tool_failures.side_effect = Exception("Service unavailable")
|
||||
mock_get_client.return_value = mock_client
|
||||
|
||||
response = client.get("/ai/metrics/tool-failures")
|
||||
assert response.status_code == 503
|
||||
|
||||
|
||||
class TestAIMetricsReset:
|
||||
"""Test /ai/metrics/reset endpoint."""
|
||||
|
||||
@patch("src.controllers.ai_controller.get_ai_client")
|
||||
def test_reset_returns_200(self, mock_get_client, client):
|
||||
"""Reset metrics should return 200."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.reset_metrics.return_value = True
|
||||
mock_get_client.return_value = mock_client
|
||||
|
||||
response = client.post("/ai/metrics/reset")
|
||||
assert response.status_code == 200
|
||||
|
||||
@patch("src.controllers.ai_controller.get_ai_client")
|
||||
def test_reset_returns_success_message(self, mock_get_client, client):
|
||||
"""Reset metrics should return success message."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.reset_metrics.return_value = True
|
||||
mock_get_client.return_value = mock_client
|
||||
|
||||
response = client.post("/ai/metrics/reset")
|
||||
data = response.json()
|
||||
|
||||
assert data["success"] is True
|
||||
assert "message" in data
|
||||
|
||||
@patch("src.controllers.ai_controller.get_ai_client")
|
||||
def test_reset_returns_503_on_error(self, mock_get_client, client):
|
||||
"""Reset metrics should return 503 when service unavailable."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.reset_metrics.side_effect = Exception("Service unavailable")
|
||||
mock_get_client.return_value = mock_client
|
||||
|
||||
response = client.post("/ai/metrics/reset")
|
||||
assert response.status_code == 503
|
||||
@@ -0,0 +1,331 @@
|
||||
"""Tests for DNS service."""
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
import dns.resolver
|
||||
import dns.exception
|
||||
|
||||
from src.dns.service import DNSService
|
||||
from src.dns.schemas import DNSLookupRequest, DNSRecord
|
||||
from src.dns.exceptions import DNSQueryError
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dns_service():
|
||||
"""Create a DNSService instance."""
|
||||
return DNSService()
|
||||
|
||||
|
||||
class TestDNSServiceInit:
|
||||
"""Test DNSService initialization."""
|
||||
|
||||
def test_service_has_resolver(self, dns_service):
|
||||
"""Service should have resolver configured."""
|
||||
assert dns_service.resolver is not None
|
||||
|
||||
def test_service_has_timeout(self, dns_service):
|
||||
"""Service should have timeout configured."""
|
||||
assert dns_service.resolver.timeout == 5.0
|
||||
assert dns_service.resolver.lifetime == 10.0
|
||||
|
||||
def test_supported_record_types(self, dns_service):
|
||||
"""Service should have supported record types."""
|
||||
assert "A" in dns_service.SUPPORTED_RECORD_TYPES
|
||||
assert "AAAA" in dns_service.SUPPORTED_RECORD_TYPES
|
||||
assert "MX" in dns_service.SUPPORTED_RECORD_TYPES
|
||||
assert "TXT" in dns_service.SUPPORTED_RECORD_TYPES
|
||||
assert "CNAME" in dns_service.SUPPORTED_RECORD_TYPES
|
||||
assert "NS" in dns_service.SUPPORTED_RECORD_TYPES
|
||||
|
||||
|
||||
class TestDNSServiceLookup:
|
||||
"""Test DNS lookup functionality."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lookup_validates_record_type(self, dns_service):
|
||||
"""lookup should raise error for unsupported record type."""
|
||||
request = DNSLookupRequest(domain="example.com", record_type="INVALID")
|
||||
|
||||
with pytest.raises(DNSQueryError) as exc_info:
|
||||
await dns_service.lookup(request)
|
||||
|
||||
assert "Unsupported record type" in str(exc_info.value)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lookup_returns_response_on_success(self, dns_service):
|
||||
"""lookup should return DNSLookupResponse on success."""
|
||||
request = DNSLookupRequest(domain="example.com", record_type="A")
|
||||
|
||||
# Mock the resolver
|
||||
mock_answer = MagicMock()
|
||||
mock_rdata = MagicMock()
|
||||
mock_rdata.__str__ = MagicMock(return_value="93.184.216.34")
|
||||
mock_answer.__iter__ = MagicMock(return_value=iter([mock_rdata]))
|
||||
|
||||
with patch("dns.resolver.Resolver") as mock_resolver_class:
|
||||
mock_resolver = MagicMock()
|
||||
mock_resolver.resolve.return_value = mock_answer
|
||||
mock_resolver.nameservers = ["8.8.8.8"]
|
||||
mock_resolver_class.return_value = mock_resolver
|
||||
|
||||
response = await dns_service.lookup(request)
|
||||
|
||||
assert response.success is True
|
||||
assert response.domain == "example.com"
|
||||
assert response.record_type == "A"
|
||||
assert len(response.records) > 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lookup_uses_custom_nameserver(self, dns_service):
|
||||
"""lookup should use custom nameserver when specified."""
|
||||
request = DNSLookupRequest(
|
||||
domain="example.com",
|
||||
record_type="A",
|
||||
nameserver="1.1.1.1"
|
||||
)
|
||||
|
||||
mock_answer = MagicMock()
|
||||
mock_answer.__iter__ = MagicMock(return_value=iter([]))
|
||||
|
||||
with patch("dns.resolver.Resolver") as mock_resolver_class:
|
||||
mock_resolver = MagicMock()
|
||||
mock_resolver.resolve.return_value = mock_answer
|
||||
mock_resolver.nameservers = []
|
||||
mock_resolver_class.return_value = mock_resolver
|
||||
|
||||
response = await dns_service.lookup(request)
|
||||
|
||||
# Verify nameserver was set
|
||||
assert mock_resolver.nameservers == ["1.1.1.1"]
|
||||
assert response.nameserver_used == "1.1.1.1"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lookup_handles_nxdomain(self, dns_service):
|
||||
"""lookup should handle NXDOMAIN (domain not found)."""
|
||||
request = DNSLookupRequest(domain="nonexistent.invalid", record_type="A")
|
||||
|
||||
with patch("dns.resolver.Resolver") as mock_resolver_class:
|
||||
mock_resolver = MagicMock()
|
||||
mock_resolver.resolve.side_effect = dns.resolver.NXDOMAIN()
|
||||
mock_resolver.nameservers = ["8.8.8.8"]
|
||||
mock_resolver_class.return_value = mock_resolver
|
||||
|
||||
response = await dns_service.lookup(request)
|
||||
|
||||
assert response.success is False
|
||||
assert "Domain not found" in response.error_message
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lookup_handles_no_answer(self, dns_service):
|
||||
"""lookup should handle NoAnswer (no records of type)."""
|
||||
request = DNSLookupRequest(domain="example.com", record_type="AAAA")
|
||||
|
||||
with patch("dns.resolver.Resolver") as mock_resolver_class:
|
||||
mock_resolver = MagicMock()
|
||||
mock_resolver.resolve.side_effect = dns.resolver.NoAnswer()
|
||||
mock_resolver.nameservers = ["8.8.8.8"]
|
||||
mock_resolver_class.return_value = mock_resolver
|
||||
|
||||
response = await dns_service.lookup(request)
|
||||
|
||||
assert response.success is False
|
||||
assert "No AAAA records found" in response.error_message
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lookup_handles_timeout(self, dns_service):
|
||||
"""lookup should handle DNS timeout."""
|
||||
request = DNSLookupRequest(domain="example.com", record_type="A")
|
||||
|
||||
with patch("dns.resolver.Resolver") as mock_resolver_class:
|
||||
mock_resolver = MagicMock()
|
||||
mock_resolver.resolve.side_effect = dns.resolver.Timeout()
|
||||
mock_resolver.nameservers = ["8.8.8.8"]
|
||||
mock_resolver_class.return_value = mock_resolver
|
||||
|
||||
response = await dns_service.lookup(request)
|
||||
|
||||
assert response.success is False
|
||||
assert "timeout" in response.error_message.lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lookup_handles_dns_exception(self, dns_service):
|
||||
"""lookup should handle generic DNS exceptions."""
|
||||
request = DNSLookupRequest(domain="example.com", record_type="A")
|
||||
|
||||
with patch("dns.resolver.Resolver") as mock_resolver_class:
|
||||
mock_resolver = MagicMock()
|
||||
mock_resolver.resolve.side_effect = dns.exception.DNSException("DNS error")
|
||||
mock_resolver.nameservers = ["8.8.8.8"]
|
||||
mock_resolver_class.return_value = mock_resolver
|
||||
|
||||
response = await dns_service.lookup(request)
|
||||
|
||||
assert response.success is False
|
||||
assert "DNS error" in response.error_message
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lookup_handles_unexpected_exception(self, dns_service):
|
||||
"""lookup should handle unexpected exceptions."""
|
||||
request = DNSLookupRequest(domain="example.com", record_type="A")
|
||||
|
||||
with patch("dns.resolver.Resolver") as mock_resolver_class:
|
||||
mock_resolver = MagicMock()
|
||||
mock_resolver.resolve.side_effect = Exception("Unexpected error")
|
||||
mock_resolver.nameservers = ["8.8.8.8"]
|
||||
mock_resolver_class.return_value = mock_resolver
|
||||
|
||||
response = await dns_service.lookup(request)
|
||||
|
||||
assert response.success is False
|
||||
assert "Unexpected error" in response.error_message
|
||||
|
||||
|
||||
class TestDNSServiceParseRecord:
|
||||
"""Test record parsing."""
|
||||
|
||||
def test_parse_a_record(self, dns_service):
|
||||
"""_parse_record should parse A record."""
|
||||
mock_rdata = MagicMock()
|
||||
mock_rdata.__str__ = MagicMock(return_value="192.168.1.1")
|
||||
|
||||
record = dns_service._parse_record(mock_rdata, "A")
|
||||
|
||||
assert record is not None
|
||||
assert record.value == "192.168.1.1"
|
||||
|
||||
def test_parse_aaaa_record(self, dns_service):
|
||||
"""_parse_record should parse AAAA record."""
|
||||
mock_rdata = MagicMock()
|
||||
mock_rdata.__str__ = MagicMock(return_value="2001:db8::1")
|
||||
|
||||
record = dns_service._parse_record(mock_rdata, "AAAA")
|
||||
|
||||
assert record is not None
|
||||
assert record.value == "2001:db8::1"
|
||||
|
||||
def test_parse_mx_record(self, dns_service):
|
||||
"""_parse_record should parse MX record with priority."""
|
||||
mock_rdata = MagicMock()
|
||||
mock_rdata.exchange = "mail.example.com"
|
||||
mock_rdata.preference = 10
|
||||
|
||||
record = dns_service._parse_record(mock_rdata, "MX")
|
||||
|
||||
assert record is not None
|
||||
assert "mail.example.com" in record.value
|
||||
assert record.priority == 10
|
||||
|
||||
def test_parse_txt_record(self, dns_service):
|
||||
"""_parse_record should parse TXT record."""
|
||||
mock_rdata = MagicMock()
|
||||
mock_rdata.strings = [b"v=spf1 include:_spf.google.com ~all"]
|
||||
|
||||
record = dns_service._parse_record(mock_rdata, "TXT")
|
||||
|
||||
assert record is not None
|
||||
assert "spf1" in record.value
|
||||
|
||||
def test_parse_cname_record(self, dns_service):
|
||||
"""_parse_record should parse CNAME record."""
|
||||
mock_rdata = MagicMock()
|
||||
mock_rdata.target = "alias.example.com"
|
||||
|
||||
record = dns_service._parse_record(mock_rdata, "CNAME")
|
||||
|
||||
assert record is not None
|
||||
assert "alias.example.com" in record.value
|
||||
|
||||
def test_parse_ns_record(self, dns_service):
|
||||
"""_parse_record should parse NS record."""
|
||||
mock_rdata = MagicMock()
|
||||
mock_rdata.target = "ns1.example.com"
|
||||
|
||||
record = dns_service._parse_record(mock_rdata, "NS")
|
||||
|
||||
assert record is not None
|
||||
assert "ns1.example.com" in record.value
|
||||
|
||||
def test_parse_soa_record(self, dns_service):
|
||||
"""_parse_record should parse SOA record."""
|
||||
mock_rdata = MagicMock()
|
||||
mock_rdata.mname = "ns1.example.com"
|
||||
mock_rdata.rname = "admin.example.com"
|
||||
mock_rdata.serial = 2024010101
|
||||
|
||||
record = dns_service._parse_record(mock_rdata, "SOA")
|
||||
|
||||
assert record is not None
|
||||
assert "ns1.example.com" in record.value
|
||||
assert "2024010101" in record.value
|
||||
|
||||
def test_parse_srv_record(self, dns_service):
|
||||
"""_parse_record should parse SRV record."""
|
||||
mock_rdata = MagicMock()
|
||||
mock_rdata.target = "server.example.com"
|
||||
mock_rdata.port = 443
|
||||
mock_rdata.priority = 10
|
||||
mock_rdata.weight = 100
|
||||
|
||||
record = dns_service._parse_record(mock_rdata, "SRV")
|
||||
|
||||
assert record is not None
|
||||
assert "server.example.com" in record.value
|
||||
assert "port=443" in record.value
|
||||
assert record.priority == 10
|
||||
|
||||
def test_parse_record_returns_none_on_error(self, dns_service):
|
||||
"""_parse_record should return None on parsing error."""
|
||||
mock_rdata = MagicMock()
|
||||
mock_rdata.__str__ = MagicMock(side_effect=Exception("Parse error"))
|
||||
|
||||
record = dns_service._parse_record(mock_rdata, "A")
|
||||
|
||||
assert record is None
|
||||
|
||||
|
||||
class TestDNSServiceErrorResponse:
|
||||
"""Test error response generation."""
|
||||
|
||||
def test_error_response_includes_domain(self, dns_service):
|
||||
"""_error_response should include domain."""
|
||||
import time
|
||||
request = DNSLookupRequest(domain="test.example.com", record_type="A")
|
||||
|
||||
response = dns_service._error_response(request, "8.8.8.8", time.time(), "Test error")
|
||||
|
||||
assert response.domain == "test.example.com"
|
||||
|
||||
def test_error_response_includes_record_type(self, dns_service):
|
||||
"""_error_response should include record type."""
|
||||
import time
|
||||
request = DNSLookupRequest(domain="test.example.com", record_type="mx")
|
||||
|
||||
response = dns_service._error_response(request, "8.8.8.8", time.time(), "Test error")
|
||||
|
||||
assert response.record_type == "MX" # Should be uppercase
|
||||
|
||||
def test_error_response_has_empty_records(self, dns_service):
|
||||
"""_error_response should have empty records list."""
|
||||
import time
|
||||
request = DNSLookupRequest(domain="test.example.com", record_type="A")
|
||||
|
||||
response = dns_service._error_response(request, "8.8.8.8", time.time(), "Test error")
|
||||
|
||||
assert response.records == []
|
||||
|
||||
def test_error_response_has_success_false(self, dns_service):
|
||||
"""_error_response should have success=False."""
|
||||
import time
|
||||
request = DNSLookupRequest(domain="test.example.com", record_type="A")
|
||||
|
||||
response = dns_service._error_response(request, "8.8.8.8", time.time(), "Test error")
|
||||
|
||||
assert response.success is False
|
||||
|
||||
def test_error_response_includes_error_message(self, dns_service):
|
||||
"""_error_response should include error message."""
|
||||
import time
|
||||
request = DNSLookupRequest(domain="test.example.com", record_type="A")
|
||||
|
||||
response = dns_service._error_response(request, "8.8.8.8", time.time(), "Specific error")
|
||||
|
||||
assert response.error_message == "Specific error"
|
||||
@@ -122,3 +122,142 @@ class TestOpenAPIEndpoint:
|
||||
"""ReDoc should be available."""
|
||||
response = client.get("/redoc")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
class TestFullHealthCheck:
|
||||
"""Test /health/full endpoint."""
|
||||
|
||||
@patch("src.controllers.health_controller.get_ollama_client")
|
||||
def test_full_health_returns_503_when_unhealthy(self, mock_get_ollama, client):
|
||||
"""Full health should return 503 when Ollama unhealthy."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.health_check.return_value = False
|
||||
mock_get_ollama.return_value = mock_client
|
||||
|
||||
response = client.get("/health/full")
|
||||
assert response.status_code == 503
|
||||
|
||||
@patch("src.controllers.health_controller.get_ollama_client")
|
||||
def test_full_health_returns_components_status(self, mock_get_ollama, client):
|
||||
"""Full health should return component status."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.health_check.return_value = False
|
||||
mock_get_ollama.return_value = mock_client
|
||||
|
||||
response = client.get("/health/full")
|
||||
data = response.json()
|
||||
|
||||
assert "status" in data
|
||||
assert "components" in data
|
||||
assert "ollama" in data["components"]
|
||||
assert "response_time_ms" in data
|
||||
|
||||
@patch("src.controllers.health_controller.get_ollama_client")
|
||||
def test_full_health_handles_list_models_error(self, mock_get_ollama, client):
|
||||
"""Full health should handle list_models errors."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.health_check.return_value = True
|
||||
mock_client.list_models.side_effect = Exception("Connection error")
|
||||
mock_get_ollama.return_value = mock_client
|
||||
|
||||
response = client.get("/health/full")
|
||||
data = response.json()
|
||||
|
||||
# Should report error in component status
|
||||
assert "ollama" in data["components"]
|
||||
|
||||
@patch("src.controllers.health_controller.get_ollama_client")
|
||||
def test_full_health_handles_health_check_exception(self, mock_get_ollama, client):
|
||||
"""Full health should handle health check exceptions gracefully."""
|
||||
mock_client = AsyncMock()
|
||||
# Return False instead of raising exception to test unhealthy path
|
||||
mock_client.health_check.return_value = False
|
||||
mock_get_ollama.return_value = mock_client
|
||||
|
||||
response = client.get("/health/full")
|
||||
# Should return 503 for unhealthy
|
||||
assert response.status_code == 503
|
||||
data = response.json()
|
||||
assert data["status"] == "unhealthy"
|
||||
|
||||
|
||||
class TestDiagnosticsEndpoint:
|
||||
"""Test /health/diagnostics endpoint."""
|
||||
|
||||
@patch("src.controllers.health_controller.get_ollama_client")
|
||||
def test_diagnostics_returns_200(self, mock_get_ollama, client):
|
||||
"""Diagnostics should return 200."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.health_check.return_value = True
|
||||
mock_get_ollama.return_value = mock_client
|
||||
|
||||
response = client.get("/health/diagnostics")
|
||||
assert response.status_code == 200
|
||||
|
||||
@patch("src.controllers.health_controller.get_ollama_client")
|
||||
def test_diagnostics_returns_service_info(self, mock_get_ollama, client):
|
||||
"""Diagnostics should return service information."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.health_check.return_value = True
|
||||
mock_get_ollama.return_value = mock_client
|
||||
|
||||
response = client.get("/health/diagnostics")
|
||||
data = response.json()
|
||||
|
||||
assert "service" in data
|
||||
assert "name" in data["service"]
|
||||
assert "version" in data["service"]
|
||||
|
||||
@patch("src.controllers.health_controller.get_ollama_client")
|
||||
def test_diagnostics_returns_components(self, mock_get_ollama, client):
|
||||
"""Diagnostics should return component details."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.health_check.return_value = True
|
||||
mock_get_ollama.return_value = mock_client
|
||||
|
||||
response = client.get("/health/diagnostics")
|
||||
data = response.json()
|
||||
|
||||
assert "components" in data
|
||||
assert "ollama" in data["components"]
|
||||
assert "agent" in data["components"]
|
||||
assert "qdrant" in data["components"]
|
||||
|
||||
@patch("src.controllers.health_controller.get_ollama_client")
|
||||
def test_diagnostics_returns_configuration(self, mock_get_ollama, client):
|
||||
"""Diagnostics should return configuration info."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.health_check.return_value = True
|
||||
mock_get_ollama.return_value = mock_client
|
||||
|
||||
response = client.get("/health/diagnostics")
|
||||
data = response.json()
|
||||
|
||||
assert "configuration" in data
|
||||
|
||||
@patch("src.controllers.health_controller.get_ollama_client")
|
||||
def test_diagnostics_returns_response_time(self, mock_get_ollama, client):
|
||||
"""Diagnostics should return response time."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.health_check.return_value = True
|
||||
mock_get_ollama.return_value = mock_client
|
||||
|
||||
response = client.get("/health/diagnostics")
|
||||
data = response.json()
|
||||
|
||||
assert "response_time_ms" in data
|
||||
assert isinstance(data["response_time_ms"], int)
|
||||
|
||||
@patch("src.controllers.health_controller.get_ollama_client")
|
||||
def test_diagnostics_handles_ollama_error(self, mock_get_ollama, client):
|
||||
"""Diagnostics should handle Ollama connection errors."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.health_check.side_effect = Exception("Connection refused")
|
||||
mock_get_ollama.return_value = mock_client
|
||||
|
||||
response = client.get("/health/diagnostics")
|
||||
data = response.json()
|
||||
|
||||
# Should still return 200 with error info
|
||||
assert response.status_code == 200
|
||||
assert "error" in data["components"]["ollama"]
|
||||
|
||||
@@ -0,0 +1,471 @@
|
||||
"""Tests for Home Assistant client."""
|
||||
import pytest
|
||||
from unittest.mock import patch, AsyncMock, MagicMock
|
||||
import httpx
|
||||
|
||||
from src.clients.homeassistant_client import HomeAssistantClient, get_homeassistant_client
|
||||
|
||||
|
||||
class TestHomeAssistantClientInit:
|
||||
"""Test HomeAssistantClient initialization."""
|
||||
|
||||
@patch("src.clients.homeassistant_client.settings")
|
||||
def test_uses_settings_defaults(self, mock_settings):
|
||||
"""Client should use settings for defaults."""
|
||||
mock_settings.homeassistant_url = "http://ha.local:8123"
|
||||
mock_settings.homeassistant_token = "test_token"
|
||||
|
||||
client = HomeAssistantClient()
|
||||
|
||||
assert client.base_url == "http://ha.local:8123"
|
||||
assert client.token == "test_token"
|
||||
|
||||
def test_accepts_custom_url_and_token(self):
|
||||
"""Client should accept custom URL and token."""
|
||||
client = HomeAssistantClient(
|
||||
base_url="http://custom:8123",
|
||||
token="custom_token"
|
||||
)
|
||||
|
||||
assert client.base_url == "http://custom:8123"
|
||||
assert client.token == "custom_token"
|
||||
|
||||
def test_strips_trailing_slash_from_url(self):
|
||||
"""Client should strip trailing slash from URL."""
|
||||
client = HomeAssistantClient(
|
||||
base_url="http://custom:8123/",
|
||||
token="token"
|
||||
)
|
||||
|
||||
assert client.base_url == "http://custom:8123"
|
||||
|
||||
@patch("src.clients.homeassistant_client.logger")
|
||||
@patch("src.clients.homeassistant_client.settings")
|
||||
def test_warns_when_token_missing(self, mock_settings, mock_logger):
|
||||
"""Client should warn when token is not configured."""
|
||||
mock_settings.homeassistant_url = "http://ha.local:8123"
|
||||
mock_settings.homeassistant_token = ""
|
||||
|
||||
HomeAssistantClient()
|
||||
|
||||
mock_logger.warning.assert_called_once()
|
||||
|
||||
|
||||
class TestHomeAssistantClientHeaders:
|
||||
"""Test header generation."""
|
||||
|
||||
def test_get_headers_includes_bearer_token(self):
|
||||
"""Headers should include Bearer token."""
|
||||
client = HomeAssistantClient(
|
||||
base_url="http://ha:8123",
|
||||
token="my_token"
|
||||
)
|
||||
|
||||
headers = client._get_headers()
|
||||
|
||||
assert headers["Authorization"] == "Bearer my_token"
|
||||
assert headers["Content-Type"] == "application/json"
|
||||
|
||||
|
||||
class TestHomeAssistantClientHealthCheck:
|
||||
"""Test health check functionality."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_check_returns_healthy(self):
|
||||
"""Health check should return healthy when HA responds."""
|
||||
client = HomeAssistantClient(base_url="http://ha:8123", token="token")
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client_class:
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {"version": "2024.12.0"}
|
||||
|
||||
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.health_check()
|
||||
|
||||
assert result["status"] == "healthy"
|
||||
assert result["connected"] is True
|
||||
assert result["platform"] == "home_assistant"
|
||||
assert result["version"] == "2024.12.0"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_check_returns_unhealthy_on_error(self):
|
||||
"""Health check should return unhealthy on connection error."""
|
||||
client = HomeAssistantClient(base_url="http://ha:8123", token="token")
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client_class:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get.side_effect = Exception("Connection refused")
|
||||
mock_client.__aenter__.return_value = mock_client
|
||||
mock_client.__aexit__.return_value = None
|
||||
mock_client_class.return_value = mock_client
|
||||
|
||||
result = await client.health_check()
|
||||
|
||||
assert result["status"] == "unhealthy"
|
||||
assert result["connected"] is False
|
||||
assert "error" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_check_returns_unhealthy_on_non_200(self):
|
||||
"""Health check should return unhealthy on non-200 status."""
|
||||
client = HomeAssistantClient(base_url="http://ha:8123", token="token")
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client_class:
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 401
|
||||
|
||||
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.health_check()
|
||||
|
||||
assert result["status"] == "unhealthy"
|
||||
assert result["connected"] is False
|
||||
|
||||
|
||||
class TestHomeAssistantClientStates:
|
||||
"""Test state retrieval methods."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_states_returns_list(self):
|
||||
"""get_states should return list of states."""
|
||||
client = HomeAssistantClient(base_url="http://ha:8123", token="token")
|
||||
|
||||
states = [
|
||||
{"entity_id": "light.test", "state": "on"},
|
||||
{"entity_id": "switch.test", "state": "off"}
|
||||
]
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client_class:
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = states
|
||||
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_states()
|
||||
|
||||
assert result == states
|
||||
assert len(result) == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_state_returns_single_entity(self):
|
||||
"""get_state should return single entity state."""
|
||||
client = HomeAssistantClient(base_url="http://ha:8123", token="token")
|
||||
|
||||
state = {"entity_id": "light.test", "state": "on", "attributes": {}}
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client_class:
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = state
|
||||
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_state("light.test")
|
||||
|
||||
assert result == state
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_state_returns_none_for_404(self):
|
||||
"""get_state should return None for non-existent entity."""
|
||||
client = HomeAssistantClient(base_url="http://ha:8123", token="token")
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client_class:
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 404
|
||||
|
||||
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_state("light.nonexistent")
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestHomeAssistantClientServices:
|
||||
"""Test service call methods."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_service_posts_to_correct_endpoint(self):
|
||||
"""call_service should POST to /api/services/{domain}/{service}."""
|
||||
client = HomeAssistantClient(base_url="http://ha:8123", token="token")
|
||||
|
||||
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.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
|
||||
|
||||
await client.call_service("light", "turn_on", "light.test")
|
||||
|
||||
# Verify the correct URL was called
|
||||
call_args = mock_client.post.call_args
|
||||
assert "/api/services/light/turn_on" in call_args[0][0]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_turn_on_calls_correct_service(self):
|
||||
"""turn_on should call the turn_on service with attributes."""
|
||||
client = HomeAssistantClient(base_url="http://ha:8123", token="token")
|
||||
|
||||
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.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
|
||||
|
||||
await client.turn_on("light.test", brightness=128)
|
||||
|
||||
call_args = mock_client.post.call_args
|
||||
assert "/api/services/light/turn_on" in call_args[0][0]
|
||||
# Check that brightness was passed in the payload
|
||||
payload = call_args[1]["json"]
|
||||
assert payload["entity_id"] == "light.test"
|
||||
assert payload["brightness"] == 128
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_turn_off_calls_correct_service(self):
|
||||
"""turn_off should call the turn_off service."""
|
||||
client = HomeAssistantClient(base_url="http://ha:8123", token="token")
|
||||
|
||||
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.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
|
||||
|
||||
await client.turn_off("switch.test")
|
||||
|
||||
call_args = mock_client.post.call_args
|
||||
assert "/api/services/switch/turn_off" in call_args[0][0]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_toggle_calls_correct_service(self):
|
||||
"""toggle should call the toggle service."""
|
||||
client = HomeAssistantClient(base_url="http://ha:8123", token="token")
|
||||
|
||||
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.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
|
||||
|
||||
await client.toggle("light.test")
|
||||
|
||||
call_args = mock_client.post.call_args
|
||||
assert "/api/services/light/toggle" in call_args[0][0]
|
||||
|
||||
|
||||
class TestHomeAssistantClientScenes:
|
||||
"""Test scene methods."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_activate_scene_calls_scene_turn_on(self):
|
||||
"""activate_scene should call scene.turn_on service."""
|
||||
client = HomeAssistantClient(base_url="http://ha:8123", token="token")
|
||||
|
||||
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.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
|
||||
|
||||
await client.activate_scene("scene.movie_night")
|
||||
|
||||
call_args = mock_client.post.call_args
|
||||
assert "/api/services/scene/turn_on" in call_args[0][0]
|
||||
|
||||
|
||||
class TestHomeAssistantClientScripts:
|
||||
"""Test script methods."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_script_calls_script_turn_on(self):
|
||||
"""run_script should call script.turn_on service."""
|
||||
client = HomeAssistantClient(base_url="http://ha:8123", token="token")
|
||||
|
||||
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.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
|
||||
|
||||
await client.run_script("script.bedtime", {"delay": 5})
|
||||
|
||||
call_args = mock_client.post.call_args
|
||||
assert "/api/services/script/turn_on" in call_args[0][0]
|
||||
|
||||
|
||||
class TestHomeAssistantClientAutomations:
|
||||
"""Test automation methods."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enable_automation_calls_turn_on(self):
|
||||
"""enable_automation should call automation.turn_on."""
|
||||
client = HomeAssistantClient(base_url="http://ha:8123", token="token")
|
||||
|
||||
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.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
|
||||
|
||||
await client.enable_automation("automation.motion")
|
||||
|
||||
call_args = mock_client.post.call_args
|
||||
assert "/api/services/automation/turn_on" in call_args[0][0]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disable_automation_calls_turn_off(self):
|
||||
"""disable_automation should call automation.turn_off."""
|
||||
client = HomeAssistantClient(base_url="http://ha:8123", token="token")
|
||||
|
||||
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.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
|
||||
|
||||
await client.disable_automation("automation.motion")
|
||||
|
||||
call_args = mock_client.post.call_args
|
||||
assert "/api/services/automation/turn_off" in call_args[0][0]
|
||||
|
||||
|
||||
class TestHomeAssistantClientHistory:
|
||||
"""Test history methods."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_history_calls_correct_endpoint(self):
|
||||
"""get_history should call /api/history/period endpoint."""
|
||||
client = HomeAssistantClient(base_url="http://ha:8123", token="token")
|
||||
|
||||
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
|
||||
|
||||
await client.get_history("light.test", hours=24)
|
||||
|
||||
call_args = mock_client.get.call_args
|
||||
assert "/api/history/period/" in call_args[0][0]
|
||||
assert call_args[1]["params"]["filter_entity_id"] == "light.test"
|
||||
|
||||
|
||||
class TestHomeAssistantClientAreas:
|
||||
"""Test areas method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_areas_uses_template_api(self):
|
||||
"""get_areas should use the template API."""
|
||||
client = HomeAssistantClient(base_url="http://ha:8123", token="token")
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client_class:
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.text = '[{"id": "living_room", "name": "Living Room"}]'
|
||||
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.get_areas()
|
||||
|
||||
call_args = mock_client.post.call_args
|
||||
assert "/api/template" in call_args[0][0]
|
||||
assert result == [{"id": "living_room", "name": "Living Room"}]
|
||||
|
||||
|
||||
class TestGetHomeAssistantClientSingleton:
|
||||
"""Test singleton pattern."""
|
||||
|
||||
def test_returns_same_instance(self):
|
||||
"""get_homeassistant_client should return singleton."""
|
||||
# Reset singleton
|
||||
import src.clients.homeassistant_client as module
|
||||
module._homeassistant_client = None
|
||||
|
||||
client1 = get_homeassistant_client()
|
||||
client2 = get_homeassistant_client()
|
||||
|
||||
assert client1 is client2
|
||||
@@ -0,0 +1,655 @@
|
||||
"""Tests for housekeeping (home automation) endpoints."""
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from unittest.mock import patch, AsyncMock
|
||||
|
||||
from src.main import app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
"""Create a test client."""
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_ha_client():
|
||||
"""Create a mock Home Assistant client."""
|
||||
mock = AsyncMock()
|
||||
return mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_states():
|
||||
"""Sample Home Assistant states for testing."""
|
||||
return [
|
||||
{
|
||||
"entity_id": "light.living_room",
|
||||
"state": "on",
|
||||
"attributes": {
|
||||
"friendly_name": "Living Room Light",
|
||||
"brightness": 255,
|
||||
"area_id": "living_room"
|
||||
},
|
||||
"last_changed": "2025-01-01T12:00:00Z"
|
||||
},
|
||||
{
|
||||
"entity_id": "light.bedroom",
|
||||
"state": "off",
|
||||
"attributes": {
|
||||
"friendly_name": "Bedroom Light",
|
||||
"area_id": "bedroom"
|
||||
},
|
||||
"last_changed": "2025-01-01T11:00:00Z"
|
||||
},
|
||||
{
|
||||
"entity_id": "switch.garage",
|
||||
"state": "off",
|
||||
"attributes": {
|
||||
"friendly_name": "Garage Switch"
|
||||
},
|
||||
"last_changed": "2025-01-01T10:00:00Z"
|
||||
},
|
||||
{
|
||||
"entity_id": "scene.movie_night",
|
||||
"state": "scening",
|
||||
"attributes": {
|
||||
"friendly_name": "Movie Night"
|
||||
},
|
||||
"last_changed": "2025-01-01T09:00:00Z"
|
||||
},
|
||||
{
|
||||
"entity_id": "script.bedtime",
|
||||
"state": "off",
|
||||
"attributes": {
|
||||
"friendly_name": "Bedtime Routine"
|
||||
},
|
||||
"last_changed": "2025-01-01T08:00:00Z"
|
||||
},
|
||||
{
|
||||
"entity_id": "automation.motion_lights",
|
||||
"state": "on",
|
||||
"attributes": {
|
||||
"friendly_name": "Motion Lights"
|
||||
},
|
||||
"last_changed": "2025-01-01T07:00:00Z"
|
||||
},
|
||||
{
|
||||
"entity_id": "sensor.temperature",
|
||||
"state": "22.5",
|
||||
"attributes": {
|
||||
"friendly_name": "Temperature",
|
||||
"unit_of_measurement": "°C"
|
||||
},
|
||||
"last_changed": "2025-01-01T06:00:00Z"
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
class TestHousekeepingHealth:
|
||||
"""Test /housekeeping/health endpoint."""
|
||||
|
||||
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
|
||||
def test_health_returns_200(self, mock_get_ha, client):
|
||||
"""Health endpoint should return 200."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.health_check.return_value = {
|
||||
"status": "healthy",
|
||||
"connected": True,
|
||||
"platform": "home_assistant",
|
||||
"version": "2024.12.0"
|
||||
}
|
||||
mock_get_ha.return_value = mock_client
|
||||
|
||||
response = client.get("/housekeeping/health")
|
||||
assert response.status_code == 200
|
||||
|
||||
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
|
||||
def test_health_returns_connection_status(self, mock_get_ha, client):
|
||||
"""Health endpoint should return connection status."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.health_check.return_value = {
|
||||
"status": "healthy",
|
||||
"connected": True,
|
||||
"platform": "home_assistant",
|
||||
"version": "2024.12.0"
|
||||
}
|
||||
mock_get_ha.return_value = mock_client
|
||||
|
||||
response = client.get("/housekeeping/health")
|
||||
data = response.json()
|
||||
|
||||
assert "status" in data
|
||||
assert "connected" in data
|
||||
assert "platform" in data
|
||||
assert data["platform"] == "home_assistant"
|
||||
|
||||
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
|
||||
def test_health_returns_unhealthy_when_disconnected(self, mock_get_ha, client):
|
||||
"""Health should report unhealthy when HA is disconnected."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.health_check.return_value = {
|
||||
"status": "unhealthy",
|
||||
"connected": False,
|
||||
"platform": "home_assistant",
|
||||
"error": "Connection refused"
|
||||
}
|
||||
mock_get_ha.return_value = mock_client
|
||||
|
||||
response = client.get("/housekeeping/health")
|
||||
data = response.json()
|
||||
|
||||
assert data["connected"] is False
|
||||
assert data["status"] == "unhealthy"
|
||||
|
||||
|
||||
class TestHousekeepingDevices:
|
||||
"""Test /housekeeping/devices endpoints."""
|
||||
|
||||
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
|
||||
def test_list_devices_returns_200(self, mock_get_ha, client, sample_states):
|
||||
"""List devices should return 200."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get_states.return_value = sample_states
|
||||
mock_get_ha.return_value = mock_client
|
||||
|
||||
response = client.get("/housekeeping/devices")
|
||||
assert response.status_code == 200
|
||||
|
||||
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
|
||||
def test_list_devices_returns_controllable_only(self, mock_get_ha, client, sample_states):
|
||||
"""List devices should filter out non-controllable entities."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get_states.return_value = sample_states
|
||||
mock_get_ha.return_value = mock_client
|
||||
|
||||
response = client.get("/housekeeping/devices")
|
||||
data = response.json()
|
||||
|
||||
# Should include lights, switches, scenes, scripts, automations
|
||||
# Should NOT include sensors
|
||||
entity_ids = [d["entity_id"] for d in data["devices"]]
|
||||
assert "light.living_room" in entity_ids
|
||||
assert "switch.garage" in entity_ids
|
||||
assert "sensor.temperature" not in entity_ids
|
||||
|
||||
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
|
||||
def test_list_devices_filter_by_domain(self, mock_get_ha, client, sample_states):
|
||||
"""List devices should filter by domain parameter."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get_states.return_value = sample_states
|
||||
mock_get_ha.return_value = mock_client
|
||||
|
||||
response = client.get("/housekeeping/devices?domain=light")
|
||||
data = response.json()
|
||||
|
||||
# Should only return lights
|
||||
assert len(data["devices"]) == 2
|
||||
for device in data["devices"]:
|
||||
assert device["domain"] == "light"
|
||||
|
||||
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
|
||||
def test_list_devices_includes_attributes(self, mock_get_ha, client, sample_states):
|
||||
"""List devices should include device attributes."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get_states.return_value = sample_states
|
||||
mock_get_ha.return_value = mock_client
|
||||
|
||||
response = client.get("/housekeeping/devices")
|
||||
data = response.json()
|
||||
|
||||
# Find the living room light
|
||||
living_room = next(d for d in data["devices"] if d["entity_id"] == "light.living_room")
|
||||
assert living_room["name"] == "Living Room Light"
|
||||
assert living_room["state"] == "on"
|
||||
assert "brightness" in living_room["attributes"]
|
||||
|
||||
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
|
||||
def test_get_device_returns_200(self, mock_get_ha, client):
|
||||
"""Get device should return 200 for existing device."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get_state.return_value = {
|
||||
"entity_id": "light.living_room",
|
||||
"state": "on",
|
||||
"attributes": {
|
||||
"friendly_name": "Living Room Light",
|
||||
"brightness": 255
|
||||
},
|
||||
"last_changed": "2025-01-01T12:00:00Z"
|
||||
}
|
||||
mock_get_ha.return_value = mock_client
|
||||
|
||||
response = client.get("/housekeeping/devices/light.living_room")
|
||||
assert response.status_code == 200
|
||||
|
||||
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
|
||||
def test_get_device_returns_404_for_missing(self, mock_get_ha, client):
|
||||
"""Get device should return 404 for non-existent device."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get_state.return_value = None
|
||||
mock_get_ha.return_value = mock_client
|
||||
|
||||
response = client.get("/housekeeping/devices/light.nonexistent")
|
||||
assert response.status_code == 404
|
||||
|
||||
data = response.json()
|
||||
assert data["detail"]["code"] == "DEVICE_NOT_FOUND"
|
||||
|
||||
|
||||
class TestHousekeepingAreas:
|
||||
"""Test /housekeeping/areas endpoint."""
|
||||
|
||||
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
|
||||
def test_list_areas_returns_200(self, mock_get_ha, client):
|
||||
"""List areas should return 200."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get_areas.return_value = [
|
||||
{"id": "living_room", "name": "Living Room"},
|
||||
{"id": "bedroom", "name": "Bedroom"}
|
||||
]
|
||||
mock_get_ha.return_value = mock_client
|
||||
|
||||
response = client.get("/housekeeping/areas")
|
||||
assert response.status_code == 200
|
||||
|
||||
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
|
||||
def test_list_areas_returns_area_data(self, mock_get_ha, client):
|
||||
"""List areas should return area id and name."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get_areas.return_value = [
|
||||
{"id": "living_room", "name": "Living Room"},
|
||||
{"id": "bedroom", "name": "Bedroom"}
|
||||
]
|
||||
mock_get_ha.return_value = mock_client
|
||||
|
||||
response = client.get("/housekeeping/areas")
|
||||
data = response.json()
|
||||
|
||||
assert "areas" in data
|
||||
assert len(data["areas"]) == 2
|
||||
assert data["areas"][0]["id"] == "living_room"
|
||||
assert data["areas"][0]["name"] == "Living Room"
|
||||
|
||||
|
||||
class TestHousekeepingScenes:
|
||||
"""Test /housekeeping/scenes endpoints."""
|
||||
|
||||
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
|
||||
def test_list_scenes_returns_200(self, mock_get_ha, client, sample_states):
|
||||
"""List scenes should return 200."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get_states.return_value = sample_states
|
||||
mock_get_ha.return_value = mock_client
|
||||
|
||||
response = client.get("/housekeeping/scenes")
|
||||
assert response.status_code == 200
|
||||
|
||||
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
|
||||
def test_list_scenes_returns_only_scenes(self, mock_get_ha, client, sample_states):
|
||||
"""List scenes should only return scene entities."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get_states.return_value = sample_states
|
||||
mock_get_ha.return_value = mock_client
|
||||
|
||||
response = client.get("/housekeeping/scenes")
|
||||
data = response.json()
|
||||
|
||||
assert "scenes" in data
|
||||
assert len(data["scenes"]) == 1
|
||||
assert data["scenes"][0]["id"] == "scene.movie_night"
|
||||
assert data["scenes"][0]["name"] == "Movie Night"
|
||||
|
||||
|
||||
class TestHousekeepingScripts:
|
||||
"""Test /housekeeping/scripts endpoints."""
|
||||
|
||||
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
|
||||
def test_list_scripts_returns_200(self, mock_get_ha, client, sample_states):
|
||||
"""List scripts should return 200."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get_states.return_value = sample_states
|
||||
mock_get_ha.return_value = mock_client
|
||||
|
||||
response = client.get("/housekeeping/scripts")
|
||||
assert response.status_code == 200
|
||||
|
||||
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
|
||||
def test_list_scripts_returns_only_scripts(self, mock_get_ha, client, sample_states):
|
||||
"""List scripts should only return script entities."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get_states.return_value = sample_states
|
||||
mock_get_ha.return_value = mock_client
|
||||
|
||||
response = client.get("/housekeeping/scripts")
|
||||
data = response.json()
|
||||
|
||||
assert "scripts" in data
|
||||
assert len(data["scripts"]) == 1
|
||||
assert data["scripts"][0]["id"] == "script.bedtime"
|
||||
|
||||
|
||||
class TestHousekeepingAutomations:
|
||||
"""Test /housekeeping/automations endpoints."""
|
||||
|
||||
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
|
||||
def test_list_automations_returns_200(self, mock_get_ha, client, sample_states):
|
||||
"""List automations should return 200."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get_states.return_value = sample_states
|
||||
mock_get_ha.return_value = mock_client
|
||||
|
||||
response = client.get("/housekeeping/automations")
|
||||
assert response.status_code == 200
|
||||
|
||||
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
|
||||
def test_list_automations_includes_enabled_status(self, mock_get_ha, client, sample_states):
|
||||
"""List automations should include enabled status."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get_states.return_value = sample_states
|
||||
mock_get_ha.return_value = mock_client
|
||||
|
||||
response = client.get("/housekeeping/automations")
|
||||
data = response.json()
|
||||
|
||||
assert "automations" in data
|
||||
assert len(data["automations"]) == 1
|
||||
assert data["automations"][0]["id"] == "automation.motion_lights"
|
||||
assert data["automations"][0]["enabled"] is True
|
||||
|
||||
|
||||
class TestHousekeepingHistory:
|
||||
"""Test /housekeeping/history endpoint."""
|
||||
|
||||
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
|
||||
def test_history_returns_200(self, mock_get_ha, client):
|
||||
"""History endpoint should return 200."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get_history.return_value = [[
|
||||
{"state": "on", "last_changed": "2025-01-01T12:00:00Z", "attributes": {}},
|
||||
{"state": "off", "last_changed": "2025-01-01T11:00:00Z", "attributes": {}}
|
||||
]]
|
||||
mock_get_ha.return_value = mock_client
|
||||
|
||||
response = client.get("/housekeeping/history?entity_id=light.living_room")
|
||||
assert response.status_code == 200
|
||||
|
||||
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
|
||||
def test_history_returns_entries(self, mock_get_ha, client):
|
||||
"""History endpoint should return history entries."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get_history.return_value = [[
|
||||
{"state": "on", "last_changed": "2025-01-01T12:00:00Z", "attributes": {"brightness": 255}},
|
||||
{"state": "off", "last_changed": "2025-01-01T11:00:00Z", "attributes": {}}
|
||||
]]
|
||||
mock_get_ha.return_value = mock_client
|
||||
|
||||
response = client.get("/housekeeping/history?entity_id=light.living_room&hours=24")
|
||||
data = response.json()
|
||||
|
||||
assert "entity_id" in data
|
||||
assert "history" in data
|
||||
assert data["entity_id"] == "light.living_room"
|
||||
assert len(data["history"]) == 2
|
||||
|
||||
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
|
||||
def test_history_requires_entity_id(self, mock_get_ha, client):
|
||||
"""History endpoint should require entity_id parameter."""
|
||||
response = client.get("/housekeeping/history")
|
||||
assert response.status_code == 422 # Validation error
|
||||
|
||||
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
|
||||
def test_history_validates_hours_range(self, mock_get_ha, client):
|
||||
"""History endpoint should validate hours range (1-168)."""
|
||||
mock_client = AsyncMock()
|
||||
mock_get_ha.return_value = mock_client
|
||||
|
||||
# Too high
|
||||
response = client.get("/housekeeping/history?entity_id=light.test&hours=200")
|
||||
assert response.status_code == 422
|
||||
|
||||
# Too low
|
||||
response = client.get("/housekeeping/history?entity_id=light.test&hours=0")
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
class TestHousekeepingDeviceControl:
|
||||
"""Test /housekeeping/devices/{entity_id}/control endpoint."""
|
||||
|
||||
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
|
||||
def test_control_device_turn_on(self, mock_get_ha, client):
|
||||
"""Control should turn on device."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get_state.return_value = {
|
||||
"entity_id": "light.living_room",
|
||||
"state": "on",
|
||||
"attributes": {"brightness": 255}
|
||||
}
|
||||
mock_client.turn_on.return_value = [{"entity_id": "light.living_room"}]
|
||||
mock_get_ha.return_value = mock_client
|
||||
|
||||
response = client.post(
|
||||
"/housekeeping/devices/light.living_room/control",
|
||||
json={"action": "turn_on"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
|
||||
def test_control_device_turn_off(self, mock_get_ha, client):
|
||||
"""Control should turn off device."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get_state.return_value = {
|
||||
"entity_id": "light.living_room",
|
||||
"state": "off",
|
||||
"attributes": {}
|
||||
}
|
||||
mock_client.turn_off.return_value = [{"entity_id": "light.living_room"}]
|
||||
mock_get_ha.return_value = mock_client
|
||||
|
||||
response = client.post(
|
||||
"/housekeeping/devices/light.living_room/control",
|
||||
json={"action": "turn_off"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
|
||||
def test_control_device_toggle(self, mock_get_ha, client):
|
||||
"""Control should toggle device."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get_state.return_value = {
|
||||
"entity_id": "light.living_room",
|
||||
"state": "on",
|
||||
"attributes": {}
|
||||
}
|
||||
mock_client.toggle.return_value = [{"entity_id": "light.living_room"}]
|
||||
mock_get_ha.return_value = mock_client
|
||||
|
||||
response = client.post(
|
||||
"/housekeeping/devices/light.living_room/control",
|
||||
json={"action": "toggle"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
|
||||
def test_control_device_with_brightness(self, mock_get_ha, client):
|
||||
"""Control should set brightness."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get_state.return_value = {
|
||||
"entity_id": "light.living_room",
|
||||
"state": "on",
|
||||
"attributes": {"brightness": 128}
|
||||
}
|
||||
mock_client.turn_on.return_value = [{"entity_id": "light.living_room"}]
|
||||
mock_get_ha.return_value = mock_client
|
||||
|
||||
response = client.post(
|
||||
"/housekeeping/devices/light.living_room/control",
|
||||
json={"action": "turn_on", "brightness": 128}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
mock_client.turn_on.assert_called_once()
|
||||
|
||||
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
|
||||
def test_control_device_returns_404_for_missing(self, mock_get_ha, client):
|
||||
"""Control should return 404 for non-existent device."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get_state.return_value = None
|
||||
mock_get_ha.return_value = mock_client
|
||||
|
||||
response = client.post(
|
||||
"/housekeeping/devices/light.nonexistent/control",
|
||||
json={"action": "turn_on"}
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
|
||||
def test_control_device_returns_error_response(self, mock_get_ha, client):
|
||||
"""Control should return proper error response."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get_state.return_value = {
|
||||
"entity_id": "light.living_room",
|
||||
"state": "on",
|
||||
"attributes": {}
|
||||
}
|
||||
mock_client.turn_on.side_effect = Exception("Service unavailable")
|
||||
mock_get_ha.return_value = mock_client
|
||||
|
||||
response = client.post(
|
||||
"/housekeeping/devices/light.living_room/control",
|
||||
json={"action": "turn_on"}
|
||||
)
|
||||
assert response.status_code == 500
|
||||
|
||||
|
||||
class TestHousekeepingSceneActivation:
|
||||
"""Test /housekeeping/scenes/{scene_id}/activate endpoint."""
|
||||
|
||||
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
|
||||
def test_activate_scene_returns_200(self, mock_get_ha, client):
|
||||
"""Activate scene should return 200."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.activate_scene.return_value = [{"entity_id": "scene.movie_night"}]
|
||||
mock_get_ha.return_value = mock_client
|
||||
|
||||
response = client.post("/housekeeping/scenes/scene.movie_night/activate")
|
||||
assert response.status_code == 200
|
||||
|
||||
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
|
||||
def test_activate_scene_returns_success_response(self, mock_get_ha, client):
|
||||
"""Activate scene should return success response."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.activate_scene.return_value = [{"entity_id": "scene.movie_night"}]
|
||||
mock_get_ha.return_value = mock_client
|
||||
|
||||
response = client.post("/housekeeping/scenes/scene.movie_night/activate")
|
||||
data = response.json()
|
||||
|
||||
assert data["success"] is True
|
||||
assert data["scene_id"] == "scene.movie_night"
|
||||
|
||||
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
|
||||
def test_activate_scene_handles_error(self, mock_get_ha, client):
|
||||
"""Activate scene should handle errors."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.activate_scene.side_effect = Exception("Service error")
|
||||
mock_get_ha.return_value = mock_client
|
||||
|
||||
response = client.post("/housekeeping/scenes/scene.movie_night/activate")
|
||||
assert response.status_code == 500
|
||||
|
||||
|
||||
class TestHousekeepingScriptRun:
|
||||
"""Test /housekeeping/scripts/{script_id}/run endpoint."""
|
||||
|
||||
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
|
||||
def test_run_script_returns_200(self, mock_get_ha, client):
|
||||
"""Run script should return 200."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.run_script.return_value = [{"entity_id": "script.bedtime"}]
|
||||
mock_get_ha.return_value = mock_client
|
||||
|
||||
response = client.post("/housekeeping/scripts/script.bedtime/run")
|
||||
assert response.status_code == 200
|
||||
|
||||
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
|
||||
def test_run_script_returns_success_response(self, mock_get_ha, client):
|
||||
"""Run script should return success response."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.run_script.return_value = [{"entity_id": "script.bedtime"}]
|
||||
mock_get_ha.return_value = mock_client
|
||||
|
||||
response = client.post("/housekeeping/scripts/script.bedtime/run")
|
||||
data = response.json()
|
||||
|
||||
assert data["success"] is True
|
||||
assert data["script_id"] == "script.bedtime"
|
||||
|
||||
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
|
||||
def test_run_script_handles_error(self, mock_get_ha, client):
|
||||
"""Run script should handle errors."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.run_script.side_effect = Exception("Script error")
|
||||
mock_get_ha.return_value = mock_client
|
||||
|
||||
response = client.post("/housekeeping/scripts/script.bedtime/run")
|
||||
assert response.status_code == 500
|
||||
|
||||
|
||||
class TestHousekeepingAutomationToggle:
|
||||
"""Test /housekeeping/automations/{automation_id}/toggle endpoint."""
|
||||
|
||||
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
|
||||
def test_toggle_automation_enable(self, mock_get_ha, client):
|
||||
"""Toggle automation should enable when requested."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.enable_automation.return_value = [{"entity_id": "automation.motion_lights"}]
|
||||
mock_get_ha.return_value = mock_client
|
||||
|
||||
response = client.post(
|
||||
"/housekeeping/automations/automation.motion_lights/toggle",
|
||||
json={"enabled": True}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
|
||||
def test_toggle_automation_disable(self, mock_get_ha, client):
|
||||
"""Toggle automation should disable when requested."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.disable_automation.return_value = [{"entity_id": "automation.motion_lights"}]
|
||||
mock_get_ha.return_value = mock_client
|
||||
|
||||
response = client.post(
|
||||
"/housekeeping/automations/automation.motion_lights/toggle",
|
||||
json={"enabled": False}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
mock_client.disable_automation.assert_called_once()
|
||||
|
||||
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
|
||||
def test_toggle_automation_returns_new_state(self, mock_get_ha, client):
|
||||
"""Toggle automation should return new enabled state."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.enable_automation.return_value = [{"entity_id": "automation.motion_lights"}]
|
||||
mock_get_ha.return_value = mock_client
|
||||
|
||||
response = client.post(
|
||||
"/housekeeping/automations/automation.motion_lights/toggle",
|
||||
json={"enabled": True}
|
||||
)
|
||||
data = response.json()
|
||||
|
||||
assert data["success"] is True
|
||||
assert data["automation_id"] == "automation.motion_lights"
|
||||
assert data["enabled"] is True
|
||||
|
||||
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
|
||||
def test_toggle_automation_handles_error(self, mock_get_ha, client):
|
||||
"""Toggle automation should handle errors."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.enable_automation.side_effect = Exception("Automation error")
|
||||
mock_get_ha.return_value = mock_client
|
||||
|
||||
response = client.post(
|
||||
"/housekeeping/automations/automation.motion_lights/toggle",
|
||||
json={"enabled": True}
|
||||
)
|
||||
assert response.status_code == 500
|
||||
@@ -0,0 +1,780 @@
|
||||
"""Tests for infrastructure endpoints."""
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from unittest.mock import patch, AsyncMock, MagicMock
|
||||
|
||||
from src.main import app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
"""Create a test client."""
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_portainer():
|
||||
"""Create a mock Portainer client."""
|
||||
mock = AsyncMock()
|
||||
return mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_npm():
|
||||
"""Create a mock NPM client."""
|
||||
mock = AsyncMock()
|
||||
return mock
|
||||
|
||||
|
||||
class TestInfrastructureHealth:
|
||||
"""Test /infrastructure/health endpoint."""
|
||||
|
||||
@patch("src.controllers.infrastructure_controller.get_portainer_client")
|
||||
@patch("src.controllers.infrastructure_controller.get_npm_client")
|
||||
def test_health_returns_200(self, mock_get_npm, mock_get_portainer, client):
|
||||
"""Health endpoint should return 200."""
|
||||
mock_portainer = AsyncMock()
|
||||
mock_portainer.health_check.return_value = True
|
||||
mock_portainer.get_stacks.return_value = []
|
||||
mock_get_portainer.return_value = mock_portainer
|
||||
|
||||
mock_npm = AsyncMock()
|
||||
mock_npm.health_check.return_value = True
|
||||
mock_npm.get_proxy_hosts.return_value = []
|
||||
mock_get_npm.return_value = mock_npm
|
||||
|
||||
response = client.get("/infrastructure/health")
|
||||
assert response.status_code == 200
|
||||
|
||||
@patch("src.controllers.infrastructure_controller.get_portainer_client")
|
||||
@patch("src.controllers.infrastructure_controller.get_npm_client")
|
||||
def test_health_returns_connection_status(self, mock_get_npm, mock_get_portainer, client):
|
||||
"""Health endpoint should return connection status for both services."""
|
||||
mock_portainer = AsyncMock()
|
||||
mock_portainer.health_check.return_value = True
|
||||
mock_portainer.get_stacks.return_value = [{"Id": 1}, {"Id": 2}]
|
||||
mock_get_portainer.return_value = mock_portainer
|
||||
|
||||
mock_npm = AsyncMock()
|
||||
mock_npm.health_check.return_value = True
|
||||
mock_npm.get_proxy_hosts.return_value = [{"id": 1}]
|
||||
mock_get_npm.return_value = mock_npm
|
||||
|
||||
response = client.get("/infrastructure/health")
|
||||
data = response.json()
|
||||
|
||||
assert "portainer_connected" in data
|
||||
assert "npm_connected" in data
|
||||
assert "total_stacks" in data
|
||||
assert "total_proxy_hosts" in data
|
||||
assert data["portainer_connected"] is True
|
||||
assert data["npm_connected"] is True
|
||||
assert data["total_stacks"] == 2
|
||||
assert data["total_proxy_hosts"] == 1
|
||||
|
||||
@patch("src.controllers.infrastructure_controller.get_portainer_client")
|
||||
@patch("src.controllers.infrastructure_controller.get_npm_client")
|
||||
def test_health_handles_disconnected_services(self, mock_get_npm, mock_get_portainer, client):
|
||||
"""Health should handle when services are disconnected."""
|
||||
mock_portainer = AsyncMock()
|
||||
mock_portainer.health_check.return_value = False
|
||||
mock_get_portainer.return_value = mock_portainer
|
||||
|
||||
mock_npm = AsyncMock()
|
||||
mock_npm.health_check.return_value = False
|
||||
mock_get_npm.return_value = mock_npm
|
||||
|
||||
response = client.get("/infrastructure/health")
|
||||
data = response.json()
|
||||
|
||||
assert data["portainer_connected"] is False
|
||||
assert data["npm_connected"] is False
|
||||
assert data["total_stacks"] == 0
|
||||
assert data["total_proxy_hosts"] == 0
|
||||
|
||||
|
||||
class TestInfrastructureServices:
|
||||
"""Test /infrastructure/services endpoints."""
|
||||
|
||||
@patch("src.controllers.infrastructure_controller.get_portainer_client")
|
||||
@patch("src.controllers.infrastructure_controller.get_npm_client")
|
||||
def test_list_services_returns_200(self, mock_get_npm, mock_get_portainer, client):
|
||||
"""List services should return 200."""
|
||||
mock_portainer = AsyncMock()
|
||||
mock_portainer.get_stacks.return_value = []
|
||||
mock_get_portainer.return_value = mock_portainer
|
||||
|
||||
mock_npm = AsyncMock()
|
||||
mock_npm.get_proxy_hosts.return_value = []
|
||||
mock_get_npm.return_value = mock_npm
|
||||
|
||||
response = client.get("/infrastructure/services")
|
||||
assert response.status_code == 200
|
||||
|
||||
@patch("src.controllers.infrastructure_controller.get_portainer_client")
|
||||
@patch("src.controllers.infrastructure_controller.get_npm_client")
|
||||
def test_list_services_returns_stack_info(self, mock_get_npm, mock_get_portainer, client):
|
||||
"""List services should return stack information."""
|
||||
mock_portainer = AsyncMock()
|
||||
mock_portainer.get_stacks.return_value = [
|
||||
{"Id": 1, "Name": "stack1", "Status": 1, "EndpointId": 1},
|
||||
{"Id": 2, "Name": "stack2", "Status": 2, "EndpointId": 1}
|
||||
]
|
||||
mock_portainer.get_containers.return_value = []
|
||||
mock_get_portainer.return_value = mock_portainer
|
||||
|
||||
mock_npm = AsyncMock()
|
||||
mock_npm.get_proxy_hosts.return_value = []
|
||||
mock_get_npm.return_value = mock_npm
|
||||
|
||||
response = client.get("/infrastructure/services")
|
||||
data = response.json()
|
||||
|
||||
assert isinstance(data, list)
|
||||
assert len(data) == 2
|
||||
assert data[0]["name"] == "stack1"
|
||||
assert data[1]["name"] == "stack2"
|
||||
|
||||
|
||||
class TestInfrastructurePorts:
|
||||
"""Test /infrastructure/ports endpoint."""
|
||||
|
||||
@patch("src.controllers.infrastructure_controller.get_portainer_client")
|
||||
@patch("src.controllers.infrastructure_controller.get_npm_client")
|
||||
def test_list_ports_returns_200(self, mock_get_npm, mock_get_portainer, client):
|
||||
"""List ports should return 200."""
|
||||
mock_portainer = AsyncMock()
|
||||
mock_portainer.get_endpoints.return_value = [{"Id": 1}]
|
||||
mock_portainer.get_containers.return_value = []
|
||||
mock_get_portainer.return_value = mock_portainer
|
||||
|
||||
mock_npm = AsyncMock()
|
||||
mock_npm.get_proxy_hosts.return_value = []
|
||||
mock_get_npm.return_value = mock_npm
|
||||
|
||||
response = client.get("/infrastructure/ports")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
class TestInfrastructureDomains:
|
||||
"""Test /infrastructure/domains endpoint."""
|
||||
|
||||
@patch("src.controllers.infrastructure_controller.get_portainer_client")
|
||||
@patch("src.controllers.infrastructure_controller.get_npm_client")
|
||||
def test_list_domains_returns_200(self, mock_get_npm, mock_get_portainer, client):
|
||||
"""List domains should return 200."""
|
||||
mock_portainer = AsyncMock()
|
||||
mock_get_portainer.return_value = mock_portainer
|
||||
|
||||
mock_npm = AsyncMock()
|
||||
mock_npm.get_proxy_hosts.return_value = []
|
||||
mock_get_npm.return_value = mock_npm
|
||||
|
||||
response = client.get("/infrastructure/domains")
|
||||
assert response.status_code == 200
|
||||
|
||||
@patch("src.controllers.infrastructure_controller.get_portainer_client")
|
||||
@patch("src.controllers.infrastructure_controller.get_npm_client")
|
||||
def test_list_domains_returns_domain_info(self, mock_get_npm, mock_get_portainer, client):
|
||||
"""List domains should return domain information."""
|
||||
mock_portainer = AsyncMock()
|
||||
mock_get_portainer.return_value = mock_portainer
|
||||
|
||||
mock_npm = AsyncMock()
|
||||
mock_npm.get_proxy_hosts.return_value = [
|
||||
{
|
||||
"id": 1,
|
||||
"domain_names": ["example.com", "www.example.com"],
|
||||
"forward_host": "app",
|
||||
"forward_port": 8080,
|
||||
"ssl_certificate_id": 1
|
||||
}
|
||||
]
|
||||
mock_get_npm.return_value = mock_npm
|
||||
|
||||
response = client.get("/infrastructure/domains")
|
||||
data = response.json()
|
||||
|
||||
assert isinstance(data, list)
|
||||
# Each domain name should be a separate entry
|
||||
assert len(data) >= 1
|
||||
|
||||
|
||||
class TestInfrastructureContainers:
|
||||
"""Test /infrastructure/containers endpoint."""
|
||||
|
||||
@patch("src.controllers.infrastructure_controller.get_portainer_client")
|
||||
@patch("src.controllers.infrastructure_controller.get_npm_client")
|
||||
def test_list_containers_returns_200(self, mock_get_npm, mock_get_portainer, client):
|
||||
"""List containers should return 200."""
|
||||
mock_portainer = AsyncMock()
|
||||
mock_portainer.list_containers.return_value = []
|
||||
mock_get_portainer.return_value = mock_portainer
|
||||
|
||||
mock_npm = AsyncMock()
|
||||
mock_get_npm.return_value = mock_npm
|
||||
|
||||
response = client.get("/infrastructure/containers")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
class TestInfrastructureWidgetData:
|
||||
"""Test /infrastructure/widget-data endpoint."""
|
||||
|
||||
@patch("src.controllers.infrastructure_controller.get_portainer_client")
|
||||
@patch("src.controllers.infrastructure_controller.get_npm_client")
|
||||
def test_widget_data_returns_200(self, mock_get_npm, mock_get_portainer, client):
|
||||
"""Widget data should return 200."""
|
||||
mock_portainer = AsyncMock()
|
||||
mock_portainer.health_check.return_value = True
|
||||
mock_portainer.get_stacks.return_value = []
|
||||
mock_portainer.list_containers.return_value = []
|
||||
mock_get_portainer.return_value = mock_portainer
|
||||
|
||||
mock_npm = AsyncMock()
|
||||
mock_npm.health_check.return_value = True
|
||||
mock_npm.get_proxy_hosts.return_value = []
|
||||
mock_get_npm.return_value = mock_npm
|
||||
|
||||
response = client.get("/infrastructure/widget-data")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
class TestInfrastructureServiceGroups:
|
||||
"""Test /infrastructure/service-groups endpoint."""
|
||||
|
||||
@patch("src.controllers.infrastructure_controller.get_portainer_client")
|
||||
@patch("src.controllers.infrastructure_controller.get_npm_client")
|
||||
def test_service_groups_returns_200(self, mock_get_npm, mock_get_portainer, client):
|
||||
"""Service groups should return 200."""
|
||||
mock_portainer = AsyncMock()
|
||||
mock_get_portainer.return_value = mock_portainer
|
||||
|
||||
mock_npm = AsyncMock()
|
||||
mock_get_npm.return_value = mock_npm
|
||||
|
||||
response = client.get("/infrastructure/service-groups")
|
||||
assert response.status_code == 200
|
||||
|
||||
@patch("src.controllers.infrastructure_controller.get_portainer_client")
|
||||
@patch("src.controllers.infrastructure_controller.get_npm_client")
|
||||
def test_service_groups_returns_group_data(self, mock_get_npm, mock_get_portainer, client):
|
||||
"""Service groups should return group data."""
|
||||
mock_portainer = AsyncMock()
|
||||
mock_get_portainer.return_value = mock_portainer
|
||||
|
||||
mock_npm = AsyncMock()
|
||||
mock_get_npm.return_value = mock_npm
|
||||
|
||||
response = client.get("/infrastructure/service-groups")
|
||||
data = response.json()
|
||||
|
||||
# Should return a dict or list of service groups
|
||||
assert isinstance(data, (dict, list))
|
||||
|
||||
|
||||
class TestInfrastructureResources:
|
||||
"""Test /infrastructure/resources endpoints."""
|
||||
|
||||
@patch("src.controllers.infrastructure_controller.get_portainer_client")
|
||||
@patch("src.controllers.infrastructure_controller.get_npm_client")
|
||||
def test_system_resources_returns_200(self, mock_get_npm, mock_get_portainer, client):
|
||||
"""System resources should return 200."""
|
||||
mock_portainer = AsyncMock()
|
||||
mock_get_portainer.return_value = mock_portainer
|
||||
|
||||
mock_npm = AsyncMock()
|
||||
mock_get_npm.return_value = mock_npm
|
||||
|
||||
response = client.get("/infrastructure/resources/system")
|
||||
assert response.status_code == 200
|
||||
|
||||
@patch("src.controllers.infrastructure_controller.get_portainer_client")
|
||||
@patch("src.controllers.infrastructure_controller.get_npm_client")
|
||||
def test_container_resources_returns_200(self, mock_get_npm, mock_get_portainer, client):
|
||||
"""Container resources should return 200."""
|
||||
mock_portainer = AsyncMock()
|
||||
mock_portainer.list_containers.return_value = []
|
||||
mock_get_portainer.return_value = mock_portainer
|
||||
|
||||
mock_npm = AsyncMock()
|
||||
mock_get_npm.return_value = mock_npm
|
||||
|
||||
response = client.get("/infrastructure/resources/containers")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
class TestInfrastructureServiceStatus:
|
||||
"""Test /infrastructure/services/{service}/status endpoint."""
|
||||
|
||||
@patch("src.controllers.infrastructure_controller.get_portainer_client")
|
||||
@patch("src.controllers.infrastructure_controller.get_npm_client")
|
||||
def test_service_status_returns_200(self, mock_get_npm, mock_get_portainer, client):
|
||||
"""Service status should return 200."""
|
||||
mock_portainer = AsyncMock()
|
||||
mock_portainer.get_stacks.return_value = [
|
||||
{"Id": 1, "Name": "testservice", "Status": 1, "EndpointId": 1}
|
||||
]
|
||||
mock_portainer.get_containers.return_value = [
|
||||
{"Names": ["/testservice_app_1"], "State": "running"}
|
||||
]
|
||||
mock_get_portainer.return_value = mock_portainer
|
||||
|
||||
mock_npm = AsyncMock()
|
||||
mock_npm.get_proxy_hosts.return_value = []
|
||||
mock_get_npm.return_value = mock_npm
|
||||
|
||||
response = client.get("/infrastructure/services/testservice/status")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
class TestInfrastructureContainerActions:
|
||||
"""Test container action endpoints."""
|
||||
|
||||
@patch("src.controllers.infrastructure_controller.get_portainer_client")
|
||||
@patch("src.controllers.infrastructure_controller.get_npm_client")
|
||||
def test_get_container_logs_returns_200(self, mock_get_npm, mock_get_portainer, client):
|
||||
"""Get container logs should return 200."""
|
||||
mock_portainer = AsyncMock()
|
||||
mock_portainer.list_containers.return_value = [
|
||||
{"Names": ["/testcontainer"], "Id": "abc123"}
|
||||
]
|
||||
mock_portainer.get_endpoints.return_value = [{"Id": 1}]
|
||||
mock_get_portainer.return_value = mock_portainer
|
||||
|
||||
mock_npm = AsyncMock()
|
||||
mock_get_npm.return_value = mock_npm
|
||||
|
||||
response = client.get("/infrastructure/containers/testcontainer/logs")
|
||||
# Response depends on container existence
|
||||
assert response.status_code in [200, 404]
|
||||
|
||||
@patch("src.controllers.infrastructure_controller.get_portainer_client")
|
||||
@patch("src.controllers.infrastructure_controller.get_npm_client")
|
||||
def test_get_single_container_returns_200(self, mock_get_npm, mock_get_portainer, client):
|
||||
"""Get single container should return 200."""
|
||||
mock_portainer = AsyncMock()
|
||||
mock_portainer.inspect_container.return_value = {
|
||||
"Id": "abc123",
|
||||
"Name": "/testcontainer",
|
||||
"State": {"Status": "running"}
|
||||
}
|
||||
mock_get_portainer.return_value = mock_portainer
|
||||
|
||||
mock_npm = AsyncMock()
|
||||
mock_get_npm.return_value = mock_npm
|
||||
|
||||
response = client.get("/infrastructure/containers/testcontainer")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
class TestInfrastructureGetService:
|
||||
"""Test /infrastructure/services/{name} endpoint."""
|
||||
|
||||
@patch("src.controllers.infrastructure_controller.get_portainer_client")
|
||||
@patch("src.controllers.infrastructure_controller.get_npm_client")
|
||||
def test_get_service_returns_200(self, mock_get_npm, mock_get_portainer, client):
|
||||
"""Get service should return 200."""
|
||||
mock_portainer = AsyncMock()
|
||||
mock_portainer.get_stacks.return_value = [
|
||||
{"Id": 1, "Name": "testservice", "Status": 1, "EndpointId": 1}
|
||||
]
|
||||
mock_portainer.get_containers.return_value = []
|
||||
mock_get_portainer.return_value = mock_portainer
|
||||
|
||||
mock_npm = AsyncMock()
|
||||
mock_npm.get_proxy_hosts.return_value = []
|
||||
mock_get_npm.return_value = mock_npm
|
||||
|
||||
response = client.get("/infrastructure/services/testservice")
|
||||
assert response.status_code == 200
|
||||
|
||||
@patch("src.controllers.infrastructure_controller.get_portainer_client")
|
||||
@patch("src.controllers.infrastructure_controller.get_npm_client")
|
||||
def test_get_service_returns_404_for_missing(self, mock_get_npm, mock_get_portainer, client):
|
||||
"""Get service should return 404 for non-existent service."""
|
||||
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/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
|
||||
@@ -0,0 +1,398 @@
|
||||
"""Tests for NPM client."""
|
||||
import pytest
|
||||
from unittest.mock import patch, AsyncMock, MagicMock
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from src.clients.npm_client import NPMClient, get_npm_client
|
||||
|
||||
|
||||
class TestNPMClientInit:
|
||||
"""Test NPMClient initialization."""
|
||||
|
||||
@patch("src.clients.npm_client.settings")
|
||||
def test_uses_settings_defaults(self, mock_settings):
|
||||
"""Client should use settings for defaults."""
|
||||
mock_settings.npm_url = "http://npm:81"
|
||||
mock_settings.npm_email = "admin@example.com"
|
||||
mock_settings.npm_password = "password123"
|
||||
|
||||
client = NPMClient()
|
||||
|
||||
assert client.base_url == "http://npm:81"
|
||||
assert client.email == "admin@example.com"
|
||||
assert client.password == "password123"
|
||||
|
||||
def test_accepts_custom_credentials(self):
|
||||
"""Client should accept custom credentials."""
|
||||
client = NPMClient(
|
||||
base_url="http://custom:81",
|
||||
email="custom@example.com",
|
||||
password="custom_pass"
|
||||
)
|
||||
|
||||
assert client.base_url == "http://custom:81"
|
||||
assert client.email == "custom@example.com"
|
||||
assert client.password == "custom_pass"
|
||||
|
||||
def test_strips_trailing_slash_from_url(self):
|
||||
"""Client should strip trailing slash from URL."""
|
||||
client = NPMClient(
|
||||
base_url="http://npm:81/",
|
||||
email="test@test.com",
|
||||
password="pass"
|
||||
)
|
||||
|
||||
assert client.base_url == "http://npm:81"
|
||||
|
||||
@patch("src.clients.npm_client.logger")
|
||||
@patch("src.clients.npm_client.settings")
|
||||
def test_warns_when_credentials_missing(self, mock_settings, mock_logger):
|
||||
"""Client should warn when credentials are not configured."""
|
||||
mock_settings.npm_url = "http://npm:81"
|
||||
mock_settings.npm_email = ""
|
||||
mock_settings.npm_password = ""
|
||||
|
||||
NPMClient()
|
||||
|
||||
mock_logger.warning.assert_called_once()
|
||||
|
||||
def test_initializes_token_as_none(self):
|
||||
"""Client should initialize token as None."""
|
||||
client = NPMClient(
|
||||
base_url="http://npm:81",
|
||||
email="test@test.com",
|
||||
password="pass"
|
||||
)
|
||||
|
||||
assert client._token is None
|
||||
assert client._token_expires is None
|
||||
|
||||
|
||||
class TestNPMClientHeaders:
|
||||
"""Test header generation."""
|
||||
|
||||
def test_get_headers_raises_without_token(self):
|
||||
"""Headers should raise if no token available."""
|
||||
client = NPMClient(
|
||||
base_url="http://npm:81",
|
||||
email="test@test.com",
|
||||
password="pass"
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="No NPM token available"):
|
||||
client._get_headers()
|
||||
|
||||
def test_get_headers_includes_bearer_token(self):
|
||||
"""Headers should include Bearer token when available."""
|
||||
client = NPMClient(
|
||||
base_url="http://npm:81",
|
||||
email="test@test.com",
|
||||
password="pass"
|
||||
)
|
||||
client._token = "test_token_123"
|
||||
|
||||
headers = client._get_headers()
|
||||
|
||||
assert headers["Authorization"] == "Bearer test_token_123"
|
||||
assert headers["Content-Type"] == "application/json"
|
||||
|
||||
|
||||
class TestNPMClientToken:
|
||||
"""Test token management."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_token_stores_token(self):
|
||||
"""_refresh_token should store token from response."""
|
||||
client = NPMClient(
|
||||
base_url="http://npm:81",
|
||||
email="test@test.com",
|
||||
password="pass"
|
||||
)
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client_class:
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {"token": "new_token_abc"}
|
||||
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
|
||||
|
||||
await client._refresh_token()
|
||||
|
||||
assert client._token == "new_token_abc"
|
||||
assert client._token_expires is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ensure_token_refreshes_when_none(self):
|
||||
"""_ensure_token should refresh when no token."""
|
||||
client = NPMClient(
|
||||
base_url="http://npm:81",
|
||||
email="test@test.com",
|
||||
password="pass"
|
||||
)
|
||||
|
||||
with patch.object(client, "_refresh_token", new_callable=AsyncMock) as mock_refresh:
|
||||
await client._ensure_token()
|
||||
mock_refresh.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ensure_token_skips_refresh_when_valid(self):
|
||||
"""_ensure_token should skip refresh when token is valid."""
|
||||
client = NPMClient(
|
||||
base_url="http://npm:81",
|
||||
email="test@test.com",
|
||||
password="pass"
|
||||
)
|
||||
client._token = "valid_token"
|
||||
client._token_expires = datetime.now() + timedelta(hours=12)
|
||||
|
||||
with patch.object(client, "_refresh_token", new_callable=AsyncMock) as mock_refresh:
|
||||
await client._ensure_token()
|
||||
mock_refresh.assert_not_called()
|
||||
|
||||
|
||||
class TestNPMClientHealthCheck:
|
||||
"""Test health check functionality."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_check_returns_true_on_200(self):
|
||||
"""Health check should return True when NPM responds 200."""
|
||||
client = NPMClient(base_url="http://npm:81", email="test@test.com", password="pass")
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client_class:
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
|
||||
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.health_check()
|
||||
|
||||
assert result is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_check_returns_true_on_redirect(self):
|
||||
"""Health check should return True on redirect (3xx)."""
|
||||
client = NPMClient(base_url="http://npm:81", email="test@test.com", password="pass")
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client_class:
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 302
|
||||
|
||||
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.health_check()
|
||||
|
||||
assert result is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_check_returns_false_on_error(self):
|
||||
"""Health check should return False on connection error."""
|
||||
client = NPMClient(base_url="http://npm:81", email="test@test.com", password="pass")
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client_class:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get.side_effect = Exception("Connection refused")
|
||||
mock_client.__aenter__.return_value = mock_client
|
||||
mock_client.__aexit__.return_value = None
|
||||
mock_client_class.return_value = mock_client
|
||||
|
||||
result = await client.health_check()
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
class TestNPMClientProxyHosts:
|
||||
"""Test proxy host operations."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_proxy_hosts_returns_list(self):
|
||||
"""get_proxy_hosts should return list of proxy hosts."""
|
||||
client = NPMClient(base_url="http://npm:81", email="test@test.com", password="pass")
|
||||
client._token = "test_token"
|
||||
client._token_expires = datetime.now() + timedelta(hours=12)
|
||||
|
||||
proxy_hosts = [
|
||||
{"id": 1, "domain_names": ["example.com"]},
|
||||
{"id": 2, "domain_names": ["test.com"]}
|
||||
]
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client_class:
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = proxy_hosts
|
||||
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_proxy_hosts()
|
||||
|
||||
assert result == proxy_hosts
|
||||
assert len(result) == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_proxy_host_returns_single_host(self):
|
||||
"""get_proxy_host should return single proxy host."""
|
||||
client = NPMClient(base_url="http://npm:81", email="test@test.com", password="pass")
|
||||
client._token = "test_token"
|
||||
client._token_expires = datetime.now() + timedelta(hours=12)
|
||||
|
||||
proxy_host = {"id": 1, "domain_names": ["example.com"], "forward_host": "app"}
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client_class:
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = proxy_host
|
||||
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_proxy_host(1)
|
||||
|
||||
assert result == proxy_host
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_proxy_host_posts_correct_data(self):
|
||||
"""create_proxy_host should POST with correct payload."""
|
||||
client = NPMClient(base_url="http://npm:81", email="test@test.com", password="pass")
|
||||
client._token = "test_token"
|
||||
client._token_expires = datetime.now() + timedelta(hours=12)
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client_class:
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {"id": 1, "domain_names": ["new.com"]}
|
||||
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
|
||||
|
||||
await client.create_proxy_host(
|
||||
domain_names=["new.com"],
|
||||
forward_host="backend",
|
||||
forward_port=8080
|
||||
)
|
||||
|
||||
call_args = mock_client.post.call_args
|
||||
assert call_args[1]["json"]["domain_names"] == ["new.com"]
|
||||
assert call_args[1]["json"]["forward_host"] == "backend"
|
||||
assert call_args[1]["json"]["forward_port"] == 8080
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_proxy_host_puts_correct_data(self):
|
||||
"""update_proxy_host should PUT with correct payload."""
|
||||
client = NPMClient(base_url="http://npm:81", email="test@test.com", password="pass")
|
||||
client._token = "test_token"
|
||||
client._token_expires = datetime.now() + timedelta(hours=12)
|
||||
|
||||
config = {"domain_names": ["updated.com"], "forward_host": "new-backend"}
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client_class:
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.is_success = True
|
||||
mock_response.json.return_value = config
|
||||
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.update_proxy_host(1, config)
|
||||
|
||||
assert result == config
|
||||
|
||||
|
||||
class TestNPMClientCertificates:
|
||||
"""Test certificate operations."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_certificates_returns_list(self):
|
||||
"""get_certificates should return list of certificates."""
|
||||
client = NPMClient(base_url="http://npm:81", email="test@test.com", password="pass")
|
||||
client._token = "test_token"
|
||||
client._token_expires = datetime.now() + timedelta(hours=12)
|
||||
|
||||
certificates = [
|
||||
{"id": 1, "domain_names": ["example.com"]},
|
||||
{"id": 2, "domain_names": ["test.com"]}
|
||||
]
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client_class:
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = certificates
|
||||
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_certificates()
|
||||
|
||||
assert result == certificates
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_certificate_posts_correct_data(self):
|
||||
"""create_certificate should POST with correct payload."""
|
||||
client = NPMClient(base_url="http://npm:81", email="test@test.com", password="pass")
|
||||
client._token = "test_token"
|
||||
client._token_expires = datetime.now() + timedelta(hours=12)
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client_class:
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {"id": 1, "domain_names": ["secure.com"]}
|
||||
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
|
||||
|
||||
await client.create_certificate(domain_names=["secure.com"])
|
||||
|
||||
call_args = mock_client.post.call_args
|
||||
assert call_args[1]["json"]["domain_names"] == ["secure.com"]
|
||||
assert call_args[1]["json"]["provider"] == "letsencrypt"
|
||||
|
||||
|
||||
class TestNPMClientSingleton:
|
||||
"""Test singleton pattern."""
|
||||
|
||||
def test_returns_same_instance(self):
|
||||
"""get_npm_client should return singleton."""
|
||||
import src.clients.npm_client as module
|
||||
module._npm_client = None
|
||||
|
||||
client1 = get_npm_client()
|
||||
client2 = get_npm_client()
|
||||
|
||||
assert client1 is client2
|
||||
@@ -0,0 +1,167 @@
|
||||
"""Tests for OIDC authentication module."""
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock, AsyncMock
|
||||
from fastapi import HTTPException
|
||||
|
||||
from src.auth.oidc import OIDCConfig, oidc_config, get_jwks, get_current_user
|
||||
|
||||
|
||||
class TestOIDCConfig:
|
||||
"""Test OIDCConfig class."""
|
||||
|
||||
def test_init_defaults(self):
|
||||
"""Config should initialize with disabled state."""
|
||||
config = OIDCConfig()
|
||||
|
||||
assert config.enabled is False
|
||||
assert config.issuer == ""
|
||||
assert config.audience == ""
|
||||
assert config.jwks_uri == ""
|
||||
|
||||
def test_configure_sets_values(self):
|
||||
"""configure should set all values."""
|
||||
config = OIDCConfig()
|
||||
config.configure(
|
||||
enabled=True,
|
||||
issuer="https://auth.example.com",
|
||||
audience="core-api"
|
||||
)
|
||||
|
||||
assert config.enabled is True
|
||||
assert config.issuer == "https://auth.example.com"
|
||||
assert config.audience == "core-api"
|
||||
assert config.jwks_uri == "https://auth.example.com/jwks/"
|
||||
|
||||
def test_configure_strips_trailing_slash(self):
|
||||
"""configure should handle trailing slash in issuer."""
|
||||
config = OIDCConfig()
|
||||
config.configure(
|
||||
enabled=True,
|
||||
issuer="https://auth.example.com/",
|
||||
audience="core-api"
|
||||
)
|
||||
|
||||
assert config.jwks_uri == "https://auth.example.com/jwks/"
|
||||
|
||||
|
||||
class TestGetJWKS:
|
||||
"""Test get_jwks function."""
|
||||
|
||||
def test_returns_empty_when_disabled(self):
|
||||
"""get_jwks should return empty dict when OIDC disabled."""
|
||||
# Save original state
|
||||
original_enabled = oidc_config.enabled
|
||||
|
||||
try:
|
||||
oidc_config.enabled = False
|
||||
# Clear the cache
|
||||
get_jwks.cache_clear()
|
||||
|
||||
result = get_jwks()
|
||||
|
||||
assert result == {}
|
||||
finally:
|
||||
# Restore original state
|
||||
oidc_config.enabled = original_enabled
|
||||
get_jwks.cache_clear()
|
||||
|
||||
@patch("src.auth.oidc.httpx.get")
|
||||
def test_fetches_jwks_when_enabled(self, mock_get):
|
||||
"""get_jwks should fetch JWKS when enabled."""
|
||||
# Save original state
|
||||
original_enabled = oidc_config.enabled
|
||||
original_jwks_uri = oidc_config.jwks_uri
|
||||
|
||||
try:
|
||||
oidc_config.enabled = True
|
||||
oidc_config.jwks_uri = "https://auth.example.com/jwks/"
|
||||
get_jwks.cache_clear()
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {"keys": [{"kid": "test"}]}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
result = get_jwks()
|
||||
|
||||
assert "keys" in result
|
||||
mock_get.assert_called_once()
|
||||
finally:
|
||||
oidc_config.enabled = original_enabled
|
||||
oidc_config.jwks_uri = original_jwks_uri
|
||||
get_jwks.cache_clear()
|
||||
|
||||
@patch("src.auth.oidc.httpx.get")
|
||||
def test_raises_exception_on_error(self, mock_get):
|
||||
"""get_jwks should raise HTTPException on fetch error."""
|
||||
# Save original state
|
||||
original_enabled = oidc_config.enabled
|
||||
original_jwks_uri = oidc_config.jwks_uri
|
||||
|
||||
try:
|
||||
oidc_config.enabled = True
|
||||
oidc_config.jwks_uri = "https://auth.example.com/jwks/"
|
||||
get_jwks.cache_clear()
|
||||
|
||||
mock_get.side_effect = Exception("Connection error")
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
get_jwks()
|
||||
|
||||
assert exc_info.value.status_code == 503
|
||||
finally:
|
||||
oidc_config.enabled = original_enabled
|
||||
oidc_config.jwks_uri = original_jwks_uri
|
||||
get_jwks.cache_clear()
|
||||
|
||||
|
||||
class TestGetCurrentUser:
|
||||
"""Test get_current_user function."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_none_when_disabled(self):
|
||||
"""get_current_user should return None when OIDC disabled."""
|
||||
# Save original state
|
||||
original_enabled = oidc_config.enabled
|
||||
|
||||
try:
|
||||
oidc_config.enabled = False
|
||||
|
||||
result = await get_current_user(credentials=None)
|
||||
|
||||
assert result is None
|
||||
finally:
|
||||
oidc_config.enabled = original_enabled
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_raises_401_when_enabled_without_token(self):
|
||||
"""get_current_user should raise 401 when enabled but no token."""
|
||||
# Save original state
|
||||
original_enabled = oidc_config.enabled
|
||||
|
||||
try:
|
||||
oidc_config.enabled = True
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_current_user(credentials=None)
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
finally:
|
||||
oidc_config.enabled = original_enabled
|
||||
|
||||
|
||||
class TestOIDCGlobalConfig:
|
||||
"""Test global OIDC config."""
|
||||
|
||||
def test_global_config_exists(self):
|
||||
"""oidc_config should be an OIDCConfig instance."""
|
||||
assert isinstance(oidc_config, OIDCConfig)
|
||||
|
||||
def test_global_config_starts_disabled(self):
|
||||
"""oidc_config should start disabled by default."""
|
||||
# This tests the initial state before any configure() is called
|
||||
# The actual state depends on app configuration
|
||||
assert hasattr(oidc_config, 'enabled')
|
||||
assert hasattr(oidc_config, 'issuer')
|
||||
assert hasattr(oidc_config, 'audience')
|
||||
assert hasattr(oidc_config, 'jwks_uri')
|
||||
@@ -0,0 +1,307 @@
|
||||
"""Tests for Ollama client."""
|
||||
import pytest
|
||||
from unittest.mock import patch, AsyncMock, MagicMock
|
||||
import json
|
||||
|
||||
from src.models.ollama_client import OllamaClient, get_ollama_client, close_ollama_client
|
||||
|
||||
|
||||
class TestOllamaClientInit:
|
||||
"""Test OllamaClient initialization."""
|
||||
|
||||
@patch("src.models.ollama_client.settings")
|
||||
def test_uses_settings_defaults(self, mock_settings):
|
||||
"""Client should use settings for defaults."""
|
||||
mock_settings.ollama_base_url = "http://ollama:11434"
|
||||
mock_settings.ollama_timeout = 60
|
||||
|
||||
client = OllamaClient()
|
||||
|
||||
assert client.base_url == "http://ollama:11434"
|
||||
assert client.timeout == 60
|
||||
|
||||
@patch("src.models.ollama_client.settings")
|
||||
def test_creates_http_client(self, mock_settings):
|
||||
"""Client should create httpx AsyncClient."""
|
||||
mock_settings.ollama_base_url = "http://ollama:11434"
|
||||
mock_settings.ollama_timeout = 30
|
||||
|
||||
client = OllamaClient()
|
||||
|
||||
assert client.client is not None
|
||||
|
||||
|
||||
class TestOllamaClientClose:
|
||||
"""Test client close functionality."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("src.models.ollama_client.settings")
|
||||
async def test_close_closes_client(self, mock_settings):
|
||||
"""close should close the HTTP client."""
|
||||
mock_settings.ollama_base_url = "http://ollama:11434"
|
||||
mock_settings.ollama_timeout = 30
|
||||
|
||||
client = OllamaClient()
|
||||
|
||||
with patch.object(client.client, "aclose", new_callable=AsyncMock) as mock_close:
|
||||
await client.close()
|
||||
mock_close.assert_called_once()
|
||||
|
||||
|
||||
class TestOllamaClientResolveModel:
|
||||
"""Test model resolution."""
|
||||
|
||||
@patch("src.models.ollama_client.settings")
|
||||
def test_resolves_aliased_model(self, mock_settings):
|
||||
"""resolve_model should map alias to actual model."""
|
||||
mock_settings.ollama_base_url = "http://ollama:11434"
|
||||
mock_settings.ollama_timeout = 30
|
||||
mock_settings.model_aliases = {"gpt-3.5-turbo": "gemma:7b"}
|
||||
|
||||
client = OllamaClient()
|
||||
result = client.resolve_model("gpt-3.5-turbo")
|
||||
|
||||
assert result == "gemma:7b"
|
||||
|
||||
@patch("src.models.ollama_client.settings")
|
||||
def test_returns_original_if_no_alias(self, mock_settings):
|
||||
"""resolve_model should return original if no alias found."""
|
||||
mock_settings.ollama_base_url = "http://ollama:11434"
|
||||
mock_settings.ollama_timeout = 30
|
||||
mock_settings.model_aliases = {}
|
||||
|
||||
client = OllamaClient()
|
||||
result = client.resolve_model("llama2")
|
||||
|
||||
assert result == "llama2"
|
||||
|
||||
|
||||
class TestOllamaClientHealthCheck:
|
||||
"""Test health check functionality."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("src.models.ollama_client.settings")
|
||||
async def test_health_check_returns_true_on_200(self, mock_settings):
|
||||
"""Health check should return True when Ollama responds 200."""
|
||||
mock_settings.ollama_base_url = "http://ollama:11434"
|
||||
mock_settings.ollama_timeout = 30
|
||||
|
||||
client = OllamaClient()
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
|
||||
with patch.object(client.client, "get", new_callable=AsyncMock) as mock_get:
|
||||
mock_get.return_value = mock_response
|
||||
result = await client.health_check()
|
||||
|
||||
assert result is True
|
||||
mock_get.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("src.models.ollama_client.settings")
|
||||
async def test_health_check_returns_false_on_error(self, mock_settings):
|
||||
"""Health check should return False on connection error."""
|
||||
mock_settings.ollama_base_url = "http://ollama:11434"
|
||||
mock_settings.ollama_timeout = 30
|
||||
|
||||
client = OllamaClient()
|
||||
|
||||
with patch.object(client.client, "get", new_callable=AsyncMock) as mock_get:
|
||||
mock_get.side_effect = Exception("Connection refused")
|
||||
result = await client.health_check()
|
||||
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("src.models.ollama_client.settings")
|
||||
async def test_health_check_returns_false_on_non_200(self, mock_settings):
|
||||
"""Health check should return False on non-200 status."""
|
||||
mock_settings.ollama_base_url = "http://ollama:11434"
|
||||
mock_settings.ollama_timeout = 30
|
||||
|
||||
client = OllamaClient()
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 500
|
||||
|
||||
with patch.object(client.client, "get", new_callable=AsyncMock) as mock_get:
|
||||
mock_get.return_value = mock_response
|
||||
result = await client.health_check()
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
class TestOllamaClientListModels:
|
||||
"""Test list models functionality."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("src.models.ollama_client.settings")
|
||||
async def test_list_models_returns_dict(self, mock_settings):
|
||||
"""list_models should return dict with models."""
|
||||
mock_settings.ollama_base_url = "http://ollama:11434"
|
||||
mock_settings.ollama_timeout = 30
|
||||
|
||||
client = OllamaClient()
|
||||
|
||||
models_data = {
|
||||
"models": [
|
||||
{"name": "llama2", "size": 1000000},
|
||||
{"name": "gemma:7b", "size": 2000000}
|
||||
]
|
||||
}
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = models_data
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
with patch.object(client.client, "get", new_callable=AsyncMock) as mock_get:
|
||||
mock_get.return_value = mock_response
|
||||
result = await client.list_models()
|
||||
|
||||
assert result == models_data
|
||||
assert len(result["models"]) == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("src.models.ollama_client.settings")
|
||||
async def test_list_models_raises_on_error(self, mock_settings):
|
||||
"""list_models should raise on error."""
|
||||
mock_settings.ollama_base_url = "http://ollama:11434"
|
||||
mock_settings.ollama_timeout = 30
|
||||
|
||||
client = OllamaClient()
|
||||
|
||||
with patch.object(client.client, "get", new_callable=AsyncMock) as mock_get:
|
||||
mock_get.side_effect = Exception("Connection error")
|
||||
|
||||
with pytest.raises(Exception):
|
||||
await client.list_models()
|
||||
|
||||
|
||||
class TestOllamaClientGenerateNonStreaming:
|
||||
"""Test non-streaming generation."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("src.models.ollama_client.settings")
|
||||
async def test_generate_non_streaming_returns_response(self, mock_settings):
|
||||
"""generate_non_streaming should return response dict."""
|
||||
mock_settings.ollama_base_url = "http://ollama:11434"
|
||||
mock_settings.ollama_timeout = 30
|
||||
mock_settings.model_aliases = {}
|
||||
|
||||
client = OllamaClient()
|
||||
|
||||
response_data = {
|
||||
"message": {"content": "Hello! How can I help?"},
|
||||
"prompt_eval_count": 10,
|
||||
"eval_count": 20
|
||||
}
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = response_data
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
with patch.object(client.client, "post", new_callable=AsyncMock) as mock_post:
|
||||
mock_post.return_value = mock_response
|
||||
result = await client.generate_non_streaming("llama2", "Hello")
|
||||
|
||||
assert result["response"] == "Hello! How can I help?"
|
||||
assert result["tokens"]["prompt"] == 10
|
||||
assert result["tokens"]["completion"] == 20
|
||||
assert result["tokens"]["total"] == 30
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("src.models.ollama_client.settings")
|
||||
async def test_generate_non_streaming_includes_max_tokens(self, mock_settings):
|
||||
"""generate_non_streaming should include max_tokens in payload."""
|
||||
mock_settings.ollama_base_url = "http://ollama:11434"
|
||||
mock_settings.ollama_timeout = 30
|
||||
mock_settings.model_aliases = {}
|
||||
|
||||
client = OllamaClient()
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {"message": {"content": "Hi"}}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
with patch.object(client.client, "post", new_callable=AsyncMock) as mock_post:
|
||||
mock_post.return_value = mock_response
|
||||
await client.generate_non_streaming("llama2", "Hello", max_tokens=100)
|
||||
|
||||
call_args = mock_post.call_args
|
||||
assert call_args[1]["json"]["options"]["num_predict"] == 100
|
||||
|
||||
|
||||
class TestOllamaClientGenerateStreaming:
|
||||
"""Test streaming generation."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("src.models.ollama_client.settings")
|
||||
async def test_generate_streaming_yields_content(self, mock_settings):
|
||||
"""generate_streaming should yield content chunks."""
|
||||
mock_settings.ollama_base_url = "http://ollama:11434"
|
||||
mock_settings.ollama_timeout = 30
|
||||
mock_settings.model_aliases = {}
|
||||
|
||||
client = OllamaClient()
|
||||
|
||||
# Create mock streaming response
|
||||
async def mock_aiter_lines():
|
||||
yield json.dumps({"message": {"content": "Hello"}})
|
||||
yield json.dumps({"message": {"content": " world"}})
|
||||
yield json.dumps({"done": True})
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_response.aiter_lines = mock_aiter_lines
|
||||
|
||||
mock_stream_context = AsyncMock()
|
||||
mock_stream_context.__aenter__.return_value = mock_response
|
||||
mock_stream_context.__aexit__.return_value = None
|
||||
|
||||
with patch.object(client.client, "stream", return_value=mock_stream_context):
|
||||
chunks = []
|
||||
async for chunk in client.generate_streaming("llama2", "Hi"):
|
||||
chunks.append(chunk)
|
||||
|
||||
assert "Hello" in chunks
|
||||
assert " world" in chunks
|
||||
|
||||
|
||||
class TestOllamaClientSingleton:
|
||||
"""Test singleton pattern."""
|
||||
|
||||
@patch("src.models.ollama_client.settings")
|
||||
def test_get_ollama_client_returns_same_instance(self, mock_settings):
|
||||
"""get_ollama_client should return singleton."""
|
||||
mock_settings.ollama_base_url = "http://ollama:11434"
|
||||
mock_settings.ollama_timeout = 30
|
||||
|
||||
import src.models.ollama_client as module
|
||||
module._ollama_client = None
|
||||
|
||||
client1 = get_ollama_client()
|
||||
client2 = get_ollama_client()
|
||||
|
||||
assert client1 is client2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("src.models.ollama_client.settings")
|
||||
async def test_close_ollama_client_clears_singleton(self, mock_settings):
|
||||
"""close_ollama_client should clear the singleton."""
|
||||
mock_settings.ollama_base_url = "http://ollama:11434"
|
||||
mock_settings.ollama_timeout = 30
|
||||
|
||||
import src.models.ollama_client as module
|
||||
module._ollama_client = None
|
||||
|
||||
client = get_ollama_client()
|
||||
|
||||
with patch.object(client.client, "aclose", new_callable=AsyncMock):
|
||||
await close_ollama_client()
|
||||
|
||||
assert module._ollama_client is None
|
||||
@@ -0,0 +1,697 @@
|
||||
"""Tests for Portainer client."""
|
||||
import pytest
|
||||
from unittest.mock import patch, AsyncMock, MagicMock
|
||||
import httpx
|
||||
|
||||
from src.clients.portainer_client import PortainerClient, get_portainer_client
|
||||
|
||||
|
||||
class TestPortainerClientInit:
|
||||
"""Test PortainerClient initialization."""
|
||||
|
||||
@patch("src.clients.portainer_client.settings")
|
||||
def test_uses_settings_defaults(self, mock_settings):
|
||||
"""Client should use settings for defaults."""
|
||||
mock_settings.portainer_url = "http://portainer:9000"
|
||||
mock_settings.portainer_api_key = "test_key"
|
||||
|
||||
client = PortainerClient()
|
||||
|
||||
assert client.base_url == "http://portainer:9000"
|
||||
assert client.api_key == "test_key"
|
||||
|
||||
def test_accepts_custom_url_and_key(self):
|
||||
"""Client should accept custom URL and API key."""
|
||||
client = PortainerClient(
|
||||
base_url="http://custom:9000",
|
||||
api_key="custom_key"
|
||||
)
|
||||
|
||||
assert client.base_url == "http://custom:9000"
|
||||
assert client.api_key == "custom_key"
|
||||
|
||||
def test_strips_trailing_slash_from_url(self):
|
||||
"""Client should strip trailing slash from URL."""
|
||||
client = PortainerClient(
|
||||
base_url="http://custom:9000/",
|
||||
api_key="key"
|
||||
)
|
||||
|
||||
assert client.base_url == "http://custom:9000"
|
||||
|
||||
@patch("src.clients.portainer_client.logger")
|
||||
@patch("src.clients.portainer_client.settings")
|
||||
def test_warns_when_api_key_missing(self, mock_settings, mock_logger):
|
||||
"""Client should warn when API key is not configured."""
|
||||
mock_settings.portainer_url = "http://portainer:9000"
|
||||
mock_settings.portainer_api_key = ""
|
||||
|
||||
PortainerClient()
|
||||
|
||||
mock_logger.warning.assert_called_once()
|
||||
|
||||
|
||||
class TestPortainerClientHeaders:
|
||||
"""Test header generation."""
|
||||
|
||||
def test_get_headers_includes_api_key(self):
|
||||
"""Headers should include X-API-Key."""
|
||||
client = PortainerClient(
|
||||
base_url="http://portainer:9000",
|
||||
api_key="my_api_key"
|
||||
)
|
||||
|
||||
headers = client._get_headers()
|
||||
|
||||
assert headers["X-API-Key"] == "my_api_key"
|
||||
assert headers["Content-Type"] == "application/json"
|
||||
|
||||
|
||||
class TestPortainerClientHealthCheck:
|
||||
"""Test health check functionality."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_check_returns_true_on_200(self):
|
||||
"""Health check should return True when Portainer responds 200."""
|
||||
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_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.health_check()
|
||||
|
||||
assert result is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_check_returns_false_on_error(self):
|
||||
"""Health check should return False on connection error."""
|
||||
client = PortainerClient(base_url="http://portainer:9000", api_key="key")
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client_class:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get.side_effect = Exception("Connection refused")
|
||||
mock_client.__aenter__.return_value = mock_client
|
||||
mock_client.__aexit__.return_value = None
|
||||
mock_client_class.return_value = mock_client
|
||||
|
||||
result = await client.health_check()
|
||||
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_check_returns_false_on_non_200(self):
|
||||
"""Health check should return False on non-200 status."""
|
||||
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 = 401
|
||||
|
||||
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.health_check()
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
class TestPortainerClientEndpoints:
|
||||
"""Test endpoint retrieval."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_endpoints_returns_list(self):
|
||||
"""get_endpoints should return list of endpoints."""
|
||||
client = PortainerClient(base_url="http://portainer:9000", api_key="key")
|
||||
|
||||
endpoints = [
|
||||
{"Id": 1, "Name": "local"},
|
||||
{"Id": 2, "Name": "remote"}
|
||||
]
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client_class:
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = endpoints
|
||||
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_endpoints()
|
||||
|
||||
assert result == endpoints
|
||||
assert len(result) == 2
|
||||
|
||||
|
||||
class TestPortainerClientStacks:
|
||||
"""Test stack operations."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_stacks_returns_list(self):
|
||||
"""get_stacks should return list of stacks."""
|
||||
client = PortainerClient(base_url="http://portainer:9000", api_key="key")
|
||||
|
||||
stacks = [
|
||||
{"Id": 1, "Name": "stack1", "Status": 1},
|
||||
{"Id": 2, "Name": "stack2", "Status": 1}
|
||||
]
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client_class:
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = stacks
|
||||
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_stacks()
|
||||
|
||||
assert result == stacks
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_stacks_with_endpoint_filter(self):
|
||||
"""get_stacks should filter by endpoint_id when provided."""
|
||||
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
|
||||
|
||||
await client.get_stacks(endpoint_id=3)
|
||||
|
||||
# Verify params include endpoint_id
|
||||
call_args = mock_client.get.call_args
|
||||
assert call_args[1]["params"]["endpointId"] == 3
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_stack_returns_single_stack(self):
|
||||
"""get_stack should return a single stack."""
|
||||
client = PortainerClient(base_url="http://portainer:9000", api_key="key")
|
||||
|
||||
stack = {"Id": 1, "Name": "mystack", "Status": 1}
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client_class:
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = stack
|
||||
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(1)
|
||||
|
||||
assert result == stack
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_stack_posts_correct_data(self):
|
||||
"""create_stack should POST with correct payload."""
|
||||
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 = {"Id": 1, "Name": "newstack"}
|
||||
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
|
||||
|
||||
await client.create_stack("newstack", "version: '3'\nservices:", 1)
|
||||
|
||||
call_args = mock_client.post.call_args
|
||||
assert call_args[1]["json"]["name"] == "newstack"
|
||||
assert "stackFileContent" in call_args[1]["json"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_stack_puts_correct_data(self):
|
||||
"""update_stack should PUT with correct payload."""
|
||||
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 = {"Id": 1, "Name": "stack"}
|
||||
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(1, "version: '3'", 1, prune=True, pull_image=True)
|
||||
|
||||
call_args = mock_client.put.call_args
|
||||
assert call_args[1]["json"]["prune"] is True
|
||||
assert call_args[1]["json"]["pullImage"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_stack_returns_true(self):
|
||||
"""delete_stack 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_stack(1, 1)
|
||||
|
||||
assert result is True
|
||||
|
||||
|
||||
class TestPortainerClientContainers:
|
||||
"""Test container operations."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_containers_returns_list(self):
|
||||
"""get_containers should return list of containers."""
|
||||
client = PortainerClient(base_url="http://portainer:9000", api_key="key")
|
||||
|
||||
containers = [
|
||||
{"Id": "abc123", "Names": ["/container1"], "State": "running"},
|
||||
{"Id": "def456", "Names": ["/container2"], "State": "exited"}
|
||||
]
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client_class:
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = containers
|
||||
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_containers(1)
|
||||
|
||||
assert result == containers
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_container_returns_details(self):
|
||||
"""get_container should return container details."""
|
||||
client = PortainerClient(base_url="http://portainer:9000", api_key="key")
|
||||
|
||||
container = {"Id": "abc123", "Name": "/container1", "State": {"Status": "running"}}
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client_class:
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = container
|
||||
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_container(1, "abc123")
|
||||
|
||||
assert result == container
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_container_returns_true(self):
|
||||
"""stop_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.stop_container(1, "abc123")
|
||||
|
||||
assert result is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_container_returns_true(self):
|
||||
"""start_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.start_container(1, "abc123")
|
||||
|
||||
assert result is True
|
||||
|
||||
|
||||
class TestPortainerClientWrapperMethods:
|
||||
"""Test convenience wrapper methods."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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"]}]
|
||||
|
||||
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_containers:
|
||||
mock_containers.return_value = containers
|
||||
|
||||
result = await client.list_containers()
|
||||
|
||||
assert result == containers
|
||||
mock_endpoints.assert_called_once()
|
||||
mock_containers.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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 = []
|
||||
|
||||
with pytest.raises(RuntimeError, match="No Portainer endpoints available"):
|
||||
await client.list_containers()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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"]}]
|
||||
container_detail = {"Id": "abc123", "Name": "/mycontainer", "State": {"Status": "running"}}
|
||||
|
||||
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_list
|
||||
|
||||
with patch.object(client, "get_container", new_callable=AsyncMock) as mock_detail:
|
||||
mock_detail.return_value = container_detail
|
||||
|
||||
result = await client.inspect_container("mycontainer")
|
||||
|
||||
assert result == container_detail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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 = [] # No containers
|
||||
|
||||
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."""
|
||||
|
||||
def test_returns_same_instance(self):
|
||||
"""get_portainer_client should return singleton."""
|
||||
# Reset singleton
|
||||
import src.clients.portainer_client as module
|
||||
module._portainer_client = None
|
||||
|
||||
client1 = get_portainer_client()
|
||||
client2 = get_portainer_client()
|
||||
|
||||
assert client1 is client2
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Tests for static controller."""
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from unittest.mock import patch, MagicMock
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import os
|
||||
|
||||
from src.main import app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
"""Create a test client."""
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
class TestListWidgets:
|
||||
"""Test /static/widgets endpoint."""
|
||||
|
||||
def test_list_widgets_returns_200(self, client):
|
||||
"""List widgets should return 200."""
|
||||
response = client.get("/static/widgets")
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_list_widgets_returns_widgets_list(self, client):
|
||||
"""List widgets should return widgets array."""
|
||||
response = client.get("/static/widgets")
|
||||
data = response.json()
|
||||
|
||||
assert "widgets" in data
|
||||
assert "count" in data
|
||||
assert isinstance(data["widgets"], list)
|
||||
|
||||
@patch("src.controllers.static_controller.StaticController")
|
||||
def test_list_widgets_handles_missing_directory(self, mock_controller_class, client):
|
||||
"""List widgets should handle missing widgets directory."""
|
||||
# Create a mock controller with non-existent static dir
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
mock_static_dir = Path(tmpdir) / "nonexistent"
|
||||
|
||||
with patch.object(
|
||||
client.app.state if hasattr(client.app, 'state') else client.app,
|
||||
'static_dir',
|
||||
mock_static_dir,
|
||||
create=True
|
||||
):
|
||||
# The actual endpoint handles this case gracefully
|
||||
response = client.get("/static/widgets")
|
||||
# Should still return 200 with empty list or message
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
class TestGetWidget:
|
||||
"""Test /static/widgets/{filename} endpoint."""
|
||||
|
||||
def test_get_widget_returns_404_for_nonexistent(self, client):
|
||||
"""Get widget should return 404 for non-existent file."""
|
||||
response = client.get("/static/widgets/nonexistent-widget.html")
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_get_widget_returns_html_content_type(self, client):
|
||||
"""Get widget should return HTML content type for existing file."""
|
||||
# First check if any widgets exist
|
||||
list_response = client.get("/static/widgets")
|
||||
widgets = list_response.json().get("widgets", [])
|
||||
|
||||
if widgets:
|
||||
# Test with first available widget
|
||||
widget_name = widgets[0]["name"]
|
||||
response = client.get(f"/static/widgets/{widget_name}")
|
||||
assert response.status_code == 200
|
||||
assert "text/html" in response.headers.get("content-type", "")
|
||||
|
||||
def test_get_widget_prevents_path_traversal(self, client):
|
||||
"""Get widget should prevent path traversal attacks."""
|
||||
# Attempt path traversal
|
||||
response = client.get("/static/widgets/../../../etc/passwd")
|
||||
# Should either return 404 or 403, not the actual file
|
||||
assert response.status_code in [400, 403, 404]
|
||||
|
||||
def test_get_widget_includes_cache_headers(self, client):
|
||||
"""Get widget should include no-cache headers."""
|
||||
list_response = client.get("/static/widgets")
|
||||
widgets = list_response.json().get("widgets", [])
|
||||
|
||||
if widgets:
|
||||
widget_name = widgets[0]["name"]
|
||||
response = client.get(f"/static/widgets/{widget_name}")
|
||||
|
||||
if response.status_code == 200:
|
||||
assert "no-cache" in response.headers.get("cache-control", "")
|
||||
|
||||
|
||||
class TestStaticControllerInit:
|
||||
"""Test StaticController initialization."""
|
||||
|
||||
def test_controller_has_static_dir(self):
|
||||
"""Controller should have static directory configured."""
|
||||
from src.controllers.static_controller import static_controller
|
||||
|
||||
assert static_controller.static_dir is not None
|
||||
assert isinstance(static_controller.static_dir, Path)
|
||||
|
||||
def test_controller_has_correct_prefix(self):
|
||||
"""Controller should have /static prefix."""
|
||||
from src.controllers.static_controller import static_controller
|
||||
|
||||
assert static_controller.prefix == "/static"
|
||||
|
||||
def test_controller_has_correct_tags(self):
|
||||
"""Controller should have Static tag."""
|
||||
from src.controllers.static_controller import static_controller
|
||||
|
||||
assert "Static" in static_controller.tags
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Tests for tools controller."""
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from unittest.mock import patch, AsyncMock, MagicMock
|
||||
|
||||
from src.main import app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
"""Create a test client."""
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
class TestDNSLookup:
|
||||
"""Test /tools/dns/lookup endpoint."""
|
||||
|
||||
@patch("src.controllers.tools_controller.DNSService")
|
||||
def test_dns_lookup_returns_200(self, mock_dns_class, client):
|
||||
"""DNS lookup should return 200 for valid request."""
|
||||
mock_service = MagicMock()
|
||||
mock_service.lookup = AsyncMock(return_value=MagicMock(
|
||||
success=True,
|
||||
domain="example.com",
|
||||
record_type="A",
|
||||
records=[{"value": "93.184.216.34"}],
|
||||
nameserver_used="8.8.8.8",
|
||||
query_time_ms=50,
|
||||
error_message=None
|
||||
))
|
||||
mock_dns_class.return_value = mock_service
|
||||
|
||||
response = client.post(
|
||||
"/tools/dns/lookup",
|
||||
json={"domain": "example.com", "record_type": "A"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
@patch("src.controllers.tools_controller.DNSService")
|
||||
def test_dns_lookup_returns_result(self, mock_dns_class, client):
|
||||
"""DNS lookup should return lookup results."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.success = True
|
||||
mock_response.domain = "example.com"
|
||||
mock_response.record_type = "A"
|
||||
mock_response.records = [{"value": "93.184.216.34"}]
|
||||
mock_response.nameserver_used = "8.8.8.8"
|
||||
mock_response.query_time_ms = 50
|
||||
mock_response.error_message = None
|
||||
mock_response.model_dump = MagicMock(return_value={
|
||||
"success": True,
|
||||
"domain": "example.com",
|
||||
"record_type": "A",
|
||||
"records": [{"value": "93.184.216.34"}],
|
||||
"nameserver_used": "8.8.8.8",
|
||||
"query_time_ms": 50,
|
||||
"error_message": None
|
||||
})
|
||||
|
||||
mock_service = MagicMock()
|
||||
mock_service.lookup = AsyncMock(return_value=mock_response)
|
||||
mock_dns_class.return_value = mock_service
|
||||
|
||||
response = client.post(
|
||||
"/tools/dns/lookup",
|
||||
json={"domain": "example.com", "record_type": "A"}
|
||||
)
|
||||
data = response.json()
|
||||
|
||||
assert data["success"] is True
|
||||
assert data["domain"] == "example.com"
|
||||
|
||||
def test_dns_lookup_requires_domain(self, client):
|
||||
"""DNS lookup should require domain parameter."""
|
||||
response = client.post(
|
||||
"/tools/dns/lookup",
|
||||
json={"record_type": "A"}
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
@patch("src.controllers.tools_controller.DNSService")
|
||||
def test_dns_lookup_handles_dns_query_error(self, mock_dns_class, client):
|
||||
"""DNS lookup should handle DNSQueryError."""
|
||||
from src.dns.exceptions import DNSQueryError
|
||||
|
||||
mock_service = MagicMock()
|
||||
mock_service.lookup = AsyncMock(side_effect=DNSQueryError("Unsupported record type"))
|
||||
mock_dns_class.return_value = mock_service
|
||||
|
||||
response = client.post(
|
||||
"/tools/dns/lookup",
|
||||
json={"domain": "example.com", "record_type": "INVALID"}
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
@patch("src.controllers.tools_controller.DNSService")
|
||||
def test_dns_lookup_accepts_custom_nameserver(self, mock_dns_class, client):
|
||||
"""DNS lookup should accept custom nameserver."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.success = True
|
||||
mock_response.domain = "example.com"
|
||||
mock_response.record_type = "A"
|
||||
mock_response.records = []
|
||||
mock_response.nameserver_used = "1.1.1.1"
|
||||
mock_response.query_time_ms = 30
|
||||
mock_response.error_message = None
|
||||
mock_response.model_dump = MagicMock(return_value={
|
||||
"success": True,
|
||||
"domain": "example.com",
|
||||
"record_type": "A",
|
||||
"records": [],
|
||||
"nameserver_used": "1.1.1.1",
|
||||
"query_time_ms": 30,
|
||||
"error_message": None
|
||||
})
|
||||
|
||||
mock_service = MagicMock()
|
||||
mock_service.lookup = AsyncMock(return_value=mock_response)
|
||||
mock_dns_class.return_value = mock_service
|
||||
|
||||
response = client.post(
|
||||
"/tools/dns/lookup",
|
||||
json={"domain": "example.com", "record_type": "A", "nameserver": "1.1.1.1"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
class TestToolsControllerInit:
|
||||
"""Test ToolsController initialization."""
|
||||
|
||||
def test_controller_has_correct_prefix(self):
|
||||
"""Controller should have /tools prefix."""
|
||||
from src.controllers.tools_controller import tools_controller
|
||||
|
||||
assert tools_controller.prefix == "/tools"
|
||||
|
||||
def test_controller_has_correct_tags(self):
|
||||
"""Controller should have Tools tag."""
|
||||
from src.controllers.tools_controller import tools_controller
|
||||
|
||||
assert "Tools" in tools_controller.tags
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user