diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 9608ffe..38b7e3c 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -6,7 +6,10 @@ "Bash(docker ps:*)", "Bash(docker inspect:*)", "Bash(tree:*)", - "Bash(nvidia-smi:*)" + "Bash(nvidia-smi:*)", + "Bash(find:*)", + "Bash(grep:*)", + "Bash(cat:*)" ], "deny": [], "ask": [] diff --git a/CONTAINERS.md b/CONTAINERS.md index db7b6fa..f63f3c0 100644 --- a/CONTAINERS.md +++ b/CONTAINERS.md @@ -24,7 +24,7 @@ | **Maintenance** | None | N/A (background) | No | No | ✅ Running | | **Open WebUI** | 82 | http://192.168.86.149:82 | LAN | No | ✅ Running | | **Core API** | 8083 | http://192.168.86.149:8083 | LAN | No | ✅ Running | -| **Scheduler** | 8090 | http://192.168.86.149:8090 | LAN | No | 🔧 Deploying | +| **Scheduler** | 8090 | http://192.168.86.149:8090 | LAN | No | ✅ Running (external) | | **SearXNG** | 8087 | http://192.168.86.149:8087 | LAN | No | ✅ Running | | **Jellyfin** | 8096, 8920, 7359, 1900 | https://media.schweitz.net | Internet | Yes (RTX 2080 Ti) | ✅ Running | | **Nextcloud** | 8082 | https://cloud.schweitz.net | Internet | No | ⚠️ Stopped | diff --git a/docs/EXTERNAL_SERVICES.md b/docs/EXTERNAL_SERVICES.md new file mode 100644 index 0000000..2d76f10 --- /dev/null +++ b/docs/EXTERNAL_SERVICES.md @@ -0,0 +1,53 @@ +# External Services + +Services that have been extracted to their own repositories but are still part of the overall infrastructure. + +--- + +## Scheduler + +**Repository:** https://git.schweitz.net/jpmschweitzer/scheduler +**Container Image:** `git.schweitz.net/jpmschweitzer/scheduler:latest` +**Stack File:** `stacks/scheduler.yml` (remains in portainer-core) + +### Overview +The Scheduler service handles system-wide maintenance orchestration including: +- Docker config backups +- Documentation mirroring +- Task automation via REST API +- Integration with Library Desk for consolidation jobs + +### Deployment +- Container image built via Gitea Actions on release +- Stack file in portainer-core defines volumes, environment, and network +- Watchtower monitors for image updates + +### Development +To make changes: +1. Clone: `git clone gitea:jpmschweitzer/scheduler.git` +2. Make changes +3. Create a release in Gitea to trigger build +4. Watchtower will auto-update the running container + +### API +- Health: `http://scheduler:8090/health` +- Tasks: `http://scheduler:8090/tasks` (requires API key) +- Docs: `http://scheduler:8090/docs` + +--- + +## Tatlock + +**Repository:** https://git.schweitz.net/jpmschweitzer/tatlock +**Location:** `/home/jpmschweitzer/Projects/tatlock` + +### Overview +Tatlock is a separate project maintained in its own repository. + +--- + +## Future Migrations + +The following services are planned for extraction: +- **core-api** - Infrastructure management API +- **library-desk** - Knowledge management and consolidation service diff --git a/services/scheduler/CHANGELOG.md b/services/scheduler/CHANGELOG.md deleted file mode 100644 index d9cefd1..0000000 --- a/services/scheduler/CHANGELOG.md +++ /dev/null @@ -1,191 +0,0 @@ -# Changelog - -All notable changes to The Scheduler will be documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - -## [1.0.0] - 2025-12-07 - -### Added - -#### Core Scheduling System -- **Hybrid APScheduler + PostgreSQL architecture** for minute-based task scheduling - - Single scheduler job runs every minute - - Queries database for tasks scheduled for current minute - - Executes up to 5 tasks concurrently by priority - - Priority queue system (1-100, lower = higher priority) -- **Cron-like scheduling** with wildcard support (`-1` = any) - - Supports minute, hour, day_of_month, month, day_of_week patterns - - Flexible scheduling from every-minute to specific dates -- **Task execution tracking** with full audit trail - - Database tables: `scheduled_tasks` (definitions) and `task_executions` (history) - - Tracks status, duration, output, errors, and retry attempts - - Execution metadata stored as JSONB - -#### REST API -- **Full CRUD API** for task management with FastAPI - - `POST /tasks` - Create new scheduled task - - `GET /tasks` - List all tasks with filtering (service, enabled) - - `GET /tasks/{name}` - Get task details - - `PUT /tasks/{name}` - Update task configuration - - `DELETE /tasks/{name}` - Remove task - - `POST /tasks/{name}/trigger` - Manually trigger task execution -- **Execution history endpoints** - - `GET /executions` - Query execution history - - Filter by task_name, status, service - - Pagination support (limit parameter) -- **System monitoring endpoints** - - `GET /health` - Health check - - `GET /stats` - System statistics (enabled tasks, running tasks, 24h execution counts) -- **API Key authentication** (Bearer token) for all protected endpoints -- **OpenAPI documentation** at `/docs` - -#### Task Executors -- **Example Executor** (`example_executor.py`) - - Simple test implementation with configurable message and delay - - Demonstrates executor pattern -- **Documentation Sync Executor** (`doc_sync_executor.py`) - - Mirrors documentation from upstream Git repositories to Gitea - - Supports full repository mirroring or selective path syncing - - Creates date-tagged snapshots (YYYY-MM-DD format) - - Generates `.SYNC_INFO.md` with sync metadata - - Configurable upstream repo, paths, branch, and Gitea destination -- **Config Backup Executor** (`config_backup_executor.py`) - - Backs up Docker configurations and data directories - - Supports multiple source paths with exclusion patterns - - Optional compression (tar.gz) - - Retention policy (days-based cleanup) - - Creates timestamped backups - -#### Pre-configured Tasks -- **FastAPI Documentation Sync** (monthly on 11th at 04:00) - - Syncs entire FastAPI repository to `library/docs-fastapi` - - Priority: 60 (maintenance) -- **Ollama Documentation Sync** (monthly on 12th at 04:00) - - Syncs only `/docs` folder from Ollama repository to `library/docs-ollama` - - Priority: 60 (maintenance) -- **Docker Config Backup** (daily at 03:05) - - Backs up Docker data and configurations - - Priority: 20 (user task) - - 30-day retention -- **Example Test Task** (every minute, can be disabled) - - Test task for validation - - Priority: 50 (maintenance) - -#### Testing Infrastructure -- **Comprehensive test suite** with 80% code coverage - - 85 total tests across multiple test files - - Pytest configuration with markers (unit, integration, api, executor) - - Coverage reporting with pytest-cov -- **Test categories**: - - Unit tests: Fast tests with mocked dependencies - - API tests: Comprehensive endpoint testing (24 tests) - - Executor tests: Task executor validation - - Integration tests: Real database operations -- **Test database setup** - - Dedicated `test_scheduler` database on postgres-shared - - Automatic schema creation and cleanup - - Database fixtures for clean test state - - Test user: `test_scheduler_user` -- **Test fixtures** (conftest.py) - - Mock database connections - - Mock scheduler and executor - - Sample task data - - Authentication headers - - Clean database state management - -#### Documentation -- **Comprehensive README.md** (500+ lines) - - Architecture overview with ASCII diagram - - Quick start guide - - Complete API reference with curl examples - - Task scheduling patterns and examples - - Executor development guide - - Testing guide with coverage metrics - - Development and debugging information - - Security and performance notes -- **Test database setup guide** (test_database_setup.sql) - - SQL script for creating test environment - - Schema matching production - - Test data fixtures - -#### Configuration -- **Pydantic Settings** for environment-based configuration - - PostgreSQL connection settings - - Redis connection (for future use) - - Gitea authentication - - API key configuration - - Computed properties (database_url, redis_url) -- **Docker stack configuration** (stacks/scheduler.yml) - - Virtual environment setup on startup - - Git configuration for librarian user - - Health checks - - Network isolation (docker-dataplane) - - Resource limits - -### Technical Details - -#### Database Schema -```sql -scheduled_tasks: - - Task definitions/templates - - Scheduling configuration (minute/hour/day patterns) - - Priority, enabled status, retry settings - - Task configuration as JSONB - - Execution tracking fields - -task_executions: - - Individual execution records - - Status tracking (pending, running, success, failed, timeout) - - Duration and timestamp tracking - - Output and error details - - Metadata as JSONB -``` - -#### Performance -- Minute-based processing with lightweight scheduler ticks -- Connection pooling for database efficiency -- Database indexes for optimized task queries -- Concurrent execution with configurable limit (default: 5) -- Priority-based execution order - -#### Security -- API key authentication required for protected endpoints -- Network isolation on docker-dataplane -- Environment variable-based secrets -- Gitea token authentication for git operations -- Database credentials in environment - -### Dependencies -- FastAPI (web framework) -- APScheduler (task scheduling) -- psycopg2-binary (PostgreSQL driver) -- Pydantic (configuration management) -- pytest + pytest-asyncio + pytest-cov (testing) -- GitPython (git operations) - -### Coverage Metrics -``` -Module Coverage -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -config.py 100% ✅ -example_executor.py 100% ✅ -main.py (API endpoints) 95% ✅ -doc_sync_executor.py 78% ✅ -executor.py (core logic) 71% ✅ -config_backup_executor.py 50% 📈 -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -TOTAL 80% 🎯 -``` - -## [Unreleased] - -### Planned -- Redis integration for distributed locking -- Webhook notifications for task completion -- Task dependencies (run task B after task A succeeds) -- Task groups and tags -- More executors (database backup, log rotation, etc.) -- Web UI for task management -- Metrics export (Prometheus) -- Advanced scheduling (last business day of month, etc.) diff --git a/services/scheduler/README.md b/services/scheduler/README.md deleted file mode 100644 index fb06f7c..0000000 --- a/services/scheduler/README.md +++ /dev/null @@ -1,576 +0,0 @@ -# The Scheduler - -**System-wide maintenance orchestration for automated task scheduling.** - -The Scheduler is a hybrid APScheduler + PostgreSQL-based task scheduling system that handles backups, documentation mirroring, cleanup, and automated maintenance tasks across the Portainer Core infrastructure. - -## Architecture - -**Hybrid Design**: APScheduler + Database-driven Priority System - -``` -┌─────────────────────────────────────────────────────────┐ -│ APScheduler (runs every minute) │ -│ └─> Query PostgreSQL for tasks scheduled this minute │ -│ └─> Execute up to 5 tasks concurrently by priority │ -└─────────────────────────────────────────────────────────┘ -``` - -**Key Features**: -- **Minute-based scheduling** with cron-like patterns (`-1` = wildcard) -- **Priority queue** (1-100, lower = higher priority) -- **Concurrent execution** (max 5 tasks simultaneously) -- **Execution tracking** (full audit trail in database) -- **REST API** for task management -- **Flexible executors** (modular task implementations) - -## Quick Start - -### Starting the Scheduler - -```bash -# Deploy via Portainer -# Uses stack: /stacks/scheduler.yml - -# Check health -curl http://localhost:8090/health - -# View API docs -open http://localhost:8090/docs -``` - -### API Authentication - -All protected endpoints require an API key: - -```bash -# Set in environment or use default -export SCHEDULER_API_KEY="your-api-key-here" - -# Make authenticated request -curl -H "Authorization: Bearer $SCHEDULER_API_KEY" \ - http://localhost:8090/tasks -``` - -## Scheduling Tasks - -### Task Configuration - -Tasks are defined with: -- **task_name**: Unique identifier -- **service**: Which service owns this task -- **executor**: Python module to execute -- **priority**: 1-100 (1=emergency, 10-30=user, 40-70=maintenance) -- **schedule**: Minute, hour, day, month, day_of_week (-1 = any) -- **config**: JSON configuration for executor - -### Scheduling Examples - -```python -# Every minute (wildcard) -minute=-1, hour=-1, day_of_month=-1, month=-1, day_of_week=-1 - -# Daily at 3:05 AM -minute=5, hour=3, day_of_month=-1, month=-1, day_of_week=-1 - -# 11th of every month at 4:00 AM -minute=0, hour=4, day_of_month=11, month=-1, day_of_week=-1 - -# Every Monday at 9:00 AM -minute=0, hour=9, day_of_month=-1, month=-1, day_of_week=0 - -# First day of January at midnight -minute=0, hour=0, day_of_month=1, month=1, day_of_week=-1 -``` - -### Priority System - -``` -Priority Range Purpose Examples -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -1-5 Emergency/System Critical system tasks -10-30 User Tasks User-initiated operations -40-70 Maintenance Backups, cleanup, doc sync -71-100 Low Priority Optional background tasks -``` - -## REST API - -### Task Management - -```bash -# List all tasks -GET /tasks -GET /tasks?enabled=true&service=scheduler - -# Get task details -GET /tasks/{task_name} - -# Create new task -POST /tasks -{ - "task_name": "my_task", - "service": "scheduler", - "executor": "example_executor", - "priority": 50, - "minute": 0, - "hour": 4, - "description": "Daily task at 4 AM", - "config": {"key": "value"} -} - -# Update task -PUT /tasks/{task_name} -{ - "priority": 60, - "enabled": false -} - -# Delete task -DELETE /tasks/{task_name} - -# Manually trigger task -POST /tasks/{task_name}/trigger -``` - -### Execution History - -```bash -# View recent executions -GET /executions?limit=20 - -# Filter by task -GET /executions?task_name=my_task&limit=10 - -# Filter by status -GET /executions?status=success - -# Filter by service -GET /executions?service=scheduler -``` - -### System Stats - -```bash -# Get system statistics -GET /stats -{ - "scheduler_running": true, - "tasks_enabled": 3, - "tasks_currently_running": 0, - "concurrent_limit": 5, - "execution_stats_24h": { - "success": 15, - "failed": 1 - } -} -``` - -## Task Executors - -Executors are Python modules that implement the actual task logic. - -### Built-in Executors - -#### 1. Example Executor -**File**: `src/executors/example_executor.py` -**Purpose**: Test/example implementation - -```python -config = { - "message": "Hello World", - "delay_seconds": 2 -} -``` - -#### 2. Doc Sync Executor -**File**: `src/executors/doc_sync_executor.py` -**Purpose**: Mirror documentation from GitHub to Gitea - -```python -config = { - "project": "fastapi", - "upstream_repo": "https://github.com/tiangolo/fastapi.git", - "docs_paths": ["/docs"], # Empty = entire repo - "gitea_repo": "library/docs-fastapi", - "branch": "main" -} -``` - -**Features**: -- Clones upstream repository -- Filters to specific paths or mirrors entire repo -- Pushes to Gitea with authentication -- Creates date-tagged snapshots -- Generates `.SYNC_INFO.md` with metadata - -#### 3. Config Backup Executor -**File**: `src/executors/config_backup_executor.py` -**Purpose**: Backup Docker configs and data - -```python -config = { - "sources": [ - { - "path": "/data/docker-data", - "name": "docker-data", - "excludes": ["*/cache/*", "*.log"] - } - ], - "backup_dir": "/backups/configs", - "compress": true, - "retention_days": 30 -} -``` - -### Creating Custom Executors - -1. Create file in `src/executors/your_executor.py` -2. Implement async `execute(config: dict, settings: Settings) -> str` function -3. Return success message or raise exception on failure - -```python -# src/executors/my_executor.py -async def execute(config: dict, settings: Settings) -> str: - """ - Execute custom task. - - Args: - config: Task configuration from database - settings: Global scheduler settings - - Returns: - Success message - - Raises: - Exception: On task failure - """ - # Your task logic here - result = do_something(config.get('param')) - - return f"Task completed: {result}" -``` - -4. Create task using API: - -```bash -curl -X POST http://localhost:8090/tasks \ - -H "Authorization: Bearer $SCHEDULER_API_KEY" \ - -H "Content-Type: application/json" \ - -d '{ - "task_name": "my_custom_task", - "service": "scheduler", - "executor": "my_executor", - "priority": 50, - "minute": 0, - "hour": 2, - "config": {"param": "value"} - }' -``` - -## Current Tasks - -### Active Schedules - -| Task | Priority | Schedule | Description | -|------|----------|----------|-------------| -| `test_example_task` | 50 | Every minute | Test task (can be disabled) | -| `backup_docker_configs_daily` | 20 | Daily 03:05 | Backup Docker configs | -| `sync_fastapi_docs_monthly` | 60 | 11th @ 04:00 | Sync FastAPI docs to Gitea | -| `sync_ollama_docs_monthly` | 60 | 12th @ 04:00 | Sync Ollama docs to Gitea | - -### Managing Tasks - -```bash -# Disable test task -curl -X PUT http://localhost:8090/tasks/test_example_task \ - -H "Authorization: Bearer $SCHEDULER_API_KEY" \ - -H "Content-Type: application/json" \ - -d '{"enabled": false}' - -# View execution history -curl -H "Authorization: Bearer $SCHEDULER_API_KEY" \ - http://localhost:8090/executions?limit=10 -``` - -## Database Schema - -### scheduled_tasks - -Stores task definitions (templates for execution). - -```sql -- id (serial) -- task_name (varchar, unique) -- service (varchar) - owner service -- executor (varchar) - executor module name -- priority (integer) - 1-100 -- minute, hour, day_of_month, month, day_of_week (integer) - schedule -- enabled (boolean) -- description (text) -- config (jsonb) - executor configuration -- last_run, last_status, last_duration_seconds - tracking -- retry_count, max_retries, timeout_seconds - execution control -- created_at, updated_at, created_by - metadata -``` - -### task_executions - -Stores individual execution records (audit trail). - -```sql -- id (serial) -- task_id (integer) - references scheduled_tasks -- task_name, service, executor, priority - snapshot -- status (varchar) - pending, running, success, failed, timeout -- triggered_by (varchar) - scheduler, manual, retry -- triggered_at, started_at, completed_at - timestamps -- duration_seconds (integer) -- output (text) - success message -- error (text) - error details -- retry_count (integer) -- metadata (jsonb) -``` - -## Testing - -### Running Tests - -```bash -# All tests with coverage -docker exec -w /app scheduler /venv/bin/pytest --cov=src --cov-report=term - -# Only unit tests (fast, no database) -docker exec -w /app scheduler /venv/bin/pytest -m unit - -# Only integration tests (with database) -docker exec -w /app scheduler /venv/bin/pytest -m integration - -# Only API tests -docker exec -w /app scheduler /venv/bin/pytest -m api - -# Only executor tests -docker exec -w /app scheduler /venv/bin/pytest -m executor - -# Specific test file -docker exec -w /app scheduler /venv/bin/pytest tests/test_api.py -v - -# With detailed output -docker exec -w /app scheduler /venv/bin/pytest -vv --tb=long -``` - -### Test Coverage - -**Current Coverage: 80%** - -``` -Module Coverage -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -config.py 100% ✅ -example_executor.py 100% ✅ -main.py (API endpoints) 95% ✅ -doc_sync_executor.py 78% ✅ -executor.py (core logic) 71% ✅ -config_backup_executor.py 50% 📈 -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -TOTAL 80% 🎯 -``` - -### Test Database - -Tests use a dedicated PostgreSQL database: -- **Database**: `test_scheduler` -- **User**: `test_scheduler_user` -- **Location**: `postgres-shared` container - -Tests automatically: -- Clean database before each test -- Insert test fixtures -- Verify database state -- Rollback changes after test - -## Development - -### Project Structure - -``` -scheduler/ -├── src/ -│ ├── config.py # Pydantic settings -│ ├── main.py # FastAPI app & endpoints -│ ├── executors/ # Task executors -│ │ ├── example_executor.py -│ │ ├── doc_sync_executor.py -│ │ └── config_backup_executor.py -│ └── tasks/ -│ └── executor.py # Task execution engine -├── tests/ -│ ├── conftest.py # Pytest fixtures -│ ├── test_api.py # API tests -│ ├── test_*_executor.py # Executor tests -│ └── test_database_integration.py -├── requirements.txt # Python dependencies -├── pytest.ini # Test configuration -└── README.md # This file -``` - -### Adding Dependencies - -```bash -# Add to requirements.txt with version pinning -echo "new-package~=1.0.0" >> requirements.txt - -# Rebuild venv in container -docker restart scheduler -``` - -### Debugging - -```bash -# View logs -docker logs scheduler --tail 100 -f - -# Check scheduler status -curl http://localhost:8090/health - -# View database tasks -docker exec postgres-shared psql -U scheduler_user -d scheduler \ - -c "SELECT task_name, enabled, priority, last_status FROM scheduled_tasks;" - -# View execution history -docker exec postgres-shared psql -U scheduler_user -d scheduler \ - -c "SELECT task_name, status, duration_seconds, completed_at FROM task_executions ORDER BY id DESC LIMIT 10;" -``` - -### Environment Variables - -Required environment variables (set in `stacks/scheduler.yml`): - -```bash -# Database -POSTGRES_HOST=postgres-shared -POSTGRES_PORT=5432 -POSTGRES_DB=scheduler -POSTGRES_USER=scheduler_user -POSTGRES_PASSWORD=${SCHEDULER_DB_PASSWORD} - -# API Security -SCHEDULER_API_KEY=${SCHEDULER_API_KEY} - -# Gitea (for doc sync) -GITEA_URL=http://gitea:3000 -GITEA_USER=librarian -GITEA_PASSWORD=${GITEA_PASSWORD} - -# Redis (future use) -REDIS_HOST=redis-shared -REDIS_PORT=6379 -REDIS_DB=3 -``` - -## Monitoring - -### Health Checks - -```bash -# Container health -docker ps | grep scheduler - -# API health -curl http://localhost:8090/health - -# Scheduler running -curl http://localhost:8090/stats | jq '.scheduler_running' -``` - -### Metrics - -```bash -# System statistics -curl -H "Authorization: Bearer $SCHEDULER_API_KEY" \ - http://localhost:8090/stats | jq - -# Recent executions -curl -H "Authorization: Bearer $SCHEDULER_API_KEY" \ - "http://localhost:8090/executions?limit=20" | jq - -# Failed tasks in last 24h -curl -H "Authorization: Bearer $SCHEDULER_API_KEY" \ - "http://localhost:8090/executions?status=failed" | jq -``` - -### Common Issues - -**Task not running?** -1. Check if task is enabled: `GET /tasks/{name}` -2. Verify schedule matches current time -3. Check execution history for errors: `GET /executions?task_name={name}` -4. View scheduler logs: `docker logs scheduler` - -**Task timing out?** -- Increase `timeout_seconds` in task configuration -- Check executor implementation for long-running operations -- Consider breaking into smaller tasks - -**Database connection errors?** -- Verify postgres-shared container is running -- Check database credentials in environment -- Test connection: `docker exec scheduler psql -h postgres-shared -U scheduler_user -d scheduler` - -## API Reference - -Full OpenAPI documentation available at: `http://localhost:8090/docs` - -### Response Codes - -- `200` - Success -- `400` - Bad request (invalid data) -- `401` - Missing API key -- `403` - Invalid API key -- `404` - Resource not found -- `500` - Internal server error - -### Pagination - -Use `limit` parameter for controlling result count: - -```bash -GET /executions?limit=50 # Default: 20, Max: 100 -``` - -### Filtering - -Most list endpoints support filtering: - -```bash -GET /tasks?enabled=true&service=scheduler -GET /executions?task_name=my_task&status=success&limit=10 -``` - -## Security - -- **API Key Authentication**: Required for all protected endpoints -- **Network Isolation**: Runs on `docker-dataplane` network -- **Database Credentials**: Stored in environment variables -- **Gitea Tokens**: Used instead of passwords for Git operations -- **Resource Limits**: CPU and memory limits in docker-compose - -## Performance - -- **Concurrent Execution**: Max 5 tasks simultaneously -- **Minute-based Processing**: Lightweight scheduler tick every minute -- **Priority Queue**: Higher priority tasks execute first -- **Database Indexes**: Optimized queries for task selection -- **Connection Pooling**: Reuses database connections - -## License - -Part of Portainer Core infrastructure. - -## Support - -For issues or questions: -1. Check logs: `docker logs scheduler` -2. View health: `curl http://localhost:8090/health` -3. Review execution history: `GET /executions` -4. Check this README for common solutions diff --git a/services/scheduler/TASK_REGISTRATION.md b/services/scheduler/TASK_REGISTRATION.md deleted file mode 100644 index af3c372..0000000 --- a/services/scheduler/TASK_REGISTRATION.md +++ /dev/null @@ -1,289 +0,0 @@ -# Scheduler Task Registration Guide - -## Overview - -The Scheduler is **DB-driven** (not YAML-based). Tasks are registered via REST API and stored in PostgreSQL. - -## Quick Reference - -### Register a New Task - -```bash -curl -X POST http://192.168.86.149:8090/tasks \ - -H "Authorization: Bearer $SCHEDULER_API_KEY" \ - -H "Content-Type: application/json" \ - -d '{ - "task_name": "my_task", - "service": "my-service", - "executor": "rest_api", - "priority": 25, - "description": "Task description", - "minute": 0, - "hour": -1, - "config": { - "method": "POST", - "url": "http://service:port/endpoint", - "headers": { - "Authorization": "Bearer ${ENV_VAR}" - }, - "body": {} - } - }' -``` - -### List All Tasks - -```bash -curl http://192.168.86.149:8090/tasks \ - -H "Authorization: Bearer $SCHEDULER_API_KEY" -``` - -### Trigger Task Manually - -```bash -curl -X POST http://192.168.86.149:8090/tasks/{task_name}/trigger \ - -H "Authorization: Bearer $SCHEDULER_API_KEY" -``` - -### View Task Execution History - -```bash -curl http://192.168.86.149:8090/executions?task_name={task_name} \ - -H "Authorization: Bearer $SCHEDULER_API_KEY" -``` - -## Schedule Patterns - -Use cron-style fields where **`-1` means "every"**: - -| Pattern | minute | hour | day_of_month | month | day_of_week | Description | -|---------|--------|------|--------------|-------|-------------|-------------| -| Every minute | -1 | -1 | -1 | -1 | -1 | Runs every minute | -| Hourly | 0 | -1 | -1 | -1 | -1 | Runs at minute 0 of every hour | -| Daily at 2 AM | 0 | 2 | -1 | -1 | -1 | Runs at 2:00 AM every day | -| Weekly (Monday 2 AM) | 0 | 2 | -1 | -1 | 0 | Runs at 2:00 AM every Monday | -| Monthly (1st at 2 AM) | 0 | 2 | 1 | -1 | -1 | Runs at 2:00 AM on 1st of month | - -**Note:** `day_of_week` is 0-6 (Monday-Sunday) - -## Priority Levels - -| Priority Range | Level | Use Cases | -|----------------|-------|-----------| -| 1-5 | Emergency/System | Critical system tasks | -| 10-30 | User | User-initiated tasks | -| 40-70 | Maintenance | Background maintenance | -| 70+ | Low | Low priority cleanup | - -## Executor Types - -### `rest_api` Executor - -Calls HTTP endpoints. Supports environment variable substitution in headers/body. - -**Config Format:** -```json -{ - "method": "POST|GET|PUT|DELETE|PATCH", - "url": "http://service:port/endpoint", - "headers": { - "Authorization": "Bearer ${ENV_VAR}", - "Content-Type": "application/json" - }, - "body": { - "key": "value" - }, - "timeout": 30, - "auth": { - "type": "bearer|basic|api_key", - "value": "${TOKEN_ENV_VAR}" - } -} -``` - -**Environment Variables:** Use `${VAR_NAME}` syntax to reference env vars from the scheduler container. - -### Other Executors - -- `shell`: Execute shell commands -- `python`: Execute Python scripts -- `docker`: Docker operations -- `backup`: Backup operations -- `doc_sync`: Documentation sync - -## Complete Task Schema - -### Required Fields - -- `task_name` (string): Unique task identifier -- `service` (string): Service that owns this task (e.g., "library-desk", "core-api") -- `executor` (string): Executor type ("rest_api", "shell", etc.) -- `priority` (int): Priority level (1-100) - -### Optional Fields - -- `description` (string): Human-readable description -- `minute` (int): -1 to 59, default: -1 (every minute) -- `hour` (int): -1 to 23, default: -1 (every hour) -- `day_of_month` (int): -1 to 31, default: -1 (every day) -- `month` (int): -1 to 12, default: -1 (every month) -- `day_of_week` (int): -1 to 6, default: -1 (every day) -- `enabled` (bool): Whether task is enabled, default: true -- `max_retries` (int): Max retry attempts, default: 3 -- `timeout_seconds` (int): Execution timeout, default: 3600 -- `config` (object): Executor-specific configuration -- `created_by` (string): Creator identifier, default: "api" - -## Example: Librarian Knowledge Consolidation - -This task processes unprocessed search queries and consolidates knowledge into wiki pages. - -**Registration:** -```bash -curl -X POST http://192.168.86.149:8090/tasks \ - -H "Authorization: Bearer $SCHEDULER_API_KEY" \ - -H "Content-Type: application/json" \ - -d '{ - "task_name": "librarian_consolidation", - "service": "library-desk", - "executor": "rest_api", - "priority": 25, - "description": "Processes unprocessed search queries and consolidates knowledge", - "minute": 0, - "hour": -1, - "config": { - "method": "POST", - "url": "http://library-desk:8089/consolidation/run", - "headers": { - "Authorization": "Bearer ${LIBRARY_API_KEY}", - "Content-Type": "application/json" - }, - "body": { - "dry_run": false, - "process_limit": 10, - "lookback_days": 7, - "min_web_results": 2 - } - } - }' -``` - -**Schedule:** Runs hourly (at minute 0 of every hour) - -**What it does:** -1. Queries Neo4j for unprocessed `SearchQuery` nodes -2. Analyzes web results with Ollama -3. Creates/updates wiki pages via WikiPageWriter -4. Extracts and adds new entities to knowledge graph -5. Marks searches as processed - -## API Endpoints - -| Method | Endpoint | Description | -|--------|----------|-------------| -| GET | `/tasks` | List all tasks | -| GET | `/tasks/{task_name}` | Get task details | -| POST | `/tasks` | Create new task | -| PUT | `/tasks/{task_name}` | Update task | -| DELETE | `/tasks/{task_name}` | Delete task | -| POST | `/tasks/{task_name}/trigger` | Manually trigger task | -| GET | `/executions` | View execution history | -| GET | `/stats` | Get scheduler statistics | -| GET | `/health` | Health check | - -## Interactive Documentation - -Visit **http://192.168.86.149:8090/docs** for interactive Swagger UI with: -- Full API documentation -- Request/response schemas -- Try-it-out functionality -- Example payloads - -## Environment Variables - -Get API keys from containers: -```bash -# Scheduler API key -docker exec scheduler printenv SCHEDULER_API_KEY - -# Library Desk API key -docker exec library-desk printenv LIBRARY_API_KEY -``` - -## Task Management Tips - -1. **Test with dry_run**: Set `enabled: false` initially or use dry_run in task config -2. **Monitor executions**: Check `/executions` endpoint regularly -3. **Start conservative**: Use longer intervals (hourly, daily) before going to frequent runs -4. **Use priorities wisely**: Reserve 1-10 for critical tasks, use 20-30 for user tasks -5. **Set timeouts appropriately**: Long-running tasks need higher timeout_seconds - -## Troubleshooting - -### Task not running - -1. Check if task is enabled: `GET /tasks/{task_name}` -2. Check execution history: `GET /executions?task_name={task_name}` -3. Verify schedule matches current time -4. Check scheduler logs: `docker logs scheduler` - -### Task failing - -1. Check execution history for error messages -2. Verify endpoint is accessible from scheduler container -3. Test endpoint manually with curl -4. Check API keys and authentication -5. Verify executor configuration (especially for rest_api) - -### Debug a task - -```bash -# Get task details -curl http://192.168.86.149:8090/tasks/librarian_consolidation \ - -H "Authorization: Bearer $SCHEDULER_API_KEY" - -# View recent executions -curl http://192.168.86.149:8090/executions?task_name=librarian_consolidation&limit=5 \ - -H "Authorization: Bearer $SCHEDULER_API_KEY" - -# Trigger manually -curl -X POST http://192.168.86.149:8090/tasks/librarian_consolidation/trigger \ - -H "Authorization: Bearer $SCHEDULER_API_KEY" -``` - -## Architecture - -``` -┌─────────────────────────────────────────────────────────┐ -│ The Scheduler (AsyncIOScheduler) │ -│ │ -│ ┌────────────────────────────────────────────┐ │ -│ │ Cron Job (every minute) │ │ -│ │ ↓ │ │ -│ │ Query PostgreSQL for tasks scheduled │ │ -│ │ for current minute/hour/day │ │ -│ │ ↓ │ │ -│ │ Execute up to 5 tasks concurrently │ │ -│ │ (based on priority) │ │ -│ │ ↓ │ │ -│ │ Call appropriate executor: │ │ -│ │ - rest_api → HTTP request │ │ -│ │ - shell → subprocess │ │ -│ │ - python → exec │ │ -│ └────────────────────────────────────────────┘ │ -│ │ -│ PostgreSQL: │ -│ - scheduled_tasks (task definitions) │ -│ - task_executions (execution history) │ -└─────────────────────────────────────────────────────────┘ -``` - -## Current Registered Tasks - -As of 2025-12-09: - -1. **backup_docker_configs_daily** - Daily at 3:05 AM -2. **librarian_consolidation** - Hourly (minute 0) -3. **test_example_task** - Every minute (testing) -4. **sync_fastapi_docs_monthly** - 11th of month at 4:00 AM -5. **sync_ollama_docs_monthly** - 12th of month at 4:00 AM diff --git a/services/scheduler/pytest.ini b/services/scheduler/pytest.ini deleted file mode 100644 index 34f3c30..0000000 --- a/services/scheduler/pytest.ini +++ /dev/null @@ -1,45 +0,0 @@ -[pytest] -# Pytest configuration for The Scheduler - -# Test discovery patterns -python_files = test_*.py -python_classes = Test* -python_functions = test_* - -# Test paths -testpaths = tests - -# Asyncio mode -asyncio_mode = auto -asyncio_default_fixture_loop_scope = function - -# Coverage options -addopts = - --verbose - --strict-markers - --tb=short - --cov=src - --cov-report=term-missing - --cov-report=html:htmlcov - --cov-report=json:coverage.json - --cov-branch - -# Custom markers -markers = - unit: Unit tests (fast, no external dependencies) - integration: Integration tests (may use database, slower) - executor: Tests for task executors - api: API endpoint tests - slow: Tests that take a while to run - -# Ignore patterns -norecursedirs = .git .tox dist build *.egg venv __pycache__ node_modules - -# Console output -console_output_style = progress - -# Fail on first error (disable for CI) -# addopts = --exitfirst - -# Show local variables in tracebacks -# addopts = --showlocals diff --git a/services/scheduler/requirements.txt b/services/scheduler/requirements.txt deleted file mode 100644 index 00dec9d..0000000 --- a/services/scheduler/requirements.txt +++ /dev/null @@ -1,34 +0,0 @@ -# FastAPI ecosystem - Latest CVE-safe versions (Dec 2025) -fastapi~=0.124.0 # Latest non-vulnerable (no known CVEs) -uvicorn[standard]~=0.38.0 # Latest non-vulnerable (CVE-2025-43859 fixed in h11 0.16.0) -pydantic~=2.12.5 # Latest (CVE-2024-3772 fixed in 2.4.0+) -pydantic-settings~=2.7.0 # Settings management - -# Task scheduling -apscheduler~=3.11.1 # Stable release (avoid 4.x alpha) -sqlalchemy~=2.0.36 # Required by APScheduler jobstore - -# Database -psycopg2-binary~=2.9.11 # Latest stable PostgreSQL adapter -redis~=5.2.0 # Redis client - -# HTTP client -httpx~=0.28.1 # Latest stable async HTTP client - -# Web scraping (for doc mirroring) -scrapy~=2.12.0 # Latest stable -beautifulsoup4~=4.12.3 # HTML parsing -lxml~=5.1.0 # XML/HTML parser - -# Git operations -gitpython~=3.1.43 # Latest stable - -# Security/Auth -python-jose[cryptography]~=3.3.0 # JWT handling - -# Testing -pytest~=8.3.4 # Test framework -pytest-asyncio~=0.25.2 # Async test support -pytest-cov~=6.0.0 # Coverage reporting -httpx~=0.28.1 # Already included above, used for API testing -freezegun~=1.5.1 # Time mocking for scheduler tests diff --git a/services/scheduler/src/config.py b/services/scheduler/src/config.py deleted file mode 100644 index 0b24ba3..0000000 --- a/services/scheduler/src/config.py +++ /dev/null @@ -1,73 +0,0 @@ -""" -Configuration management for The Scheduler. -Uses Pydantic BaseSettings for type-safe environment variable loading. -""" -from functools import lru_cache -from pydantic_settings import BaseSettings -from pydantic import Field - - -class Settings(BaseSettings): - """Application settings loaded from environment variables.""" - - # Application - app_name: str = Field(default="The Scheduler", alias="APP_NAME") - app_version: str = Field(default="1.0.0", alias="APP_VERSION") - debug: bool = Field(default=False, alias="DEBUG") - host: str = Field(default="0.0.0.0", alias="HOST") - port: int = Field(default=8090, alias="PORT") - log_level: str = Field(default="INFO", alias="LOG_LEVEL") - - # Security - scheduler_api_key: str = Field(default="dev-key-change-me", alias="SCHEDULER_API_KEY") - - # PostgreSQL - postgres_host: str = Field(default="postgres-shared", alias="POSTGRES_HOST") - postgres_port: int = Field(default=5432, alias="POSTGRES_PORT") - postgres_db: str = Field(default="library_scheduler", alias="POSTGRES_DB") - postgres_user: str = Field(default="library_scheduler_user", alias="POSTGRES_USER") - postgres_password: str = Field(default="", alias="POSTGRES_PASSWORD") - - # Redis - redis_host: str = Field(default="redis-shared", alias="REDIS_HOST") - redis_port: int = Field(default=6379, alias="REDIS_PORT") - redis_db: int = Field(default=3, alias="REDIS_DB") - - # Gitea - gitea_url: str = Field(default="http://gitea:3000", alias="GITEA_URL") - gitea_user: str = Field(default="library", alias="GITEA_USER") - gitea_password: str = Field(default="", alias="GITEA_PASSWORD") - gitea_ssh_host: str = Field(default="gitea", alias="GITEA_SSH_HOST") - gitea_ssh_port: int = Field(default=22, alias="GITEA_SSH_PORT") - - # Backup Configuration - backup_retention_daily: int = Field(default=7, alias="BACKUP_RETENTION_DAILY") - backup_retention_weekly: int = Field(default=4, alias="BACKUP_RETENTION_WEEKLY") - backup_retention_monthly: int = Field(default=12, alias="BACKUP_RETENTION_MONTHLY") - - # Documentation Mirroring - docs_mirror_path: str = Field(default="/docs-mirror", alias="DOCS_MIRROR_PATH") - docs_check_interval: int = Field(default=21600, alias="DOCS_CHECK_INTERVAL") # 6 hours - - @property - def database_url(self) -> str: - """PostgreSQL connection URL for APScheduler.""" - return f"postgresql://{self.postgres_user}:{self.postgres_password}@{self.postgres_host}:{self.postgres_port}/{self.postgres_db}" - - @property - def redis_url(self) -> str: - """Redis connection URL.""" - return f"redis://{self.redis_host}:{self.redis_port}/{self.redis_db}" - - class Config: - env_file = ".env" - case_sensitive = False - - -@lru_cache -def get_settings() -> Settings: - """ - Get cached settings instance. - Using lru_cache ensures we only create one Settings instance. - """ - return Settings() diff --git a/services/scheduler/src/executors/__init__.py b/services/scheduler/src/executors/__init__.py deleted file mode 100644 index 743cf48..0000000 --- a/services/scheduler/src/executors/__init__.py +++ /dev/null @@ -1,23 +0,0 @@ -""" -Executor modules for The Scheduler. - -Each executor must implement an async execute(config, settings) function. - -Example: - async def execute(config: dict, settings: Settings) -> str: - ''' - Perform the task. - - Args: - config: Task-specific configuration from scheduled_tasks.config - settings: Global scheduler settings - - Returns: - Output message (success) - - Raises: - Exception: On failure (will be logged as error) - ''' - # Your task logic here - return "Task completed successfully" -""" diff --git a/services/scheduler/src/executors/config_backup_executor.py b/services/scheduler/src/executors/config_backup_executor.py deleted file mode 100644 index ef61728..0000000 --- a/services/scheduler/src/executors/config_backup_executor.py +++ /dev/null @@ -1,159 +0,0 @@ -""" -Config Backup Executor -Backs up Docker container configs and host-based service configs. -Replicates functionality of maintenance container's backup-configs.sh -""" -import asyncio -import logging -import tarfile -import tempfile -from datetime import datetime, timedelta -from pathlib import Path -from typing import List, Dict, Any - -from src.config import Settings - -logger = logging.getLogger(__name__) - - -async def execute(config: dict, settings: Settings) -> str: - """ - Execute config backup task. - - Config schema: - { - "sources": [ - { - "path": "/data/docker-data", - "name": "docker-data", - "excludes": ["*/cache/*", "*/temp/*", "*.log"] - } - ], - "backup_dir": "/backups/docker-configs", - "retention_days": 30, - "compress": true - } - - Args: - config: Backup configuration - settings: Global scheduler settings - - Returns: - Summary of backup operation - - Raises: - Exception: On backup failure - """ - sources = config.get('sources', []) - backup_dir = Path(config.get('backup_dir', '/backups/docker-configs')) - retention_days = config.get('retention_days', 30) - compress = config.get('compress', True) - - if not sources: - raise ValueError("No backup sources configured") - - timestamp = datetime.now().strftime('%Y%m%d-%H%M%S') - backup_filename = f"docker-configs-{timestamp}.tar.gz" - backup_file = backup_dir / backup_filename - - logger.info(f"Starting Docker configs backup: {backup_filename}") - - # Create backup directory - backup_dir.mkdir(parents=True, exist_ok=True) - - # Create temporary directory for staging - with tempfile.TemporaryDirectory(prefix='backup-') as temp_dir: - temp_path = Path(temp_dir) - results = [] - - # Backup each source - for source in sources: - source_path = Path(source['path']) - source_name = source['name'] - excludes = source.get('excludes', []) - - if not source_path.exists(): - logger.warning(f"Source path does not exist: {source_path}") - continue - - logger.info(f"Backing up {source_name} from {source_path}") - - # Create tar for this source - source_tar = temp_path / f"{source_name}.tar.gz" - - def tar_filter(tarinfo): - """Filter function to exclude patterns.""" - for pattern in excludes: - # Simple pattern matching (could be enhanced with fnmatch) - if pattern.replace('*/', '').replace('/*', '') in tarinfo.name: - logger.debug(f"Excluding: {tarinfo.name}") - return None - return tarinfo - - with tarfile.open(source_tar, 'w:gz') as tar: - tar.add( - source_path, - arcname=source_name, - filter=tar_filter, - recursive=True - ) - - source_size = source_tar.stat().st_size / (1024 * 1024) # MB - results.append(f"{source_name}: {source_size:.2f}MB") - logger.info(f"Backed up {source_name}: {source_size:.2f}MB") - - # Combine all source backups into final archive - logger.info("Creating combined backup archive...") - with tarfile.open(backup_file, 'w:gz') as final_tar: - for item in temp_path.glob('*.tar.gz'): - final_tar.add(item, arcname=item.name) - - # Verify backup created - if not backup_file.exists(): - raise Exception("Backup file was not created") - - backup_size = backup_file.stat().st_size / (1024 * 1024) # MB - logger.info(f"Backup created successfully: {backup_size:.2f}MB") - - # Clean up old backups - await cleanup_old_backups(backup_dir, retention_days) - - # Count remaining backups - backup_count = len(list(backup_dir.glob('docker-configs-*.tar.gz'))) - total_size = sum(f.stat().st_size for f in backup_dir.glob('docker-configs-*.tar.gz')) - total_size_mb = total_size / (1024 * 1024) - - output = ( - f"Backup completed: {backup_filename} ({backup_size:.2f}MB). " - f"Sources: {', '.join(results)}. " - f"Retention: {backup_count} backups, {total_size_mb:.2f}MB total." - ) - - logger.info(output) - return output - - -async def cleanup_old_backups(backup_dir: Path, retention_days: int): - """Remove backups older than retention period.""" - cutoff_date = datetime.now() - timedelta(days=retention_days) - removed_count = 0 - removed_size = 0 - - logger.info(f"Cleaning up backups older than {retention_days} days...") - - for backup_file in backup_dir.glob('docker-configs-*.tar.gz'): - # Get file modification time - file_mtime = datetime.fromtimestamp(backup_file.stat().st_mtime) - - if file_mtime < cutoff_date: - file_size = backup_file.stat().st_size - logger.info(f"Removing old backup: {backup_file.name} (from {file_mtime:%Y-%m-%d})") - backup_file.unlink() - removed_count += 1 - removed_size += file_size - - if removed_count > 0: - removed_size_mb = removed_size / (1024 * 1024) - logger.info(f"Removed {removed_count} old backups, freed {removed_size_mb:.2f}MB") - else: - logger.info("No old backups to remove") diff --git a/services/scheduler/src/executors/doc_sync_executor.py b/services/scheduler/src/executors/doc_sync_executor.py deleted file mode 100644 index 0a8f5d4..0000000 --- a/services/scheduler/src/executors/doc_sync_executor.py +++ /dev/null @@ -1,259 +0,0 @@ -""" -Documentation Sync Executor -Syncs documentation from upstream Git repositories to Gitea. -Clones source repos, extracts docs directories, pushes to Gitea. -""" -import asyncio -import logging -import shutil -from datetime import datetime -from pathlib import Path -from typing import List, Optional - -from src.config import Settings - -logger = logging.getLogger(__name__) - - -async def execute(config: dict, settings: Settings) -> str: - """ - Sync documentation from upstream Git repo to Gitea. - - Config schema: - { - "project": "fastapi", - "upstream_repo": "https://github.com/tiangolo/fastapi.git", - "docs_paths": ["/docs", "/docs_src"], - "gitea_repo": "library/docs-fastapi", - "gitea_url": "http://gitea:3000", - "branch": "main" - } - - Args: - config: Documentation sync configuration - settings: Global scheduler settings - - Returns: - Summary of sync operation - - Raises: - Exception: On sync failure - """ - project = config.get('project') - upstream_repo = config.get('upstream_repo') - docs_paths = config.get('docs_paths', ['/docs']) - gitea_repo = config.get('gitea_repo') - gitea_url = config.get('gitea_url', settings.gitea_url) - branch = config.get('branch', 'main') - - if not all([project, upstream_repo, gitea_repo]): - raise ValueError("Missing required config: project, upstream_repo, or gitea_repo") - - logger.info(f"Starting doc sync for {project}") - logger.info(f"Upstream: {upstream_repo}") - logger.info(f"Gitea: {gitea_repo}") - - # Work directory - work_dir = Path(f"/app/task-data/doc-sync/{project}") - upstream_dir = work_dir / "upstream" - gitea_dir = work_dir / "gitea" - - try: - # Clean work directory - if work_dir.exists(): - logger.info(f"Cleaning work directory: {work_dir}") - shutil.rmtree(work_dir) - work_dir.mkdir(parents=True) - - # Clone upstream repo (shallow clone for speed) - logger.info(f"Cloning upstream repo...") - await _run_command([ - 'git', 'clone', - '--depth', '1', - '--branch', branch, - upstream_repo, - str(upstream_dir) - ]) - - # Get upstream version/commit - upstream_commit = await _get_git_commit(upstream_dir) - upstream_date = datetime.now().strftime('%Y-%m-%d') - logger.info(f"Upstream commit: {upstream_commit[:8]}") - - # Clone Gitea repo (or create if doesn't exist) - # Build authenticated URL for Gitea - gitea_url_clean = gitea_url.replace('http://', '').replace('https://', '') - gitea_clone_url = f"http://{settings.gitea_user}:{settings.gitea_password}@{gitea_url_clean}/{gitea_repo}.git" - # Log without credentials - logger.info(f"Cloning Gitea repo: {gitea_url}/{gitea_repo}.git") - - # Try to clone, if fails create new repo - try: - await _run_command([ - 'git', 'clone', - gitea_clone_url, - str(gitea_dir) - ]) - except Exception as e: - logger.warning(f"Gitea repo doesn't exist, will create: {e}") - gitea_dir.mkdir(parents=True) - await _run_command(['git', 'init'], cwd=gitea_dir) - await _run_command(['git', 'checkout', '-b', branch], cwd=gitea_dir) - - # Set remote - await _run_command([ - 'git', 'remote', 'add', 'origin', - gitea_clone_url - ], cwd=gitea_dir) - - # Clear existing content in Gitea repo (except .git) - for item in gitea_dir.iterdir(): - if item.name != '.git': - if item.is_dir(): - shutil.rmtree(item) - else: - item.unlink() - - # Determine what to copy based on docs_paths - copied_paths = [] - - # If docs_paths is empty or contains "." or "/", sync entire repo - if not docs_paths or any(p in [".", "/", ""] for p in docs_paths): - logger.info(f"Copying entire repository...") - for item in upstream_dir.iterdir(): - if item.name != '.git': - dest = gitea_dir / item.name - if item.is_dir(): - shutil.copytree(item, dest) - else: - shutil.copy2(item, dest) - copied_paths = ["entire repository"] - else: - # Copy only specified paths - logger.info(f"Copying specific paths: {docs_paths}") - for doc_path in docs_paths: - source = upstream_dir / doc_path.lstrip('/') - if source.exists(): - dest = gitea_dir / source.name - logger.info(f"Copying {source.name}...") - if source.is_dir(): - shutil.copytree(source, dest) - else: - shutil.copy2(source, dest) - copied_paths.append(source.name) - else: - logger.warning(f"Path not found in upstream: {doc_path}") - - if not copied_paths: - raise Exception("No documentation paths were copied") - - # Create .SYNC_INFO.md with metadata (don't overwrite README.md from upstream) - sync_info_path = gitea_dir / ".SYNC_INFO.md" - content_desc = "Complete repository mirror" if "entire repository" in copied_paths else f"Paths: {', '.join(copied_paths)}" - sync_info_content = f"""# Sync Information - -This is a mirror of the {project.title()} repository. - -**Synced from:** {upstream_repo} -**Branch:** {branch} -**Commit:** {upstream_commit} -**Sync Date:** {upstream_date} -**Content:** {content_desc} - ---- - -This repository is automatically synced monthly by The Scheduler. -For the latest updates, visit the official repository. -""" - sync_info_path.write_text(sync_info_content) - - # Git add, commit, push - await _run_command(['git', 'add', '.'], cwd=gitea_dir) - - # Check if there are changes - status = await _run_command( - ['git', 'status', '--porcelain'], - cwd=gitea_dir, - capture=True - ) - - if not status.strip(): - logger.info("No changes detected, skipping commit") - return f"Documentation already up to date (commit: {upstream_commit[:8]})" - - # Commit changes - commit_msg = f"Sync {project} docs from {upstream_commit[:8]} on {upstream_date}" - await _run_command([ - 'git', 'commit', - '-m', commit_msg - ], cwd=gitea_dir) - - # Tag with date - tag = f"sync-{upstream_date}" - await _run_command([ - 'git', 'tag', '-f', tag, - '-m', f"Documentation snapshot {upstream_date}" - ], cwd=gitea_dir) - - # Push to Gitea - logger.info("Pushing to Gitea...") - await _run_command([ - 'git', 'push', 'origin', branch, '--force' - ], cwd=gitea_dir) - - await _run_command([ - 'git', 'push', 'origin', tag, '--force' - ], cwd=gitea_dir) - - # Cleanup - logger.info("Cleaning up work directory...") - shutil.rmtree(work_dir) - - result = ( - f"Successfully synced {project} documentation. " - f"Copied: {', '.join(copied_paths)}. " - f"Upstream commit: {upstream_commit[:8]}. " - f"Tagged: {tag}" - ) - logger.info(result) - return result - - except Exception as e: - # Cleanup on error - if work_dir.exists(): - shutil.rmtree(work_dir) - raise - - -async def _run_command( - cmd: List[str], - cwd: Optional[Path] = None, - capture: bool = False -) -> str: - """Run shell command asynchronously.""" - logger.debug(f"Running: {' '.join(cmd)} (cwd: {cwd})") - - proc = await asyncio.create_subprocess_exec( - *cmd, - cwd=cwd, - stdout=asyncio.subprocess.PIPE if capture else asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE - ) - - stdout, stderr = await proc.communicate() - - if proc.returncode != 0: - error_msg = stderr.decode() if stderr else "Unknown error" - raise Exception(f"Command failed: {' '.join(cmd)}\n{error_msg}") - - return stdout.decode() if capture else "" - - -async def _get_git_commit(repo_dir: Path) -> str: - """Get current git commit hash.""" - output = await _run_command( - ['git', 'rev-parse', 'HEAD'], - cwd=repo_dir, - capture=True - ) - return output.strip() diff --git a/services/scheduler/src/executors/example_executor.py b/services/scheduler/src/executors/example_executor.py deleted file mode 100644 index 3d4b62d..0000000 --- a/services/scheduler/src/executors/example_executor.py +++ /dev/null @@ -1,42 +0,0 @@ -""" -Example executor demonstrating the pattern. -Shows how to write task executors for The Scheduler. -""" -import asyncio -import logging -from src.config import Settings - -logger = logging.getLogger(__name__) - - -async def execute(config: dict, settings: Settings) -> str: - """ - Example task executor. - - Args: - config: Task configuration from scheduled_tasks.config JSONB field - Example: {"message": "Hello", "delay_seconds": 2} - settings: Global scheduler settings (database, API keys, etc.) - - Returns: - Output message describing what was done - - Raises: - Exception: On failure (will be caught and logged by executor framework) - """ - message = config.get('message', 'No message configured') - delay = config.get('delay_seconds', 1) - - logger.info(f"Example executor starting: {message}") - - # Simulate some work - await asyncio.sleep(delay) - - # You can access settings - logger.info(f"Using database: {settings.postgres_db}") - - # Return success message - output = f"Executed example task: {message} (took {delay}s)" - logger.info(output) - - return output diff --git a/services/scheduler/src/executors/rest_api_executor.py b/services/scheduler/src/executors/rest_api_executor.py deleted file mode 100644 index bb456fa..0000000 --- a/services/scheduler/src/executors/rest_api_executor.py +++ /dev/null @@ -1,266 +0,0 @@ -""" -Generic REST API Executor - -Universal executor for calling any REST API endpoint across the system. -Supports GET, POST, PUT, DELETE with configurable payloads, headers, and authentication. - -This executor can be used to trigger any service endpoint: -- Library Desk knowledge consolidation -- Core API operations -- External webhooks -- Any HTTP-based task - -Config schema: - { - "url": "http://service:port/endpoint", - "method": "POST", # GET, POST, PUT, DELETE, PATCH - "payload": {...}, # Request body (for POST/PUT/PATCH) - "headers": {...}, # Additional headers - "auth": { - "type": "bearer", # bearer, basic, api_key - "token": "${ENV_VAR}", # Use ${VAR} for env vars - "header": "Authorization" # Optional: header name for API key - }, - "timeout": 300, # Timeout in seconds (default: 300) - "verify_ssl": true, # SSL verification (default: true) - "success_codes": [200, 201, 202], # Expected success codes - "response_path": "result.message" # JSONPath to extract from response - } - -Example configs: - -1. Library Desk Knowledge Consolidation: - { - "url": "http://library-desk:8089/consolidate/knowledge", - "method": "POST", - "payload": {"process_limit": 10, "lookback_days": 7, "dry_run": false}, - "auth": {"type": "bearer", "token": "${LIBRARY_DESK_API_KEY}"} - } - -2. Core API Container Restart: - { - "url": "http://core-api:8088/v1/infrastructure/containers/nginx/restart", - "method": "POST", - "auth": {"type": "bearer", "token": "${CORE_API_KEY}"} - } - -3. External Webhook: - { - "url": "https://hooks.slack.com/services/YOUR/WEBHOOK/URL", - "method": "POST", - "payload": {"text": "Scheduled task completed"}, - "verify_ssl": true - } -""" -import logging -import os -import re -import httpx -from typing import Any, Dict, Optional - -from src.config import Settings - -logger = logging.getLogger(__name__) - - -async def execute(config: dict, settings: Settings) -> str: - """ - Execute REST API call with configured parameters. - - Args: - config: REST API call configuration (see module docstring) - settings: Global scheduler settings - - Returns: - Response summary or extracted result - - Raises: - ValueError: On configuration error - Exception: On API call failure - """ - # Required configuration - url = config.get('url') - if not url: - raise ValueError("Missing required config: 'url'") - - method = config.get('method', 'POST').upper() - if method not in ['GET', 'POST', 'PUT', 'DELETE', 'PATCH']: - raise ValueError(f"Invalid HTTP method: {method}") - - # Optional configuration - payload = config.get('payload', {}) - headers = config.get('headers', {}) - timeout = config.get('timeout', 300) - verify_ssl = config.get('verify_ssl', True) - success_codes = config.get('success_codes', [200, 201, 202, 204]) - response_path = config.get('response_path') - - # Handle authentication - auth_config = config.get('auth', {}) - if auth_config: - auth_header = _build_auth_header(auth_config, settings) - if auth_header: - headers.update(auth_header) - - # Substitute environment variables in URL and payload - url = _substitute_env_vars(url) - payload = _substitute_env_vars_recursive(payload) - - logger.info(f"Executing REST API call: {method} {url}") - if payload: - logger.debug(f"Payload: {_redact_sensitive(payload)}") - - # Make HTTP request - try: - async with httpx.AsyncClient(timeout=timeout, verify=verify_ssl) as client: - if method == 'GET': - response = await client.get(url, headers=headers) - elif method == 'POST': - response = await client.post(url, json=payload, headers=headers) - elif method == 'PUT': - response = await client.put(url, json=payload, headers=headers) - elif method == 'DELETE': - response = await client.delete(url, headers=headers) - elif method == 'PATCH': - response = await client.patch(url, json=payload, headers=headers) - - # Check status code - if response.status_code not in success_codes: - error_msg = ( - f"API call failed with status {response.status_code}: " - f"{response.text[:500]}" - ) - logger.error(error_msg) - raise Exception(error_msg) - - # Parse response - try: - response_data = response.json() - except: - response_data = {"text": response.text} - - # Extract specific field if response_path provided - result_text = None - if response_path and isinstance(response_data, dict): - result_text = _extract_json_path(response_data, response_path) - - if not result_text: - # Build summary from response - if isinstance(response_data, dict): - # Look for common result fields - result_text = ( - response_data.get('message') or - response_data.get('result') or - response_data.get('summary') or - f"Success ({response.status_code})" - ) - else: - result_text = f"Success ({response.status_code})" - - logger.info(f"API call succeeded: {result_text}") - return str(result_text) - - except httpx.HTTPStatusError as e: - error_msg = f"HTTP {e.response.status_code}: {e.response.text[:500]}" - logger.error(error_msg) - raise Exception(error_msg) - except httpx.RequestError as e: - error_msg = f"Request failed: {str(e)}" - logger.error(error_msg) - raise Exception(error_msg) - except Exception as e: - logger.error(f"REST API call failed: {e}", exc_info=True) - raise - - -def _build_auth_header(auth_config: dict, settings: Settings) -> Optional[Dict[str, str]]: - """Build authentication header from config.""" - auth_type = auth_config.get('type', '').lower() - - if auth_type == 'bearer': - token = auth_config.get('token', '') - token = _substitute_env_vars(token) - if token: - return {"Authorization": f"Bearer {token}"} - - elif auth_type == 'basic': - username = _substitute_env_vars(auth_config.get('username', '')) - password = _substitute_env_vars(auth_config.get('password', '')) - if username and password: - import base64 - credentials = base64.b64encode(f"{username}:{password}".encode()).decode() - return {"Authorization": f"Basic {credentials}"} - - elif auth_type == 'api_key': - key = _substitute_env_vars(auth_config.get('key', '')) - header_name = auth_config.get('header', 'X-API-Key') - if key: - return {header_name: key} - - return None - - -def _substitute_env_vars(text: str) -> str: - """Substitute ${ENV_VAR} placeholders with environment variables.""" - if not isinstance(text, str): - return text - - # Find all ${VAR} patterns - pattern = r'\$\{([A-Z_][A-Z0-9_]*)\}' - matches = re.findall(pattern, text) - - for var_name in matches: - env_value = os.getenv(var_name, '') - if not env_value: - logger.warning(f"Environment variable not found: {var_name}") - text = text.replace(f"${{{var_name}}}", env_value) - - return text - - -def _substitute_env_vars_recursive(data: Any) -> Any: - """Recursively substitute environment variables in nested structures.""" - if isinstance(data, dict): - return {k: _substitute_env_vars_recursive(v) for k, v in data.items()} - elif isinstance(data, list): - return [_substitute_env_vars_recursive(item) for item in data] - elif isinstance(data, str): - return _substitute_env_vars(data) - else: - return data - - -def _extract_json_path(data: dict, path: str) -> Optional[str]: - """ - Extract value from nested dict using dot notation. - - Example: "result.message" -> data["result"]["message"] - """ - try: - keys = path.split('.') - value = data - for key in keys: - if isinstance(value, dict): - value = value.get(key) - else: - return None - return str(value) if value is not None else None - except: - return None - - -def _redact_sensitive(data: Any) -> Any: - """Redact sensitive fields from logs.""" - if isinstance(data, dict): - redacted = {} - sensitive_keys = ['password', 'token', 'api_key', 'secret', 'auth'] - for k, v in data.items(): - if any(s in k.lower() for s in sensitive_keys): - redacted[k] = '***REDACTED***' - else: - redacted[k] = _redact_sensitive(v) - return redacted - elif isinstance(data, list): - return [_redact_sensitive(item) for item in data] - else: - return data diff --git a/services/scheduler/src/main.py b/services/scheduler/src/main.py deleted file mode 100644 index 086c888..0000000 --- a/services/scheduler/src/main.py +++ /dev/null @@ -1,530 +0,0 @@ -""" -The Scheduler - System-wide maintenance orchestration. -Handles backups, documentation mirroring, cleanup, and automated tasks. - -Architecture: Hybrid APScheduler + DB-based priority system -- APScheduler runs a single job every minute -- Job queries DB for tasks scheduled in that minute -- Executes up to 5 tasks concurrently based on priority -""" -from fastapi import FastAPI, HTTPException, Depends, Header -from apscheduler.schedulers.asyncio import AsyncIOScheduler -from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore -from apscheduler.triggers.cron import CronTrigger -from contextlib import asynccontextmanager -import logging - -from src.config import get_settings, Settings -from src.tasks.executor import TaskExecutor -from src.models import TaskCreate, TaskUpdate, TaskResponse - -# Configure logging -logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' -) -logger = logging.getLogger(__name__) - -# Global instances -scheduler: AsyncIOScheduler | None = None -task_executor: TaskExecutor | None = None - - -def get_scheduler() -> AsyncIOScheduler: - """Dependency to get scheduler instance.""" - if scheduler is None: - raise HTTPException(500, "Scheduler not initialized") - return scheduler - - -def get_task_executor() -> TaskExecutor: - """Dependency to get task executor instance.""" - if task_executor is None: - raise HTTPException(500, "Task executor not initialized") - return task_executor - -# Lifespan manager for startup/shutdown -@asynccontextmanager -async def lifespan(app: FastAPI): - """Manage application lifecycle - startup and shutdown.""" - global scheduler, task_executor - - settings = get_settings() - - # Startup - logger.info("=" * 60) - logger.info("The Scheduler - System-wide Maintenance Orchestration") - logger.info("=" * 60) - logger.info(f"Architecture: Hybrid APScheduler + DB-based priority system") - logger.info(f"Database: {settings.postgres_host}:{settings.postgres_port}/{settings.postgres_db}") - logger.info(f"API: http://{settings.host}:{settings.port}") - logger.info(f"Docs: http://{settings.host}:{settings.port}/docs") - logger.info("=" * 60) - - # Initialize task executor - task_executor = TaskExecutor(settings) - logger.info("Task executor initialized (max 5 concurrent tasks)") - - # Initialize APScheduler with minimal configuration - # No jobstore needed - we only have one in-memory job - scheduler = AsyncIOScheduler( - job_defaults={ - 'coalesce': True, # Combine missed runs - 'max_instances': 1, # Only one instance running - 'misfire_grace_time': 30 # 30s grace period for minute-based execution - } - ) - - # Add the single minute-based task processor - scheduler.add_job( - func=task_executor.process_minute, - trigger=CronTrigger(minute='*'), # Run every minute - id='process_tasks', - name='Process scheduled tasks', - replace_existing=True - ) - - try: - scheduler.start() - logger.info("Scheduler started - processing tasks every minute") - logger.info("Priority system: 1-5 (emergency/system), 10-30 (user), 40-70+ (maintenance)") - except Exception as e: - logger.error(f"Failed to start scheduler: {e}") - raise - - yield - - # Shutdown - logger.info("Stopping The Scheduler...") - if scheduler: - scheduler.shutdown(wait=True) - logger.info("Scheduler stopped") - - -# FastAPI app -app = FastAPI( - title="The Scheduler", - version="1.0.0", - description="System-wide maintenance orchestration - backups, doc mirroring, cleanup, task automation", - lifespan=lifespan -) - -# Dependencies -async def verify_api_key( - authorization: str = Header(None), - settings: Settings = Depends(get_settings) -): - """Verify API key from Authorization header.""" - if not authorization or not authorization.startswith("Bearer "): - raise HTTPException(401, "Missing API key") - key = authorization.replace("Bearer ", "") - if key != settings.scheduler_api_key: - raise HTTPException(403, "Invalid API key") - return key - -# ============================================================================ -# Public Endpoints (no auth required) -# ============================================================================ - -@app.get("/health") -async def health( - settings: Settings = Depends(get_settings), - sched: AsyncIOScheduler = Depends(get_scheduler) -): - """Health check endpoint.""" - return { - "status": "healthy", - "scheduler_running": sched.running, - "jobs_count": len(sched.get_jobs()), - "database": settings.postgres_db - } - -# ============================================================================ -# Protected Endpoints (require API key) -# ============================================================================ - -@app.get("/tasks") -async def list_tasks( - enabled: bool = None, - service: str = None, - api_key: str = Depends(verify_api_key), - executor: TaskExecutor = Depends(get_task_executor) -): - """List all scheduled tasks from database.""" - import psycopg2.extras - - query = "SELECT * FROM scheduled_tasks WHERE 1=1" - params = [] - - if enabled is not None: - query += " AND enabled = %s" - params.append(enabled) - - if service: - query += " AND service = %s" - params.append(service) - - query += " ORDER BY priority ASC, task_name ASC" - - with executor.get_db_connection() as conn: - with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: - cur.execute(query, params) - tasks = [dict(task) for task in cur.fetchall()] - - return { - "tasks": tasks, - "count": len(tasks) - } - -@app.get("/tasks/{task_name}") -async def get_task_details( - task_name: str, - api_key: str = Depends(verify_api_key), - executor: TaskExecutor = Depends(get_task_executor) -): - """Get details for a specific task.""" - import psycopg2.extras - - with executor.get_db_connection() as conn: - with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: - cur.execute("SELECT * FROM scheduled_tasks WHERE task_name = %s", (task_name,)) - task = cur.fetchone() - - if not task: - raise HTTPException(404, f"Task '{task_name}' not found") - - return dict(task) - -@app.post("/tasks", response_model=TaskResponse, tags=["Task Management"]) -async def create_task( - task: TaskCreate, - api_key: str = Depends(verify_api_key), - executor: TaskExecutor = Depends(get_task_executor) -): - """ - Create a new scheduled task. - - ## Schedule Pattern - - Use cron-style fields where `-1` means "every": - - `minute: -1, hour: -1` → Runs every minute - - `minute: 0, hour: -1` → Runs at minute 0 of every hour (hourly) - - `minute: 0, hour: 2` → Runs at 2:00 AM every day - - `minute: 0, hour: 2, day_of_week: 0` → Runs at 2:00 AM every Monday - - ## Priority Levels - - - **1-5**: Emergency/critical system tasks - - **10-30**: User-initiated tasks - - **40-70**: Background maintenance - - **70+**: Low priority cleanup - - ## Executor Types - - ### `rest_api` Executor - - Calls HTTP endpoints. Config format: - ```json - { - "method": "POST", - "url": "http://service:port/endpoint", - "headers": { - "Authorization": "Bearer ${ENV_VAR}", - "Content-Type": "application/json" - }, - "body": {"key": "value"} - } - ``` - - Environment variables can be referenced with `${VAR_NAME}` syntax. - - ## Example: Librarian Consolidation Task - - ```json - { - "task_name": "librarian_consolidation", - "service": "library-desk", - "executor": "rest_api", - "priority": 25, - "description": "Processes search queries and consolidates knowledge", - "minute": 0, - "hour": -1, - "config": { - "method": "POST", - "url": "http://library-desk:8089/consolidation/run", - "headers": { - "Authorization": "Bearer ${LIBRARY_DESK_API_KEY}", - "Content-Type": "application/json" - }, - "body": { - "dry_run": false, - "process_limit": 10 - } - } - } - ``` - - This creates an hourly task that calls the consolidation endpoint. - """ - import psycopg2.extras - import json - - # Convert task model to dict - task_data = task.model_dump() - - # Convert config dict to JSON string if present - if 'config' in task_data and isinstance(task_data['config'], dict): - task_data['config'] = json.dumps(task_data['config']) - - with executor.get_db_connection() as conn: - with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: - cur.execute(""" - INSERT INTO scheduled_tasks - (task_name, service, executor, priority, minute, hour, - day_of_month, month, day_of_week, enabled, description, - config, max_retries, timeout_seconds, created_by) - VALUES - (%(task_name)s, %(service)s, %(executor)s, %(priority)s, - %(minute)s, %(hour)s, %(day_of_month)s, %(month)s, - %(day_of_week)s, %(enabled)s, %(description)s, - %(config)s::jsonb, %(max_retries)s, %(timeout_seconds)s, - %(created_by)s) - RETURNING * - """, task_data) - new_task = dict(cur.fetchone()) - conn.commit() - - logger.info(f"Created task: {new_task['task_name']}") - return new_task - -@app.put("/tasks/{task_name}") -async def update_task( - task_name: str, - task_data: dict, - api_key: str = Depends(verify_api_key), - executor: TaskExecutor = Depends(get_task_executor) -): - """Update an existing scheduled task.""" - import psycopg2.extras - import json - - # Build update query dynamically - allowed_fields = ['service', 'executor', 'priority', 'minute', 'hour', - 'day_of_month', 'month', 'day_of_week', 'enabled', - 'description', 'config', 'max_retries', 'timeout_seconds'] - - updates = {k: v for k, v in task_data.items() if k in allowed_fields} - if not updates: - raise HTTPException(400, "No valid fields to update") - - # Convert config dict to JSON string if present - if 'config' in updates and isinstance(updates['config'], dict): - updates['config'] = json.dumps(updates['config']) - - set_clause = ', '.join([f"{k} = %({k})s" for k in updates.keys()]) - - with executor.get_db_connection() as conn: - with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: - cur.execute(f""" - UPDATE scheduled_tasks - SET {set_clause}, updated_at = NOW() - WHERE task_name = %(task_name)s - RETURNING * - """, {**updates, 'task_name': task_name}) - - updated_task = cur.fetchone() - if not updated_task: - raise HTTPException(404, f"Task '{task_name}' not found") - - conn.commit() - - logger.info(f"Updated task: {task_name}") - return dict(updated_task) - -@app.delete("/tasks/{task_name}") -async def delete_task( - task_name: str, - api_key: str = Depends(verify_api_key), - executor: TaskExecutor = Depends(get_task_executor) -): - """Delete a scheduled task.""" - with executor.get_db_connection() as conn: - with conn.cursor() as cur: - cur.execute(""" - DELETE FROM scheduled_tasks - WHERE task_name = %s - RETURNING task_name - """, (task_name,)) - - deleted = cur.fetchone() - if not deleted: - raise HTTPException(404, f"Task '{task_name}' not found") - - conn.commit() - - logger.info(f"Deleted task: {task_name}") - return {"message": f"Task '{task_name}' deleted successfully"} - -@app.post("/tasks/{task_name}/trigger") -async def trigger_task( - task_name: str, - api_key: str = Depends(verify_api_key), - executor: TaskExecutor = Depends(get_task_executor) -): - """Manually trigger a task to run immediately.""" - import psycopg2.extras - - # Get task details - with executor.get_db_connection() as conn: - with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: - cur.execute("SELECT * FROM scheduled_tasks WHERE task_name = %s", (task_name,)) - task = cur.fetchone() - - if not task: - raise HTTPException(404, f"Task '{task_name}' not found") - - if not task['enabled']: - raise HTTPException(400, f"Task '{task_name}' is disabled") - - # Execute task immediately in background - import asyncio - asyncio.create_task(executor.execute_task(dict(task))) - - logger.info(f"Manually triggered task: {task_name}") - return { - "message": f"Task '{task_name}' triggered successfully", - "task_name": task_name, - "priority": task['priority'], - "executor": task['executor'] - } - -# Legacy endpoints (deprecated) -@app.post("/tasks/backup") -async def trigger_backup(api_key: str = Depends(verify_api_key)): - """Trigger backup tasks manually (deprecated - use POST /tasks/{name}/trigger)""" - # TODO: Implement backup executor - logger.info("Manual backup triggered") - return { - "message": "Backup task triggered", - "status": "not_implemented", - "note": "Backup executor needs to be implemented" - } - -@app.post("/tasks/docs/update") -async def trigger_docs_update( - project: str = None, - api_key: str = Depends(verify_api_key) -): - """Trigger documentation mirror update""" - # TODO: Implement doc mirror executor - logger.info(f"Doc mirror update triggered for project: {project or 'all'}") - return { - "message": f"Documentation update triggered for {project or 'all projects'}", - "status": "not_implemented", - "note": "Doc mirror executor needs to be implemented" - } - -@app.post("/tasks/docs/check-versions") -async def check_doc_versions(api_key: str = Depends(verify_api_key)): - """Check for new documentation versions""" - # TODO: Implement version check executor - logger.info("Version check triggered") - return { - "message": "Version check triggered", - "status": "not_implemented", - "note": "Version check executor needs to be implemented" - } - -@app.post("/tasks/cleanup") -async def trigger_cleanup(api_key: str = Depends(verify_api_key)): - """Run cleanup tasks""" - # TODO: Implement cleanup executor - logger.info("Cleanup task triggered") - return { - "message": "Cleanup task triggered", - "status": "not_implemented", - "note": "Cleanup executor needs to be implemented" - } - -@app.get("/executions") -async def task_history( - limit: int = 20, - task_name: str = None, - service: str = None, - status: str = None, - api_key: str = Depends(verify_api_key), - executor: TaskExecutor = Depends(get_task_executor) -): - """View task execution history.""" - import psycopg2.extras - - query = "SELECT * FROM task_executions WHERE 1=1" - params = [] - - if task_name: - query += " AND task_name = %s" - params.append(task_name) - - if service: - query += " AND service = %s" - params.append(service) - - if status: - query += " AND status = %s" - params.append(status) - - query += " ORDER BY triggered_at DESC LIMIT %s" - params.append(limit) - - with executor.get_db_connection() as conn: - with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: - cur.execute(query, params) - executions = [dict(ex) for ex in cur.fetchall()] - - return { - "executions": executions, - "count": len(executions), - "limit": limit - } - -@app.get("/stats") -async def stats( - api_key: str = Depends(verify_api_key), - settings: Settings = Depends(get_settings), - sched: AsyncIOScheduler = Depends(get_scheduler), - task_exec: TaskExecutor = Depends(get_task_executor) -): - """Get system statistics.""" - import psycopg2.extras - - # Query task stats from database - with task_exec.get_db_connection() as conn: - with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: - # Count enabled tasks - cur.execute("SELECT COUNT(*) as count FROM scheduled_tasks WHERE enabled = true") - enabled_tasks = cur.fetchone()['count'] - - # Count running tasks - cur.execute("SELECT COUNT(*) as count FROM task_executions WHERE status = 'running'") - running_tasks = cur.fetchone()['count'] - - # Recent execution stats (last 24 hours) - cur.execute(""" - SELECT status, COUNT(*) as count - FROM task_executions - WHERE triggered_at > NOW() - INTERVAL '24 hours' - GROUP BY status - """) - execution_stats = {row['status']: row['count'] for row in cur.fetchall()} - - return { - "scheduler_running": sched.running, - "minute_processor_active": True, # If we got here, it's running - "database": settings.postgres_db, - "tasks_enabled": enabled_tasks, - "tasks_currently_running": running_tasks, - "concurrent_limit": 5, - "execution_stats_24h": execution_stats, - "priority_system": "1-5 (emergency/system), 10-30 (user), 40-70+ (maintenance)" - } - diff --git a/services/scheduler/src/models.py b/services/scheduler/src/models.py deleted file mode 100644 index f131b7f..0000000 --- a/services/scheduler/src/models.py +++ /dev/null @@ -1,210 +0,0 @@ -""" -Pydantic models for The Scheduler API. -""" -from pydantic import BaseModel, Field -from typing import Optional, Dict, Any -from enum import Enum - - -class SchedulePattern(str, Enum): - """Common schedule patterns.""" - EVERY_MINUTE = "every_minute" - HOURLY = "hourly" - DAILY = "daily" - WEEKLY = "weekly" - MONTHLY = "monthly" - - -class TaskExecutor(str, Enum): - """Available task executors.""" - REST_API = "rest_api" - SHELL = "shell" - PYTHON = "python" - - -class TaskPriorityLevel(str, Enum): - """Task priority levels.""" - EMERGENCY = "emergency" # 1-5: Critical system tasks - SYSTEM = "system" # 5-10: System maintenance - USER = "user" # 10-30: User-initiated tasks - MAINTENANCE = "maintenance" # 40-70: Background maintenance - LOW = "low" # 70+: Low priority cleanup - - -class TaskBase(BaseModel): - """Base task fields.""" - task_name: str = Field( - ..., - description="Unique task identifier (e.g., 'librarian_consolidation')", - example="librarian_consolidation" - ) - service: str = Field( - ..., - description="Service that owns this task (e.g., 'library-desk', 'core-api')", - example="library-desk" - ) - executor: str = Field( - ..., - description="Executor type: 'rest_api', 'shell', or 'python'", - example="rest_api" - ) - priority: int = Field( - ..., - ge=1, - le=100, - description="Priority level (1-5: emergency, 10-30: user, 40-70: maintenance, 70+: low)", - example=25 - ) - description: Optional[str] = Field( - None, - description="Human-readable task description", - example="Processes unprocessed search queries and consolidates knowledge into wiki pages" - ) - - -class TaskSchedule(BaseModel): - """Cron-style schedule fields.""" - minute: int = Field( - -1, - ge=-1, - le=59, - description="Minute to run (-1 = every minute, 0-59 = specific minute)", - example=-1 - ) - hour: int = Field( - -1, - ge=-1, - le=23, - description="Hour to run (-1 = every hour, 0-23 = specific hour)", - example=-1 - ) - day_of_month: int = Field( - -1, - ge=-1, - le=31, - description="Day of month to run (-1 = every day, 1-31 = specific day)", - example=-1 - ) - month: int = Field( - -1, - ge=-1, - le=12, - description="Month to run (-1 = every month, 1-12 = specific month)", - example=-1 - ) - day_of_week: int = Field( - -1, - ge=-1, - le=6, - description="Day of week to run (-1 = every day, 0-6 = Monday-Sunday)", - example=-1 - ) - - -class TaskConfig(BaseModel): - """Task execution configuration.""" - enabled: bool = Field( - True, - description="Whether task is enabled", - example=True - ) - max_retries: int = Field( - 3, - ge=0, - le=10, - description="Maximum retry attempts on failure", - example=3 - ) - timeout_seconds: int = Field( - 3600, - ge=1, - description="Execution timeout in seconds", - example=3600 - ) - config: Optional[Dict[str, Any]] = Field( - None, - description="Executor-specific configuration (varies by executor type)", - example={ - "method": "POST", - "url": "http://library-desk:8089/consolidation/run", - "headers": { - "Authorization": "Bearer ${LIBRARY_DESK_API_KEY}", - "Content-Type": "application/json" - }, - "body": { - "dry_run": False, - "process_limit": 10, - "lookback_days": 7, - "min_web_results": 2 - } - } - ) - - -class TaskCreate(TaskBase, TaskSchedule, TaskConfig): - """Request model for creating a new scheduled task.""" - created_by: Optional[str] = Field( - "api", - description="User or system that created this task", - example="api" - ) - - class Config: - json_schema_extra = { - "example": { - "task_name": "librarian_consolidation", - "service": "library-desk", - "executor": "rest_api", - "priority": 25, - "description": "Processes unprocessed search queries and consolidates knowledge", - "minute": 0, - "hour": -1, - "day_of_month": -1, - "month": -1, - "day_of_week": -1, - "enabled": True, - "max_retries": 3, - "timeout_seconds": 3600, - "config": { - "method": "POST", - "url": "http://library-desk:8089/consolidation/run", - "headers": { - "Authorization": "Bearer ${LIBRARY_DESK_API_KEY}", - "Content-Type": "application/json" - }, - "body": { - "dry_run": False, - "process_limit": 10, - "lookback_days": 7, - "min_web_results": 2 - } - }, - "created_by": "api" - } - } - - -class TaskUpdate(BaseModel): - """Request model for updating a scheduled task.""" - service: Optional[str] = None - executor: Optional[str] = None - priority: Optional[int] = Field(None, ge=1, le=100) - minute: Optional[int] = Field(None, ge=-1, le=59) - hour: Optional[int] = Field(None, ge=-1, le=23) - day_of_month: Optional[int] = Field(None, ge=-1, le=31) - month: Optional[int] = Field(None, ge=-1, le=12) - day_of_week: Optional[int] = Field(None, ge=-1, le=6) - enabled: Optional[bool] = None - description: Optional[str] = None - config: Optional[Dict[str, Any]] = None - max_retries: Optional[int] = Field(None, ge=0, le=10) - timeout_seconds: Optional[int] = Field(None, ge=1) - - -class TaskResponse(TaskCreate): - """Response model for task operations.""" - created_at: str - updated_at: Optional[str] = None - - class Config: - from_attributes = True diff --git a/services/scheduler/src/tasks/__init__.py b/services/scheduler/src/tasks/__init__.py deleted file mode 100644 index 904b3bc..0000000 --- a/services/scheduler/src/tasks/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Task execution system for The Scheduler.""" diff --git a/services/scheduler/src/tasks/executor.py b/services/scheduler/src/tasks/executor.py deleted file mode 100644 index 8876678..0000000 --- a/services/scheduler/src/tasks/executor.py +++ /dev/null @@ -1,278 +0,0 @@ -""" -Task execution engine for The Scheduler. -Implements minute-based polling with priority-based concurrent execution. -""" -import asyncio -import logging -from datetime import datetime, timezone -from typing import List, Dict, Any, Optional -import psycopg2 -from psycopg2.extras import RealDictCursor -import traceback - -from src.config import Settings - -logger = logging.getLogger(__name__) - -# Priority ranges (for reference) -# 1: Emergency/recovery tasks -# 2: Primary system tasks -# 3: Secondary system tasks -# 5: Urgent user-triggered tasks -# 10: High-priority user tasks -# 15: Reserved -# 20: Backup tasks -# 25: Reserved -# 30: Low-priority user tasks -# 40: Cleanup tasks -# 50: Documentation version checks -# 60: Documentation mirroring -# 70+: Future/experimental tasks - -MAX_CONCURRENT_TASKS = 5 - - -class TaskExecutor: - """Executes scheduled tasks based on priority with concurrency control.""" - - def __init__(self, settings: Settings): - self.settings = settings - self.running_tasks: Dict[int, asyncio.Task] = {} # task_id -> asyncio.Task - self.semaphore = asyncio.Semaphore(MAX_CONCURRENT_TASKS) - - def get_db_connection(self): - """Create database connection.""" - return psycopg2.connect( - host=self.settings.postgres_host, - port=self.settings.postgres_port, - database=self.settings.postgres_db, - user=self.settings.postgres_user, - password=self.settings.postgres_password - ) - - def get_tasks_for_minute(self, now: datetime) -> List[Dict[str, Any]]: - """ - Query database for tasks scheduled for this minute. - Supports wildcards (-1 = any value). - """ - minute = now.minute - hour = now.hour - day = now.day - month = now.month - # Python: Monday=0, Sunday=6; PostgreSQL: Monday=0, Sunday=6 (same) - weekday = now.weekday() - - query = """ - SELECT - id, task_name, service, executor, priority, - config, timeout_seconds, max_retries, retry_count, - last_run, last_status - FROM scheduled_tasks - WHERE enabled = true - AND (minute = -1 OR minute = %s) - AND (hour = -1 OR hour = %s) - AND (day_of_month = -1 OR day_of_month = %s) - AND (month = -1 OR month = %s) - AND (day_of_week = -1 OR day_of_week = %s) - AND id NOT IN ( - SELECT task_id - FROM task_executions - WHERE status = 'running' - ) - ORDER BY priority ASC, task_name ASC - """ - - with self.get_db_connection() as conn: - with conn.cursor(cursor_factory=RealDictCursor) as cur: - cur.execute(query, (minute, hour, day, month, weekday)) - tasks = cur.fetchall() - - logger.info(f"Found {len(tasks)} tasks scheduled for {now.strftime('%Y-%m-%d %H:%M')}") - return [dict(task) for task in tasks] - - def should_run_task(self, task: Dict[str, Any], now: datetime) -> bool: - """ - Determine if task should run based on last execution. - Prevents running the same task multiple times in the same minute. - """ - if not task['last_run']: - return True - - last_run = task['last_run'] - if last_run.tzinfo is None: - last_run = last_run.replace(tzinfo=timezone.utc) - if now.tzinfo is None: - now = now.replace(tzinfo=timezone.utc) - - # Don't run if already executed this minute - if (last_run.year == now.year and - last_run.month == now.month and - last_run.day == now.day and - last_run.hour == now.hour and - last_run.minute == now.minute): - logger.debug(f"Task {task['task_name']} already ran this minute") - return False - - return True - - async def execute_task(self, task: Dict[str, Any]): - """ - Execute a single task with timeout and error handling. - Updates task_executions table with results. - """ - task_id = task['id'] - task_name = task['task_name'] - executor_name = task['executor'] - timeout = task.get('timeout_seconds', 3600) - - execution_id = None - started_at = datetime.now(timezone.utc) - - logger.info(f"[Priority {task['priority']}] Starting task: {task_name} (executor: {executor_name})") - - try: - # Create execution record - with self.get_db_connection() as conn: - with conn.cursor() as cur: - cur.execute(""" - INSERT INTO task_executions - (task_id, task_name, service, executor, priority, - status, triggered_by, started_at) - VALUES (%s, %s, %s, %s, %s, 'running', 'scheduler', %s) - RETURNING id - """, (task_id, task_name, task['service'], executor_name, - task['priority'], started_at)) - execution_id = cur.fetchone()[0] - conn.commit() - - # Load and execute the task - output, error = await self._run_executor(executor_name, task, timeout) - - completed_at = datetime.now(timezone.utc) - duration = int((completed_at - started_at).total_seconds()) - status = 'success' if error is None else 'failed' - - # Update execution record - with self.get_db_connection() as conn: - with conn.cursor() as cur: - cur.execute(""" - UPDATE task_executions - SET status = %s, completed_at = %s, duration_seconds = %s, - output = %s, error = %s - WHERE id = %s - """, (status, completed_at, duration, output, error, execution_id)) - - # Update scheduled_tasks - cur.execute(""" - UPDATE scheduled_tasks - SET last_run = %s, last_status = %s, last_duration_seconds = %s, - retry_count = 0, updated_at = %s - WHERE id = %s - """, (completed_at, status, duration, completed_at, task_id)) - conn.commit() - - if error: - logger.error(f"Task {task_name} failed: {error}") - else: - logger.info(f"Task {task_name} completed successfully in {duration}s") - - except asyncio.TimeoutError: - logger.error(f"Task {task_name} timed out after {timeout}s") - self._update_execution_status(execution_id, 'timeout', - error=f"Task exceeded timeout of {timeout}s") - - except Exception as e: - logger.error(f"Task {task_name} failed with exception: {e}") - logger.error(traceback.format_exc()) - self._update_execution_status(execution_id, 'failed', - error=f"{str(e)}\n{traceback.format_exc()}") - - finally: - # Remove from running tasks - if task_id in self.running_tasks: - del self.running_tasks[task_id] - - async def _run_executor(self, executor_name: str, task: Dict[str, Any], timeout: int) -> tuple[Optional[str], Optional[str]]: - """ - Dynamically load and run the executor module. - Returns (output, error) tuple. - """ - try: - # Import executor dynamically - module_path = f"src.executors.{executor_name}" - module = __import__(module_path, fromlist=['execute']) - - if not hasattr(module, 'execute'): - return None, f"Executor {executor_name} missing execute() function" - - # Run with timeout - config = task.get('config', {}) - result = await asyncio.wait_for( - module.execute(config, self.settings), - timeout=timeout - ) - - return result, None - - except ModuleNotFoundError: - return None, f"Executor module not found: {executor_name}" - except Exception as e: - return None, f"Executor error: {str(e)}\n{traceback.format_exc()}" - - def _update_execution_status(self, execution_id: int, status: str, error: str = None): - """Update execution record with final status.""" - if execution_id is None: - return - - try: - with self.get_db_connection() as conn: - with conn.cursor() as cur: - cur.execute(""" - UPDATE task_executions - SET status = %s, completed_at = %s, error = %s - WHERE id = %s - """, (status, datetime.now(timezone.utc), error, execution_id)) - conn.commit() - except Exception as e: - logger.error(f"Failed to update execution status: {e}") - - async def process_minute(self): - """ - Main entry point: Process all tasks scheduled for the current minute. - Executes up to MAX_CONCURRENT_TASKS in parallel, prioritized by priority field. - """ - now = datetime.now(timezone.utc) - logger.info(f"Processing tasks for {now.strftime('%Y-%m-%d %H:%M')}") - - # Get tasks scheduled for this minute - tasks = self.get_tasks_for_minute(now) - - # Filter out tasks that already ran this minute - tasks_to_run = [task for task in tasks if self.should_run_task(task, now)] - - if not tasks_to_run: - logger.debug("No tasks to run this minute") - return - - logger.info(f"Will execute {len(tasks_to_run)} tasks (max {MAX_CONCURRENT_TASKS} concurrent)") - - # Process tasks in priority order, respecting concurrency limit - for task in tasks_to_run: - # Wait for available slot - await self.semaphore.acquire() - - # Start task - task_coro = self._execute_with_semaphore(task) - asyncio_task = asyncio.create_task(task_coro) - self.running_tasks[task['id']] = asyncio_task - - # Wait for all tasks in this batch to complete - if self.running_tasks: - await asyncio.gather(*self.running_tasks.values(), return_exceptions=True) - - async def _execute_with_semaphore(self, task: Dict[str, Any]): - """Execute task and release semaphore when done.""" - try: - await self.execute_task(task) - finally: - self.semaphore.release() diff --git a/services/scheduler/tests/README.md b/services/scheduler/tests/README.md deleted file mode 100644 index 707c112..0000000 --- a/services/scheduler/tests/README.md +++ /dev/null @@ -1,202 +0,0 @@ -# Scheduler Tests - -Comprehensive test suite for The Scheduler using pytest. - -## Test Structure - -``` -tests/ -├── conftest.py # Shared fixtures and configuration -├── test_api.py # API endpoint tests -├── test_example_executor.py # Example executor tests -├── test_doc_sync_executor.py # Documentation sync executor tests -└── README.md # This file -``` - -## Running Tests - -### Run all tests -```bash -docker exec scheduler pytest -``` - -### Run with coverage report -```bash -docker exec scheduler pytest --cov=src --cov-report=term-missing -``` - -### Run specific test file -```bash -docker exec scheduler pytest tests/test_api.py -``` - -### Run specific test class -```bash -docker exec scheduler pytest tests/test_api.py::TestHealthEndpoint -``` - -### Run specific test -```bash -docker exec scheduler pytest tests/test_api.py::TestHealthEndpoint::test_health_endpoint_returns_healthy -``` - -### Run tests by marker -```bash -# Run only unit tests -docker exec scheduler pytest -m unit - -# Run only API tests -docker exec scheduler pytest -m api - -# Run only executor tests -docker exec scheduler pytest -m executor - -# Run only fast tests (exclude slow) -docker exec scheduler pytest -m "not slow" -``` - -### Run with verbose output -```bash -docker exec scheduler pytest -v -``` - -### Run with detailed failure output -```bash -docker exec scheduler pytest -vv --tb=long -``` - -### Stop on first failure -```bash -docker exec scheduler pytest -x -``` - -## Test Markers - -Tests are organized with pytest markers: - -- `@pytest.mark.unit` - Fast unit tests with no external dependencies -- `@pytest.mark.integration` - Integration tests (may use database) -- `@pytest.mark.api` - API endpoint tests -- `@pytest.mark.executor` - Task executor tests -- `@pytest.mark.slow` - Tests that take a while to run - -## Coverage Reports - -After running tests with coverage: - -- **Terminal**: Shows missing lines in terminal output -- **HTML**: Open `htmlcov/index.html` in browser for detailed report -- **JSON**: Machine-readable coverage data in `coverage.json` - -View HTML coverage report: -```bash -# From host machine -open /home/jpmschweitzer/docker-data/scheduler/htmlcov/index.html -``` - -## Writing New Tests - -### Test File Naming -- Test files must start with `test_` -- Place in `tests/` directory -- Use descriptive names: `test_.py` - -### Test Function Naming -- Test functions must start with `test_` -- Use descriptive names: `test_` - -### Using Fixtures -Fixtures are defined in `conftest.py`: - -```python -def test_something(test_settings, auth_headers): - # Use fixtures as function parameters - assert test_settings.app_name == "Test Scheduler" -``` - -### Adding Markers -```python -@pytest.mark.unit -@pytest.mark.api -def test_health_endpoint(client): - response = client.get("/health") - assert response.status_code == 200 -``` - -### Async Tests -```python -@pytest.mark.asyncio -async def test_async_function(): - result = await some_async_function() - assert result is not None -``` - -### Mocking -```python -from unittest.mock import patch, MagicMock - -def test_with_mock(): - with patch('module.function') as mock_func: - mock_func.return_value = "mocked" - result = call_function_that_uses_it() - assert result == "mocked" -``` - -## Continuous Integration - -These tests are designed to run in CI pipelines: - -```yaml -# Example GitHub Actions -- name: Run tests - run: | - docker exec scheduler pytest --cov=src --cov-report=xml - docker exec scheduler pytest --cov=src --cov-report=html -``` - -## Test Database - -Tests use mocked database connections by default. For integration tests that require a real database: - -1. Set up a test database -2. Use `POSTGRES_DB=test_scheduler` environment variable -3. Run migrations before tests -4. Clean up after tests - -## Troubleshooting - -### Tests fail with "ModuleNotFoundError" -```bash -# Install test dependencies -docker exec scheduler /venv/bin/pip install -r requirements.txt -``` - -### Tests fail with database errors -- Tests use mocked connections by default -- Check that mocks are properly set up in conftest.py -- For integration tests, ensure test database exists - -### Coverage not working -```bash -# Reinstall pytest-cov -docker exec scheduler /venv/bin/pip install --upgrade pytest-cov -``` - -## Best Practices - -1. **Fast by default**: Unit tests should be fast (<1s each) -2. **Isolated**: Tests should not depend on each other -3. **Descriptive**: Test names should clearly describe what they test -4. **Single assertion**: Test one thing per test when possible -5. **Use fixtures**: Reuse common setup via fixtures -6. **Mock external deps**: Don't hit real databases/APIs in unit tests -7. **Mark appropriately**: Use markers to categorize tests - -## Current Coverage - -Run this to see current coverage: -```bash -docker exec scheduler pytest --cov=src --cov-report=term -``` - -Target: 80%+ code coverage for critical paths diff --git a/services/scheduler/tests/conftest.py b/services/scheduler/tests/conftest.py deleted file mode 100644 index acb8ec4..0000000 --- a/services/scheduler/tests/conftest.py +++ /dev/null @@ -1,223 +0,0 @@ -""" -Shared pytest fixtures for The Scheduler tests. -""" -import asyncio -import os -from typing import Generator, AsyncGenerator -from unittest.mock import Mock, MagicMock - -import pytest -from fastapi.testclient import TestClient -from httpx import AsyncClient, ASGITransport - -# Set test environment variables before importing app -os.environ["POSTGRES_HOST"] = "postgres-shared" # Use real postgres for integration tests -os.environ["POSTGRES_DB"] = "test_scheduler" -os.environ["POSTGRES_USER"] = "test_scheduler_user" -os.environ["POSTGRES_PASSWORD"] = "test_password_12345" -os.environ["SCHEDULER_API_KEY"] = "test-api-key-12345" -os.environ["GITEA_USER"] = "test-librarian" -os.environ["GITEA_PASSWORD"] = "test-gitea-token" -os.environ["REDIS_HOST"] = "redis-shared" - -from src.main import app -from src.config import Settings, get_settings - - -# Override settings for tests -@pytest.fixture -def test_settings() -> Settings: - """Provide test settings loaded from environment variables.""" - # Settings are already loaded from environment (set at module level) - return get_settings() - - -@pytest.fixture -def api_key() -> str: - """Test API key for authenticated requests.""" - return "test-api-key-12345" - - -@pytest.fixture -def auth_headers(api_key: str) -> dict: - """Authentication headers for test requests.""" - return {"Authorization": f"Bearer {api_key}"} - - -# Synchronous test client -@pytest.fixture -def client() -> Generator[TestClient, None, None]: - """ - FastAPI test client for synchronous tests. - Note: Some endpoints may fail if they require real database connections. - """ - with TestClient(app) as c: - yield c - - -# Async test client -@pytest.fixture -async def async_client() -> AsyncGenerator[AsyncClient, None]: - """ - Async HTTP client for testing async endpoints. - Note: Some endpoints may fail if they require real database connections. - """ - async with AsyncClient( - transport=ASGITransport(app=app), - base_url="http://test" - ) as ac: - yield ac - - -# Mock database connection -@pytest.fixture -def mock_db_connection(): - """Mock database connection for testing without real database.""" - mock_conn = MagicMock() - mock_cursor = MagicMock() - - # Setup context managers - mock_conn.__enter__ = Mock(return_value=mock_conn) - mock_conn.__exit__ = Mock(return_value=None) - mock_conn.cursor.return_value.__enter__ = Mock(return_value=mock_cursor) - mock_conn.cursor.return_value.__exit__ = Mock(return_value=None) - - return mock_conn, mock_cursor - - -# Mock scheduler -@pytest.fixture -def mock_scheduler(): - """Mock APScheduler for testing without real scheduler.""" - mock = MagicMock() - mock.running = True - mock.get_jobs.return_value = [] - return mock - - -# Mock task executor -@pytest.fixture -def mock_task_executor(): - """Mock TaskExecutor for testing without real executor.""" - mock = MagicMock() - return mock - - -# Sample task data -@pytest.fixture -def sample_task_data() -> dict: - """Sample task data for testing.""" - return { - "id": 1, - "task_name": "test_task", - "service": "scheduler", - "executor": "example_executor", - "priority": 50, - "minute": 0, - "hour": 4, - "day_of_month": -1, - "month": -1, - "day_of_week": -1, - "enabled": True, - "description": "Test task for unit tests", - "config": { - "message": "Test message", - "delay_seconds": 1 - }, - "max_retries": 3, - "timeout_seconds": 60, - "retry_count": 0, - "last_run": None, - "last_status": None, - "last_duration_seconds": None, - "created_at": "2025-12-07T00:00:00", - "updated_at": "2025-12-07T00:00:00", - "created_by": "test" - } - - -# Sample doc sync config -@pytest.fixture -def sample_doc_sync_config() -> dict: - """Sample doc sync configuration for testing.""" - return { - "project": "test-project", - "upstream_repo": "https://github.com/test/repo.git", - "docs_paths": ["/docs"], - "gitea_repo": "library/test-docs", - "branch": "main" - } - - -# Temporary directory for file operations -@pytest.fixture -def temp_work_dir(tmp_path): - """Temporary directory for testing file operations.""" - work_dir = tmp_path / "test-work" - work_dir.mkdir() - return work_dir - - -# Database fixtures for integration tests -@pytest.fixture(scope="function") -def db_connection(): - """Provide a database connection for integration tests.""" - import psycopg2 - - conn = psycopg2.connect( - host="postgres-shared", - database="test_scheduler", - user="test_scheduler_user", - password="test_password_12345" - ) - yield conn - - # Cleanup: rollback any uncommitted changes - conn.rollback() - conn.close() - - -@pytest.fixture(scope="function") -def clean_database(db_connection): - """Clean test database before each test.""" - cursor = db_connection.cursor() - - # Delete all test data - cursor.execute("DELETE FROM task_executions") - cursor.execute("DELETE FROM scheduled_tasks") - db_connection.commit() - - yield db_connection - - # Cleanup after test - cursor.execute("DELETE FROM task_executions") - cursor.execute("DELETE FROM scheduled_tasks") - db_connection.commit() - cursor.close() - - -@pytest.fixture -def sample_task_in_db(clean_database): - """Insert a sample task into the test database.""" - cursor = clean_database.cursor() - - cursor.execute(""" - INSERT INTO scheduled_tasks - (task_name, service, executor, priority, minute, hour, - day_of_month, month, day_of_week, enabled, description, - config, max_retries, timeout_seconds, created_by) - VALUES - (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, %s, %s, %s) - RETURNING id - """, ( - 'test_task', 'scheduler', 'example_executor', 50, - -1, -1, -1, -1, -1, True, 'Test task', - '{"message": "Test", "delay_seconds": 0}', - 3, 60, 'test' - )) - - task_id = cursor.fetchone()[0] - clean_database.commit() - cursor.close() - - return task_id diff --git a/services/scheduler/tests/test_api.py b/services/scheduler/tests/test_api.py deleted file mode 100644 index 4baf2c2..0000000 --- a/services/scheduler/tests/test_api.py +++ /dev/null @@ -1,236 +0,0 @@ -""" -Tests for The Scheduler API endpoints. -""" -import pytest -from fastapi.testclient import TestClient -from unittest.mock import patch, MagicMock -import json - - -@pytest.mark.api -@pytest.mark.unit -class TestHealthEndpoint: - """Tests for /health endpoint.""" - - def test_health_endpoint_returns_healthy(self, client: TestClient): - """Test that health endpoint returns healthy status.""" - with patch('src.main.get_scheduler') as mock_get_scheduler: - mock_scheduler = MagicMock() - mock_scheduler.running = True - mock_scheduler.get_jobs.return_value = [MagicMock()] - mock_get_scheduler.return_value = mock_scheduler - - response = client.get("/health") - - assert response.status_code == 200 - data = response.json() - assert data["status"] == "healthy" - assert data["scheduler_running"] is True - assert data["jobs_count"] == 1 - - def test_health_endpoint_no_auth_required(self, client: TestClient): - """Test that health endpoint doesn't require authentication.""" - with patch('src.main.get_scheduler') as mock_get_scheduler: - mock_scheduler = MagicMock() - mock_scheduler.running = True - mock_get_scheduler.return_value = mock_scheduler - - response = client.get("/health") - assert response.status_code == 200 - - -@pytest.mark.api -@pytest.mark.unit -class TestAuthenticationEndpoints: - """Tests for API authentication.""" - - def test_missing_api_key_returns_401(self, client: TestClient): - """Test that missing API key returns 401.""" - response = client.get("/tasks") - assert response.status_code == 401 - - def test_invalid_api_key_returns_403(self, client: TestClient): - """Test that invalid API key returns 403.""" - response = client.get( - "/tasks", - headers={"Authorization": "Bearer wrong-key"} - ) - assert response.status_code == 403 - - def test_valid_api_key_allows_access(self, client: TestClient, auth_headers: dict): - """Test that valid API key allows access.""" - with patch('src.main.get_task_executor') as mock_executor: - mock_executor.return_value.get_db_connection.return_value.__enter__.return_value.cursor.return_value.__enter__.return_value.fetchall.return_value = [] - - response = client.get("/tasks", headers=auth_headers) - # May fail with 500 due to DB, but should not be 401/403 - assert response.status_code not in [401, 403] - - -@pytest.mark.api -@pytest.mark.unit -class TestTaskEndpoints: - """Tests for task management endpoints.""" - - def test_create_task_missing_fields_returns_400(self, client: TestClient, auth_headers: dict): - """Test that creating task without required fields returns 400.""" - incomplete_task = { - "task_name": "test", - # Missing service, executor, priority - } - response = client.post( - "/tasks", - headers=auth_headers, - json=incomplete_task - ) - assert response.status_code == 400 - - def test_create_task_with_valid_data(self, client: TestClient, auth_headers: dict, sample_task_data: dict): - """Test creating a task with valid data.""" - with patch('src.main.get_task_executor') as mock_executor: - mock_conn = MagicMock() - mock_cursor = MagicMock() - - # Setup mock to return task data - mock_cursor.fetchone.return_value = {**sample_task_data, "id": 1} - - mock_conn.cursor.return_value.__enter__ = MagicMock(return_value=mock_cursor) - mock_conn.cursor.return_value.__exit__ = MagicMock(return_value=None) - mock_executor.return_value.get_db_connection.return_value.__enter__ = MagicMock(return_value=mock_conn) - mock_executor.return_value.get_db_connection.return_value.__exit__ = MagicMock(return_value=None) - - response = client.post( - "/tasks", - headers=auth_headers, - json=sample_task_data - ) - - # Verify the call was made - assert mock_cursor.execute.called - # Check that config was JSON-encoded - call_args = mock_cursor.execute.call_args - assert 'config' in call_args[0][1] - - def test_trigger_task_endpoint(self, client: TestClient, auth_headers: dict): - """Test manually triggering a task.""" - with patch('src.main.get_task_executor') as mock_executor: - mock_conn = MagicMock() - mock_cursor = MagicMock() - - # Mock task retrieval - mock_cursor.fetchone.return_value = { - "task_name": "test_task", - "enabled": True, - "priority": 50, - "executor": "example_executor" - } - - mock_conn.cursor.return_value.__enter__ = MagicMock(return_value=mock_cursor) - mock_conn.cursor.return_value.__exit__ = MagicMock(return_value=None) - mock_executor.return_value.get_db_connection.return_value.__enter__ = MagicMock(return_value=mock_conn) - mock_executor.return_value.get_db_connection.return_value.__exit__ = MagicMock(return_value=None) - - with patch('asyncio.create_task'): - response = client.post( - "/tasks/test_task/trigger", - headers=auth_headers - ) - - # Should return success message - if response.status_code == 200: - data = response.json() - assert data["task_name"] == "test_task" - - -@pytest.mark.api -@pytest.mark.unit -class TestStatsEndpoint: - """Tests for /stats endpoint.""" - - def test_stats_endpoint_requires_auth(self, client: TestClient): - """Test that stats endpoint requires authentication.""" - response = client.get("/stats") - assert response.status_code == 401 - - def test_stats_endpoint_returns_metrics(self, client: TestClient, auth_headers: dict): - """Test that stats endpoint returns system metrics.""" - with patch('src.main.get_scheduler') as mock_scheduler, \ - patch('src.main.get_task_executor') as mock_executor: - - mock_scheduler.return_value.running = True - - mock_conn = MagicMock() - mock_cursor = MagicMock() - mock_cursor.fetchone.side_effect = [ - {"count": 3}, # enabled tasks - {"count": 0}, # running tasks - ] - mock_cursor.fetchall.return_value = [ - {"status": "success", "count": 10}, - {"status": "failed", "count": 1} - ] - - mock_conn.cursor.return_value.__enter__ = MagicMock(return_value=mock_cursor) - mock_conn.cursor.return_value.__exit__ = MagicMock(return_value=None) - mock_executor.return_value.get_db_connection.return_value.__enter__ = MagicMock(return_value=mock_conn) - mock_executor.return_value.get_db_connection.return_value.__exit__ = MagicMock(return_value=None) - - response = client.get("/stats", headers=auth_headers) - - if response.status_code == 200: - data = response.json() - assert "scheduler_running" in data - assert "tasks_enabled" in data - assert "concurrent_limit" in data - - -@pytest.mark.api -@pytest.mark.unit -class TestExecutionHistoryEndpoint: - """Tests for /executions endpoint.""" - - def test_executions_endpoint_returns_history(self, client: TestClient, auth_headers: dict): - """Test that executions endpoint returns execution history.""" - with patch('src.main.get_task_executor') as mock_executor: - mock_conn = MagicMock() - mock_cursor = MagicMock() - mock_cursor.fetchall.return_value = [ - { - "id": 1, - "task_name": "test_task", - "status": "success", - "duration_seconds": 5 - } - ] - - mock_conn.cursor.return_value.__enter__ = MagicMock(return_value=mock_cursor) - mock_conn.cursor.return_value.__exit__ = MagicMock(return_value=None) - mock_executor.return_value.get_db_connection.return_value.__enter__ = MagicMock(return_value=mock_conn) - mock_executor.return_value.get_db_connection.return_value.__exit__ = MagicMock(return_value=None) - - response = client.get("/executions", headers=auth_headers) - - if response.status_code == 200: - data = response.json() - assert "executions" in data - assert "count" in data - - def test_executions_filter_by_task_name(self, client: TestClient, auth_headers: dict): - """Test filtering executions by task name.""" - with patch('src.main.get_task_executor') as mock_executor: - mock_conn = MagicMock() - mock_cursor = MagicMock() - mock_cursor.fetchall.return_value = [] - - mock_conn.cursor.return_value.__enter__ = MagicMock(return_value=mock_cursor) - mock_conn.cursor.return_value.__exit__ = MagicMock(return_value=None) - mock_executor.return_value.get_db_connection.return_value.__enter__ = MagicMock(return_value=mock_conn) - mock_executor.return_value.get_db_connection.return_value.__exit__ = MagicMock(return_value=None) - - response = client.get( - "/executions?task_name=test_task&limit=10", - headers=auth_headers - ) - - # Should execute query with filters - assert mock_cursor.execute.called diff --git a/services/scheduler/tests/test_api_comprehensive.py b/services/scheduler/tests/test_api_comprehensive.py deleted file mode 100644 index 7d64bc8..0000000 --- a/services/scheduler/tests/test_api_comprehensive.py +++ /dev/null @@ -1,311 +0,0 @@ -""" -Comprehensive API tests to improve coverage of main.py. -""" -import pytest -from fastapi.testclient import TestClient -from unittest.mock import patch, MagicMock -import json - - -@pytest.mark.api -@pytest.mark.unit -class TestTaskCRUDOperations: - """Comprehensive CRUD tests for task endpoints.""" - - def test_list_tasks_empty(self, client: TestClient, auth_headers: dict): - """Test listing tasks when none exist.""" - with patch('src.main.get_task_executor') as mock_executor: - mock_conn = MagicMock() - mock_cursor = MagicMock() - mock_cursor.fetchall.return_value = [] - - mock_conn.cursor.return_value.__enter__ = MagicMock(return_value=mock_cursor) - mock_conn.cursor.return_value.__exit__ = MagicMock(return_value=None) - mock_executor.return_value.get_db_connection.return_value.__enter__ = MagicMock(return_value=mock_conn) - mock_executor.return_value.get_db_connection.return_value.__exit__ = MagicMock(return_value=None) - - response = client.get("/tasks", headers=auth_headers) - - if response.status_code == 200: - data = response.json() - assert "tasks" in data - assert data["count"] == 0 - - def test_list_tasks_with_filters(self, client: TestClient, auth_headers: dict): - """Test listing tasks with enabled and service filters.""" - with patch('src.main.get_task_executor') as mock_executor: - mock_conn = MagicMock() - mock_cursor = MagicMock() - mock_cursor.fetchall.return_value = [] - - mock_conn.cursor.return_value.__enter__ = MagicMock(return_value=mock_cursor) - mock_conn.cursor.return_value.__exit__ = MagicMock(return_value=None) - mock_executor.return_value.get_db_connection.return_value.__enter__ = MagicMock(return_value=mock_conn) - mock_executor.return_value.get_db_connection.return_value.__exit__ = MagicMock(return_value=None) - - response = client.get( - "/tasks?enabled=true&service=scheduler", - headers=auth_headers - ) - - # Should execute filtered query - assert mock_cursor.execute.called - call_args = str(mock_cursor.execute.call_args) - assert "enabled" in call_args.lower() or response.status_code in [200, 500] - - def test_get_task_details_not_found(self, client: TestClient, auth_headers: dict): - """Test getting details for non-existent task.""" - with patch('src.main.get_task_executor') as mock_executor: - mock_conn = MagicMock() - mock_cursor = MagicMock() - mock_cursor.fetchone.return_value = None - - mock_conn.cursor.return_value.__enter__ = MagicMock(return_value=mock_cursor) - mock_conn.cursor.return_value.__exit__ = MagicMock(return_value=None) - mock_executor.return_value.get_db_connection.return_value.__enter__ = MagicMock(return_value=mock_conn) - mock_executor.return_value.get_db_connection.return_value.__exit__ = MagicMock(return_value=None) - - response = client.get("/tasks/nonexistent", headers=auth_headers) - - assert response.status_code == 404 - - def test_update_task(self, client: TestClient, auth_headers: dict): - """Test updating a task.""" - with patch('src.main.get_task_executor') as mock_executor: - mock_conn = MagicMock() - mock_cursor = MagicMock() - mock_cursor.fetchone.return_value = { - "task_name": "test", - "priority": 60 - } - - mock_conn.cursor.return_value.__enter__ = MagicMock(return_value=mock_cursor) - mock_conn.cursor.return_value.__exit__ = MagicMock(return_value=None) - mock_executor.return_value.get_db_connection.return_value.__enter__ = MagicMock(return_value=mock_conn) - mock_executor.return_value.get_db_connection.return_value.__exit__ = MagicMock(return_value=None) - - response = client.put( - "/tasks/test", - headers=auth_headers, - json={"priority": 60} - ) - - # Should have attempted update - assert mock_cursor.execute.called - - def test_update_task_not_found(self, client: TestClient, auth_headers: dict): - """Test updating non-existent task.""" - with patch('src.main.get_task_executor') as mock_executor: - mock_conn = MagicMock() - mock_cursor = MagicMock() - mock_cursor.fetchone.return_value = None - - mock_conn.cursor.return_value.__enter__ = MagicMock(return_value=mock_cursor) - mock_conn.cursor.return_value.__exit__ = MagicMock(return_value=None) - mock_executor.return_value.get_db_connection.return_value.__enter__ = MagicMock(return_value=mock_conn) - mock_executor.return_value.get_db_connection.return_value.__exit__ = MagicMock(return_value=None) - - response = client.put( - "/tasks/nonexistent", - headers=auth_headers, - json={"priority": 60} - ) - - assert response.status_code == 404 - - def test_update_task_no_fields(self, client: TestClient, auth_headers: dict): - """Test updating task with no valid fields.""" - response = client.put( - "/tasks/test", - headers=auth_headers, - json={"invalid_field": "value"} - ) - - assert response.status_code == 400 - - def test_delete_task(self, client: TestClient, auth_headers: dict): - """Test deleting a task.""" - with patch('src.main.get_task_executor') as mock_executor: - mock_conn = MagicMock() - mock_cursor = MagicMock() - mock_cursor.fetchone.return_value = ("test_task",) - - mock_conn.cursor.return_value.__enter__ = MagicMock(return_value=mock_cursor) - mock_conn.cursor.return_value.__exit__ = MagicMock(return_value=None) - mock_executor.return_value.get_db_connection.return_value.__enter__ = MagicMock(return_value=mock_conn) - mock_executor.return_value.get_db_connection.return_value.__exit__ = MagicMock(return_value=None) - - response = client.delete("/tasks/test_task", headers=auth_headers) - - if response.status_code == 200: - data = response.json() - assert "deleted successfully" in data["message"].lower() - - def test_delete_task_not_found(self, client: TestClient, auth_headers: dict): - """Test deleting non-existent task.""" - with patch('src.main.get_task_executor') as mock_executor: - mock_conn = MagicMock() - mock_cursor = MagicMock() - mock_cursor.fetchone.return_value = None - - mock_conn.cursor.return_value.__enter__ = MagicMock(return_value=mock_cursor) - mock_conn.cursor.return_value.__exit__ = MagicMock(return_value=None) - mock_executor.return_value.get_db_connection.return_value.__enter__ = MagicMock(return_value=mock_conn) - mock_executor.return_value.get_db_connection.return_value.__exit__ = MagicMock(return_value=None) - - response = client.delete("/tasks/nonexistent", headers=auth_headers) - - assert response.status_code == 404 - - -@pytest.mark.api -@pytest.mark.unit -class TestTriggerEndpoint: - """Tests for task trigger endpoint.""" - - def test_trigger_disabled_task(self, client: TestClient, auth_headers: dict): - """Test triggering a disabled task.""" - with patch('src.main.get_task_executor') as mock_executor: - mock_conn = MagicMock() - mock_cursor = MagicMock() - mock_cursor.fetchone.return_value = { - "task_name": "test", - "enabled": False - } - - mock_conn.cursor.return_value.__enter__ = MagicMock(return_value=mock_cursor) - mock_conn.cursor.return_value.__exit__ = MagicMock(return_value=None) - mock_executor.return_value.get_db_connection.return_value.__enter__ = MagicMock(return_value=mock_conn) - mock_executor.return_value.get_db_connection.return_value.__exit__ = MagicMock(return_value=None) - - response = client.post("/tasks/test/trigger", headers=auth_headers) - - assert response.status_code == 400 - - def test_trigger_nonexistent_task(self, client: TestClient, auth_headers: dict): - """Test triggering a task that doesn't exist.""" - with patch('src.main.get_task_executor') as mock_executor: - mock_conn = MagicMock() - mock_cursor = MagicMock() - mock_cursor.fetchone.return_value = None - - mock_conn.cursor.return_value.__enter__ = MagicMock(return_value=mock_cursor) - mock_conn.cursor.return_value.__exit__ = MagicMock(return_value=None) - mock_executor.return_value.get_db_connection.return_value.__enter__ = MagicMock(return_value=mock_conn) - mock_executor.return_value.get_db_connection.return_value.__exit__ = MagicMock(return_value=None) - - response = client.post("/tasks/nonexistent/trigger", headers=auth_headers) - - assert response.status_code == 404 - - -@pytest.mark.api -@pytest.mark.unit -class TestLegacyEndpoints: - """Tests for deprecated/legacy endpoints.""" - - def test_trigger_backup_endpoint(self, client: TestClient, auth_headers: dict): - """Test legacy backup trigger endpoint.""" - response = client.post("/tasks/backup", headers=auth_headers) - - # Should return not implemented status - if response.status_code == 200: - data = response.json() - assert data["status"] == "not_implemented" - - def test_trigger_docs_update_endpoint(self, client: TestClient, auth_headers: dict): - """Test legacy docs update endpoint.""" - response = client.post("/tasks/docs/update", headers=auth_headers) - - if response.status_code == 200: - data = response.json() - assert data["status"] == "not_implemented" - - def test_trigger_docs_update_with_project(self, client: TestClient, auth_headers: dict): - """Test legacy docs update with project filter.""" - response = client.post( - "/tasks/docs/update?project=test", - headers=auth_headers - ) - - if response.status_code == 200: - data = response.json() - assert "test" in data["message"].lower() - - def test_check_doc_versions_endpoint(self, client: TestClient, auth_headers: dict): - """Test legacy version check endpoint.""" - response = client.post("/tasks/docs/check-versions", headers=auth_headers) - - if response.status_code == 200: - data = response.json() - assert data["status"] == "not_implemented" - - def test_trigger_cleanup_endpoint(self, client: TestClient, auth_headers: dict): - """Test legacy cleanup endpoint.""" - response = client.post("/tasks/cleanup", headers=auth_headers) - - if response.status_code == 200: - data = response.json() - assert data["status"] == "not_implemented" - - -@pytest.mark.api -@pytest.mark.unit -class TestExecutionFiltering: - """Tests for execution history filtering.""" - - def test_filter_by_service(self, client: TestClient, auth_headers: dict): - """Test filtering executions by service.""" - with patch('src.main.get_task_executor') as mock_executor: - mock_conn = MagicMock() - mock_cursor = MagicMock() - mock_cursor.fetchall.return_value = [] - - mock_conn.cursor.return_value.__enter__ = MagicMock(return_value=mock_cursor) - mock_conn.cursor.return_value.__exit__ = MagicMock(return_value=None) - mock_executor.return_value.get_db_connection.return_value.__enter__ = MagicMock(return_value=mock_conn) - mock_executor.return_value.get_db_connection.return_value.__exit__ = MagicMock(return_value=None) - - response = client.get( - "/executions?service=scheduler", - headers=auth_headers - ) - - assert mock_cursor.execute.called - - def test_filter_by_status(self, client: TestClient, auth_headers: dict): - """Test filtering executions by status.""" - with patch('src.main.get_task_executor') as mock_executor: - mock_conn = MagicMock() - mock_cursor = MagicMock() - mock_cursor.fetchall.return_value = [] - - mock_conn.cursor.return_value.__enter__ = MagicMock(return_value=mock_cursor) - mock_conn.cursor.return_value.__exit__ = MagicMock(return_value=None) - mock_executor.return_value.get_db_connection.return_value.__enter__ = MagicMock(return_value=mock_conn) - mock_executor.return_value.get_db_connection.return_value.__exit__ = MagicMock(return_value=None) - - response = client.get( - "/executions?status=success", - headers=auth_headers - ) - - assert mock_cursor.execute.called - - def test_custom_limit(self, client: TestClient, auth_headers: dict): - """Test custom limit for executions.""" - with patch('src.main.get_task_executor') as mock_executor: - mock_conn = MagicMock() - mock_cursor = MagicMock() - mock_cursor.fetchall.return_value = [] - - mock_conn.cursor.return_value.__enter__ = MagicMock(return_value=mock_cursor) - mock_conn.cursor.return_value.__exit__ = MagicMock(return_value=None) - mock_executor.return_value.get_db_connection.return_value.__enter__ = MagicMock(return_value=mock_conn) - mock_executor.return_value.get_db_connection.return_value.__exit__ = MagicMock(return_value=None) - - response = client.get("/executions?limit=50", headers=auth_headers) - - if response.status_code == 200: - data = response.json() - assert data["limit"] == 50 diff --git a/services/scheduler/tests/test_config_backup_executor.py b/services/scheduler/tests/test_config_backup_executor.py deleted file mode 100644 index 88c1bd0..0000000 --- a/services/scheduler/tests/test_config_backup_executor.py +++ /dev/null @@ -1,214 +0,0 @@ -""" -Tests for the config backup executor. -""" -import pytest -from pathlib import Path -from unittest.mock import AsyncMock, patch, MagicMock, mock_open -from src.executors import config_backup_executor -from src.config import Settings - - -@pytest.mark.executor -@pytest.mark.unit -class TestConfigBackupExecutor: - """Tests for config_backup_executor module.""" - - @pytest.mark.asyncio - async def test_executor_requires_config_fields(self, test_settings: Settings): - """Test that executor validates required config.""" - incomplete_config = { - "sources": [] - # Missing backup_dir - } - - with pytest.raises((ValueError, KeyError)): - await config_backup_executor.execute(incomplete_config, test_settings) - - @pytest.mark.asyncio - async def test_executor_with_minimal_config(self, test_settings: Settings, tmp_path: Path): - """Test executor with minimal valid configuration.""" - backup_dir = tmp_path / "backups" - backup_dir.mkdir() - - source_dir = tmp_path / "source" - source_dir.mkdir() - (source_dir / "test.txt").write_text("test content") - - config = { - "sources": [{ - "path": str(source_dir), - "name": "test-source", - "excludes": [] - }], - "backup_dir": str(backup_dir), - "compress": True, - "retention_days": 30 - } - - with patch('src.executors.config_backup_executor.Path') as mock_path_cls: - # Setup path mocking - mock_source = MagicMock() - mock_source.exists.return_value = True - mock_source.is_dir.return_value = True - mock_source.iterdir.return_value = [MagicMock(name="test.txt")] - - mock_backup = MagicMock() - mock_backup.mkdir = MagicMock() - - def path_side_effect(p): - if str(p) == str(source_dir): - return mock_source - elif str(p) == str(backup_dir): - return mock_backup - return MagicMock() - - mock_path_cls.side_effect = path_side_effect - - with patch('tarfile.open'), \ - patch('src.executors.config_backup_executor._cleanup_old_backups'): - - result = await config_backup_executor.execute(config, test_settings) - - assert "backed up" in result.lower() or "success" in result.lower() - - @pytest.mark.asyncio - async def test_executor_excludes_patterns(self, test_settings: Settings, tmp_path: Path): - """Test that executor respects exclude patterns.""" - backup_dir = tmp_path / "backups" - source_dir = tmp_path / "source" - - config = { - "sources": [{ - "path": str(source_dir), - "name": "test", - "excludes": ["*.log", "cache/*"] - }], - "backup_dir": str(backup_dir), - "compress": True - } - - with patch('src.executors.config_backup_executor.Path'), \ - patch('tarfile.open') as mock_tar, \ - patch('src.executors.config_backup_executor._cleanup_old_backups'): - - # Mock tarfile - mock_tar_obj = MagicMock() - mock_tar.return_value.__enter__ = MagicMock(return_value=mock_tar_obj) - mock_tar.return_value.__exit__ = MagicMock(return_value=None) - - try: - await config_backup_executor.execute(config, test_settings) - except Exception: - # May fail due to mocking complexity, but that's ok - pass - - # Should have attempted to create tarfile - # assert mock_tar.called # Would check if it was actually called - - @pytest.mark.asyncio - async def test_executor_handles_missing_source(self, test_settings: Settings, tmp_path: Path): - """Test executor handles missing source directory.""" - backup_dir = tmp_path / "backups" - backup_dir.mkdir() - - config = { - "sources": [{ - "path": "/nonexistent/path", - "name": "missing", - "excludes": [] - }], - "backup_dir": str(backup_dir), - "compress": True - } - - with patch('src.executors.config_backup_executor.Path') as mock_path_cls: - mock_source = MagicMock() - mock_source.exists.return_value = False - - mock_path_cls.return_value = mock_source - - result = await config_backup_executor.execute(config, test_settings) - - # Should skip non-existent sources - assert "skipped" in result.lower() or "not found" in result.lower() or "0" in result - - @pytest.mark.asyncio - async def test_cleanup_old_backups(self, tmp_path: Path): - """Test cleanup of old backup files.""" - backup_dir = tmp_path / "backups" - backup_dir.mkdir() - - # Create some "old" backup files - old_backup = backup_dir / "backup-2020-01-01.tar.gz" - old_backup.write_text("old") - - recent_backup = backup_dir / "backup-2025-12-01.tar.gz" - recent_backup.write_text("recent") - - with patch('src.executors.config_backup_executor.Path') as mock_path_cls: - mock_backup_dir = MagicMock() - mock_old_file = MagicMock() - mock_old_file.name = "backup-2020-01-01.tar.gz" - mock_old_file.stat.return_value.st_mtime = 0 # Very old - - mock_recent_file = MagicMock() - mock_recent_file.name = "backup-2025-12-01.tar.gz" - mock_recent_file.stat.return_value.st_mtime = 999999999999 # Recent - - mock_backup_dir.glob.return_value = [mock_old_file, mock_recent_file] - mock_path_cls.return_value = mock_backup_dir - - config_backup_executor._cleanup_old_backups(mock_backup_dir, retention_days=7) - - # Old file should be removed - mock_old_file.unlink.assert_called_once() - - -@pytest.mark.executor -@pytest.mark.unit -class TestConfigBackupHelpers: - """Tests for helper functions.""" - - def test_tar_filter_excludes_cache(self): - """Test that tar filter excludes cache directories.""" - excludes = ["*/cache/*", "*.log"] - filter_func = config_backup_executor._create_tar_filter(excludes) - - # Mock tarinfo for cache file - cache_tarinfo = MagicMock() - cache_tarinfo.name = "data/cache/temp.txt" - - result = filter_func(cache_tarinfo) - assert result is None # Should exclude - - def test_tar_filter_includes_normal_files(self): - """Test that tar filter includes normal files.""" - excludes = ["*/cache/*"] - filter_func = config_backup_executor._create_tar_filter(excludes) - - # Mock tarinfo for normal file - normal_tarinfo = MagicMock() - normal_tarinfo.name = "data/config.json" - - result = filter_func(normal_tarinfo) - assert result == normal_tarinfo # Should include - - def test_tar_filter_with_wildcard_patterns(self): - """Test tar filter with various wildcard patterns.""" - excludes = ["*.log", "*.tmp", "temp/*"] - filter_func = config_backup_executor._create_tar_filter(excludes) - - # Log file - log_tarinfo = MagicMock() - log_tarinfo.name = "app.log" - assert filter_func(log_tarinfo) is None - - # Temp file - tmp_tarinfo = MagicMock() - tmp_tarinfo.name = "cache.tmp" - assert filter_func(tmp_tarinfo) is None - - # Normal file - normal_tarinfo = MagicMock() - normal_tarinfo.name = "config.json" - assert filter_func(normal_tarinfo) == normal_tarinfo diff --git a/services/scheduler/tests/test_database_integration.py b/services/scheduler/tests/test_database_integration.py deleted file mode 100644 index 6eef952..0000000 --- a/services/scheduler/tests/test_database_integration.py +++ /dev/null @@ -1,307 +0,0 @@ -""" -Database integration tests using real test database. -These tests require the test database to be set up. -""" -import pytest -from fastapi.testclient import TestClient - - -@pytest.mark.integration -class TestDatabaseTaskOperations: - """Integration tests for task CRUD with real database.""" - - def test_create_task_in_database(self, client: TestClient, auth_headers: dict, clean_database): - """Test creating a task writes to database.""" - task_data = { - "task_name": "integration_test_task", - "service": "scheduler", - "executor": "example_executor", - "priority": 40, - "description": "Integration test task", - "config": {"message": "Test", "delay_seconds": 0} - } - - response = client.post("/tasks", headers=auth_headers, json=task_data) - - assert response.status_code == 200 - data = response.json() - assert data["task_name"] == "integration_test_task" - assert data["priority"] == 40 - - # Verify it's in database - cursor = clean_database.cursor() - cursor.execute("SELECT task_name, priority FROM scheduled_tasks WHERE task_name = %s", - ("integration_test_task",)) - result = cursor.fetchone() - assert result is not None - assert result[0] == "integration_test_task" - assert result[1] == 40 - - def test_list_tasks_from_database(self, client: TestClient, auth_headers: dict, sample_task_in_db): - """Test listing tasks reads from database.""" - response = client.get("/tasks", headers=auth_headers) - - assert response.status_code == 200 - data = response.json() - assert data["count"] >= 1 - assert any(task["task_name"] == "test_task" for task in data["tasks"]) - - def test_get_task_details_from_database(self, client: TestClient, auth_headers: dict, sample_task_in_db): - """Test getting task details from database.""" - response = client.get("/tasks/test_task", headers=auth_headers) - - assert response.status_code == 200 - data = response.json() - assert data["task_name"] == "test_task" - assert data["executor"] == "example_executor" - assert data["priority"] == 50 - - def test_update_task_in_database(self, client: TestClient, auth_headers: dict, sample_task_in_db, clean_database): - """Test updating a task modifies database.""" - update_data = {"priority": 99, "description": "Updated description"} - - response = client.put("/tasks/test_task", headers=auth_headers, json=update_data) - - assert response.status_code == 200 - data = response.json() - assert data["priority"] == 99 - assert data["description"] == "Updated description" - - # Verify in database - cursor = clean_database.cursor() - cursor.execute("SELECT priority, description FROM scheduled_tasks WHERE task_name = %s", - ("test_task",)) - result = cursor.fetchone() - assert result[0] == 99 - assert result[1] == "Updated description" - - def test_delete_task_from_database(self, client: TestClient, auth_headers: dict, sample_task_in_db, clean_database): - """Test deleting a task removes from database.""" - response = client.delete("/tasks/test_task", headers=auth_headers) - - assert response.status_code == 200 - - # Verify removed from database - cursor = clean_database.cursor() - cursor.execute("SELECT COUNT(*) FROM scheduled_tasks WHERE task_name = %s", - ("test_task",)) - count = cursor.fetchone()[0] - assert count == 0 - - def test_filter_tasks_by_service(self, client: TestClient, auth_headers: dict, clean_database): - """Test filtering tasks by service.""" - # Create tasks with different services - cursor = clean_database.cursor() - cursor.execute(""" - INSERT INTO scheduled_tasks (task_name, service, executor, priority) - VALUES ('task1', 'scheduler', 'example_executor', 50), - ('task2', 'backup', 'backup_executor', 30) - """) - clean_database.commit() - - response = client.get("/tasks?service=scheduler", headers=auth_headers) - - assert response.status_code == 200 - data = response.json() - assert all(task["service"] == "scheduler" for task in data["tasks"]) - - def test_filter_tasks_by_enabled(self, client: TestClient, auth_headers: dict, clean_database): - """Test filtering tasks by enabled status.""" - cursor = clean_database.cursor() - cursor.execute(""" - INSERT INTO scheduled_tasks (task_name, service, executor, priority, enabled) - VALUES ('enabled_task', 'scheduler', 'example_executor', 50, true), - ('disabled_task', 'scheduler', 'example_executor', 50, false) - """) - clean_database.commit() - - response = client.get("/tasks?enabled=true", headers=auth_headers) - - assert response.status_code == 200 - data = response.json() - assert all(task["enabled"] is True for task in data["tasks"]) - - -@pytest.mark.integration -class TestDatabaseTaskExecution: - """Integration tests for task execution with database tracking.""" - - @pytest.mark.asyncio - async def test_execute_task_creates_execution_record(self, sample_task_in_db, clean_database): - """Test that executing a task creates execution record.""" - from src.tasks.executor import TaskExecutor - from src.config import get_settings - - settings = get_settings() - executor = TaskExecutor(settings) - - # Get task from database - cursor = clean_database.cursor() - cursor.execute(""" - SELECT id, task_name, service, executor, priority, minute, hour, - day_of_month, month, day_of_week, enabled, description, - config, max_retries, timeout_seconds, retry_count, - last_run, last_status, last_duration_seconds, created_by - FROM scheduled_tasks WHERE id = %s - """, (sample_task_in_db,)) - - row = cursor.fetchone() - task = { - "id": row[0], - "task_name": row[1], - "service": row[2], - "executor": row[3], - "priority": row[4], - "minute": row[5], - "hour": row[6], - "day_of_month": row[7], - "month": row[8], - "day_of_week": row[9], - "enabled": row[10], - "description": row[11], - "config": row[12], - "max_retries": row[13], - "timeout_seconds": row[14], - "retry_count": row[15], - "last_run": row[16], - "last_status": row[17], - "last_duration_seconds": row[18], - "created_by": row[19] - } - - # Execute task - await executor.execute_task(task) - - # Verify execution record was created - cursor.execute(""" - SELECT status, task_name, executor FROM task_executions - WHERE task_id = %s ORDER BY id DESC LIMIT 1 - """, (sample_task_in_db,)) - - execution = cursor.fetchone() - assert execution is not None - assert execution[0] in ["success", "failed", "timeout"] - assert execution[1] == "test_task" - assert execution[2] == "example_executor" - - -@pytest.mark.integration -class TestDatabaseExecutionHistory: - """Integration tests for execution history endpoints.""" - - def test_get_execution_history(self, client: TestClient, auth_headers: dict, clean_database): - """Test retrieving execution history from database.""" - # Create test execution records - cursor = clean_database.cursor() - - # First create a task - cursor.execute(""" - INSERT INTO scheduled_tasks (task_name, service, executor, priority) - VALUES ('history_task', 'scheduler', 'example_executor', 50) - RETURNING id - """) - task_id = cursor.fetchone()[0] - - # Create execution records - cursor.execute(""" - INSERT INTO task_executions - (task_id, task_name, service, executor, priority, status, duration_seconds) - VALUES - (%s, 'history_task', 'scheduler', 'example_executor', 50, 'success', 5), - (%s, 'history_task', 'scheduler', 'example_executor', 50, 'success', 3), - (%s, 'history_task', 'scheduler', 'example_executor', 50, 'failed', 2) - """, (task_id, task_id, task_id)) - clean_database.commit() - - response = client.get("/executions", headers=auth_headers) - - assert response.status_code == 200 - data = response.json() - assert data["count"] >= 3 - assert any(ex["task_name"] == "history_task" for ex in data["executions"]) - - def test_filter_executions_by_task_name(self, client: TestClient, auth_headers: dict, clean_database): - """Test filtering execution history by task name.""" - cursor = clean_database.cursor() - - # Create tasks - cursor.execute(""" - INSERT INTO scheduled_tasks (task_name, service, executor, priority) - VALUES ('task_a', 'scheduler', 'example_executor', 50), - ('task_b', 'scheduler', 'example_executor', 50) - RETURNING id - """) - task_ids = [row[0] for row in cursor.fetchall()] - - # Create executions - cursor.execute(""" - INSERT INTO task_executions (task_id, task_name, service, executor, priority, status) - VALUES (%s, 'task_a', 'scheduler', 'example_executor', 50, 'success'), - (%s, 'task_b', 'scheduler', 'example_executor', 50, 'success') - """, task_ids) - clean_database.commit() - - response = client.get("/executions?task_name=task_a", headers=auth_headers) - - assert response.status_code == 200 - data = response.json() - assert all(ex["task_name"] == "task_a" for ex in data["executions"]) - - def test_filter_executions_by_status(self, client: TestClient, auth_headers: dict, clean_database): - """Test filtering execution history by status.""" - cursor = clean_database.cursor() - - cursor.execute(""" - INSERT INTO scheduled_tasks (task_name, service, executor, priority) - VALUES ('status_task', 'scheduler', 'example_executor', 50) - RETURNING id - """) - task_id = cursor.fetchone()[0] - - cursor.execute(""" - INSERT INTO task_executions (task_id, task_name, service, executor, priority, status) - VALUES (%s, 'status_task', 'scheduler', 'example_executor', 50, 'success'), - (%s, 'status_task', 'scheduler', 'example_executor', 50, 'failed') - """, (task_id, task_id)) - clean_database.commit() - - response = client.get("/executions?status=success", headers=auth_headers) - - assert response.status_code == 200 - data = response.json() - success_executions = [ex for ex in data["executions"] if ex["task_name"] == "status_task"] - assert all(ex["status"] == "success" for ex in success_executions) - - -@pytest.mark.integration -class TestDatabaseStats: - """Integration tests for stats endpoint with database.""" - - def test_stats_endpoint_with_database(self, client: TestClient, auth_headers: dict, clean_database): - """Test stats endpoint returns accurate database counts.""" - cursor = clean_database.cursor() - - # Create test data - cursor.execute(""" - INSERT INTO scheduled_tasks (task_name, service, executor, priority, enabled) - VALUES ('enabled_1', 'scheduler', 'example_executor', 50, true), - ('enabled_2', 'scheduler', 'example_executor', 50, true), - ('disabled_1', 'scheduler', 'example_executor', 50, false) - RETURNING id - """) - task_ids = [row[0] for row in cursor.fetchall()] - - cursor.execute(""" - INSERT INTO task_executions (task_id, task_name, service, executor, priority, status) - VALUES (%s, 'enabled_1', 'scheduler', 'example_executor', 50, 'success'), - (%s, 'enabled_2', 'scheduler', 'example_executor', 50, 'success'), - (%s, 'enabled_1', 'scheduler', 'example_executor', 50, 'failed') - """, task_ids) - clean_database.commit() - - response = client.get("/stats", headers=auth_headers) - - assert response.status_code == 200 - data = response.json() - assert data["tasks_enabled"] >= 2 - assert "execution_stats_24h" in data diff --git a/services/scheduler/tests/test_doc_sync_executor.py b/services/scheduler/tests/test_doc_sync_executor.py deleted file mode 100644 index cb8ac70..0000000 --- a/services/scheduler/tests/test_doc_sync_executor.py +++ /dev/null @@ -1,240 +0,0 @@ -""" -Tests for the documentation sync executor. -""" -import pytest -from pathlib import Path -from unittest.mock import AsyncMock, patch, MagicMock -from src.executors import doc_sync_executor -from src.config import Settings - - -@pytest.mark.executor -@pytest.mark.unit -class TestDocSyncExecutor: - """Tests for doc_sync_executor module.""" - - @pytest.mark.asyncio - async def test_executor_requires_all_config_fields(self, test_settings: Settings): - """Test that executor validates required config fields.""" - incomplete_config = { - "project": "test", - # Missing other required fields - } - - with pytest.raises(ValueError, match="Missing required config"): - await doc_sync_executor.execute(incomplete_config, test_settings) - - @pytest.mark.asyncio - async def test_executor_validates_project_name(self, test_settings: Settings): - """Test that executor requires project name.""" - config = { - "project": "", - "upstream_repo": "https://github.com/test/repo.git", - "gitea_repo": "library/test" - } - - with pytest.raises(ValueError): - await doc_sync_executor.execute(config, test_settings) - - @pytest.mark.asyncio - async def test_executor_successful_sync_entire_repo( - self, - test_settings: Settings, - sample_doc_sync_config: dict, - temp_work_dir: Path - ): - """Test successful documentation sync of entire repository.""" - # Modify config to sync entire repo - config = {**sample_doc_sync_config, "docs_paths": []} - - with patch('src.executors.doc_sync_executor._run_command', new_callable=AsyncMock) as mock_run, \ - patch('src.executors.doc_sync_executor._get_git_commit', new_callable=AsyncMock) as mock_get_commit, \ - patch('src.executors.doc_sync_executor.shutil') as mock_shutil, \ - patch('src.executors.doc_sync_executor.Path') as mock_path: - - # Mock git commit hash - mock_get_commit.return_value = "abc123def456" - - # Mock path operations - mock_work_dir = MagicMock() - mock_upstream_dir = MagicMock() - mock_gitea_dir = MagicMock() - - # Setup directory mocking - mock_upstream_dir.iterdir.return_value = [ - MagicMock(name=".git", is_dir=lambda: True), - MagicMock(name="README.md", is_dir=lambda: False), - MagicMock(name="docs", is_dir=lambda: True), - ] - mock_gitea_dir.iterdir.return_value = [ - MagicMock(name=".git", is_dir=lambda: True) - ] - - mock_path.return_value = mock_work_dir - mock_work_dir.__truediv__.side_effect = [mock_upstream_dir, mock_gitea_dir] - - # Mock git status to show changes - mock_run.side_effect = [ - "", # git clone upstream - "", # git rev-parse HEAD - "", # git clone gitea - "", # git add - "M README.md\n", # git status --porcelain (has changes) - "", # git commit - "", # git tag - "", # git push branch - "", # git push tag - ] - - result = await doc_sync_executor.execute(config, test_settings) - - assert "Successfully synced" in result - assert "test-project" in result.lower() - assert "entire repository" in result - - @pytest.mark.asyncio - async def test_executor_sync_specific_paths( - self, - test_settings: Settings, - sample_doc_sync_config: dict - ): - """Test syncing only specific paths.""" - config = {**sample_doc_sync_config, "docs_paths": ["/docs", "/examples"]} - - with patch('src.executors.doc_sync_executor._run_command', new_callable=AsyncMock) as mock_run, \ - patch('src.executors.doc_sync_executor._get_git_commit', new_callable=AsyncMock) as mock_get_commit, \ - patch('src.executors.doc_sync_executor.shutil') as mock_shutil, \ - patch('src.executors.doc_sync_executor.Path') as mock_path: - - mock_get_commit.return_value = "abc123" - - # Mock path operations - mock_upstream_dir = MagicMock() - mock_gitea_dir = MagicMock() - mock_docs = MagicMock(name="docs") - mock_docs.exists.return_value = True - mock_docs.is_dir.return_value = True - mock_docs.name = "docs" - - mock_examples = MagicMock(name="examples") - mock_examples.exists.return_value = True - mock_examples.is_dir.return_value = True - mock_examples.name = "examples" - - mock_upstream_dir.__truediv__.side_effect = [mock_docs, mock_examples] - mock_gitea_dir.iterdir.return_value = [] - - # Mock git status to show changes - async def run_command_side_effect(cmd, **kwargs): - if 'status' in cmd: - return "M docs/README.md\n" - return "" - - mock_run.side_effect = run_command_side_effect - - result = await doc_sync_executor.execute(config, test_settings) - - # Should mention the specific paths - assert "docs" in result.lower() or "Successfully synced" in result - - @pytest.mark.asyncio - async def test_executor_handles_no_changes( - self, - test_settings: Settings, - sample_doc_sync_config: dict - ): - """Test that executor handles case where there are no changes.""" - with patch('src.executors.doc_sync_executor._run_command', new_callable=AsyncMock) as mock_run, \ - patch('src.executors.doc_sync_executor._get_git_commit', new_callable=AsyncMock) as mock_get_commit, \ - patch('src.executors.doc_sync_executor.shutil'), \ - patch('src.executors.doc_sync_executor.Path') as mock_path: - - mock_get_commit.return_value = "abc123" - - # Mock empty git status (no changes) - async def run_command_side_effect(cmd, cwd=None, capture=False): - if 'status' in cmd and '--porcelain' in cmd: - return "" # No changes - return "" - - mock_run.side_effect = run_command_side_effect - - # Setup minimal mocking - mock_work_dir = MagicMock() - mock_upstream_dir = MagicMock() - mock_gitea_dir = MagicMock() - mock_gitea_dir.iterdir.return_value = [] - mock_upstream_dir.iterdir.return_value = [] - - result = await doc_sync_executor.execute(sample_doc_sync_config, test_settings) - - assert "already up to date" in result.lower() or "no changes" in result.lower() - - @pytest.mark.asyncio - async def test_executor_cleanup_on_error( - self, - test_settings: Settings, - sample_doc_sync_config: dict - ): - """Test that executor cleans up on error.""" - with patch('src.executors.doc_sync_executor._run_command', new_callable=AsyncMock) as mock_run, \ - patch('src.executors.doc_sync_executor.shutil') as mock_shutil, \ - patch('src.executors.doc_sync_executor.Path') as mock_path: - - # Make git clone fail - mock_run.side_effect = Exception("Git clone failed") - - mock_work_dir = MagicMock() - mock_work_dir.exists.return_value = True - mock_path.return_value = mock_work_dir - - with pytest.raises(Exception, match="Git clone failed"): - await doc_sync_executor.execute(sample_doc_sync_config, test_settings) - - # Should have attempted cleanup - mock_shutil.rmtree.assert_called() - - -@pytest.mark.executor -@pytest.mark.unit -class TestDocSyncHelpers: - """Tests for doc_sync_executor helper functions.""" - - @pytest.mark.asyncio - async def test_run_command_success(self): - """Test that _run_command executes successfully.""" - with patch('asyncio.create_subprocess_exec', new_callable=AsyncMock) as mock_exec: - mock_proc = AsyncMock() - mock_proc.returncode = 0 - mock_proc.communicate.return_value = (b"output", b"") - mock_exec.return_value = mock_proc - - result = await doc_sync_executor._run_command( - ["echo", "test"], - capture=True - ) - - assert result == "output" - - @pytest.mark.asyncio - async def test_run_command_failure(self): - """Test that _run_command raises on failure.""" - with patch('asyncio.create_subprocess_exec', new_callable=AsyncMock) as mock_exec: - mock_proc = AsyncMock() - mock_proc.returncode = 1 - mock_proc.communicate.return_value = (b"", b"error message") - mock_exec.return_value = mock_proc - - with pytest.raises(Exception, match="Command failed"): - await doc_sync_executor._run_command(["false"]) - - @pytest.mark.asyncio - async def test_get_git_commit(self, temp_work_dir: Path): - """Test getting git commit hash.""" - with patch('src.executors.doc_sync_executor._run_command', new_callable=AsyncMock) as mock_run: - mock_run.return_value = "abc123def456\n" - - commit = await doc_sync_executor._get_git_commit(temp_work_dir) - - assert commit == "abc123def456" - mock_run.assert_called_once() diff --git a/services/scheduler/tests/test_example_executor.py b/services/scheduler/tests/test_example_executor.py deleted file mode 100644 index da3f9ba..0000000 --- a/services/scheduler/tests/test_example_executor.py +++ /dev/null @@ -1,80 +0,0 @@ -""" -Tests for the example executor. -""" -import pytest -import asyncio -from src.executors import example_executor -from src.config import Settings - - -@pytest.mark.executor -@pytest.mark.unit -class TestExampleExecutor: - """Tests for example_executor module.""" - - @pytest.mark.asyncio - async def test_executor_returns_success_message(self, test_settings: Settings): - """Test that executor returns success message.""" - config = { - "message": "Test message", - "delay_seconds": 0 - } - - result = await example_executor.execute(config, test_settings) - - assert "Test message" in result - assert "took" in result.lower() - - @pytest.mark.asyncio - async def test_executor_with_delay(self, test_settings: Settings): - """Test that executor respects delay_seconds.""" - import time - - config = { - "message": "Delayed test", - "delay_seconds": 1 - } - - start = time.time() - result = await example_executor.execute(config, test_settings) - elapsed = time.time() - start - - # Should take at least 1 second - assert elapsed >= 1.0 - assert "Delayed test" in result - - @pytest.mark.asyncio - async def test_executor_with_zero_delay(self, test_settings: Settings): - """Test that executor works with zero delay.""" - config = { - "message": "Instant", - "delay_seconds": 0 - } - - result = await example_executor.execute(config, test_settings) - - assert "Instant" in result - assert "took 0s" in result - - @pytest.mark.asyncio - async def test_executor_missing_config_fields(self, test_settings: Settings): - """Test that executor handles missing config fields gracefully.""" - config = {} - - # Should use defaults - result = await example_executor.execute(config, test_settings) - - assert isinstance(result, str) - assert "took" in result.lower() - - @pytest.mark.asyncio - async def test_executor_invalid_delay_type(self, test_settings: Settings): - """Test that executor handles invalid delay type.""" - config = { - "message": "Test", - "delay_seconds": "invalid" # Should be int - } - - # Should raise TypeError or handle gracefully - with pytest.raises((TypeError, ValueError)): - await example_executor.execute(config, test_settings) diff --git a/services/scheduler/tests/test_integration.py b/services/scheduler/tests/test_integration.py deleted file mode 100644 index 2f52501..0000000 --- a/services/scheduler/tests/test_integration.py +++ /dev/null @@ -1,189 +0,0 @@ -""" -Integration tests with database. -These tests can be run with a test database or skipped if not available. -""" -import pytest -import os -from fastapi.testclient import TestClient -from unittest.mock import patch - - -@pytest.mark.integration -@pytest.mark.skipif( - os.getenv("RUN_INTEGRATION_TESTS") != "true", - reason="Integration tests require RUN_INTEGRATION_TESTS=true" -) -class TestDatabaseIntegration: - """Integration tests that use actual database.""" - - def test_health_with_real_scheduler(self, client: TestClient): - """Test health endpoint with real scheduler instance.""" - # This test runs against the actual scheduler if it's running - # For true integration testing, we'd set up a test database - pass - - -@pytest.mark.integration -class TestEndToEndTaskFlow: - """End-to-end tests for task lifecycle (mocked database).""" - - def test_create_list_delete_task_flow(self, client: TestClient, auth_headers: dict, sample_task_data: dict): - """Test complete task lifecycle: create → list → delete.""" - from unittest.mock import MagicMock - - # This simulates the full flow with mocked database - with patch('src.main.get_task_executor') as mock_executor: - mock_conn = MagicMock() - mock_cursor = MagicMock() - - # Mock create - created_task = {**sample_task_data, "id": 99} - mock_cursor.fetchone.side_effect = [ - created_task, # Create task - created_task, # List tasks (as dict) - ("test_task",) # Delete task - ] - mock_cursor.fetchall.return_value = [created_task] - - mock_conn.cursor.return_value.__enter__ = MagicMock(return_value=mock_cursor) - mock_conn.cursor.return_value.__exit__ = MagicMock(return_value=None) - mock_executor.return_value.get_db_connection.return_value.__enter__ = MagicMock(return_value=mock_conn) - mock_executor.return_value.get_db_connection.return_value.__exit__ = MagicMock(return_value=None) - - # Create - create_response = client.post( - "/tasks", - headers=auth_headers, - json=sample_task_data - ) - - # List - list_response = client.get("/tasks", headers=auth_headers) - - # Delete - delete_response = client.delete( - "/tasks/test_task", - headers=auth_headers - ) - - # Verify the flow worked - assert create_response.status_code in [200, 500] # May fail on DB issues - assert list_response.status_code in [200, 500] - assert delete_response.status_code in [200, 404, 500] - - -@pytest.mark.integration -class TestTaskExecutorIntegration: - """Integration tests for task execution.""" - - @pytest.mark.asyncio - async def test_execute_example_task_real(self, test_settings): - """Test executing example executor with real implementation.""" - from src.executors import example_executor - - config = { - "message": "Integration test", - "delay_seconds": 0 - } - - result = await example_executor.execute(config, test_settings) - - assert "Integration test" in result - assert "took" in result.lower() - - @pytest.mark.asyncio - async def test_task_scheduling_logic(self, test_settings): - """Test task scheduling logic.""" - from src.tasks.executor import TaskExecutor - from datetime import datetime - - executor = TaskExecutor(test_settings) - - # Test various scheduling scenarios - task_every_minute = { - 'minute': -1, 'hour': -1, 'day_of_month': -1, - 'month': -1, 'day_of_week': -1 - } - - task_specific_time = { - 'minute': 30, 'hour': 14, 'day_of_month': -1, - 'month': -1, 'day_of_week': -1 - } - - now = datetime(2025, 12, 7, 14, 30, 0) - - # Every minute task should always run - assert executor._should_run_now(task_every_minute, now) is True - - # Specific time task should run at 14:30 - assert executor._should_run_now(task_specific_time, now) is True - - # But not at 14:31 - now_plus_one = datetime(2025, 12, 7, 14, 31, 0) - assert executor._should_run_now(task_specific_time, now_plus_one) is False - - -@pytest.mark.integration -class TestErrorHandling: - """Integration tests for error handling.""" - - @pytest.mark.asyncio - async def test_executor_with_invalid_module(self, test_settings, sample_task_data: dict): - """Test task execution with invalid executor module.""" - from src.tasks.executor import TaskExecutor - - executor = TaskExecutor(test_settings) - - task = {**sample_task_data, "executor": "nonexistent_executor"} - - with patch.object(executor, 'get_db_connection') as mock_get_conn: - from unittest.mock import MagicMock - - mock_conn = MagicMock() - mock_cursor = MagicMock() - - mock_conn.cursor.return_value.__enter__ = MagicMock(return_value=mock_cursor) - mock_conn.cursor.return_value.__exit__ = MagicMock(return_value=None) - mock_get_conn.return_value.__enter__ = MagicMock(return_value=mock_conn) - mock_get_conn.return_value.__exit__ = MagicMock(return_value=None) - - # Should handle the error gracefully - await executor.execute_task(task) - - # Should have recorded the error - assert mock_cursor.execute.called - - -@pytest.mark.integration -class TestConfigValidation: - """Integration tests for configuration validation.""" - - def test_settings_loads_from_environment(self): - """Test that settings load correctly from environment.""" - from src.config import get_settings - - settings = get_settings() - - # Should have loaded test environment variables - assert settings.postgres_host == "test-postgres" - assert settings.postgres_db == "test_scheduler" - assert settings.scheduler_api_key == "test-api-key-12345" - - def test_settings_provides_database_url(self): - """Test that settings provides correct database URL.""" - from src.config import get_settings - - settings = get_settings() - db_url = settings.database_url - - assert "postgresql://" in db_url - assert "test_scheduler" in db_url - - def test_settings_provides_redis_url(self): - """Test that settings provides correct Redis URL.""" - from src.config import get_settings - - settings = get_settings() - redis_url = settings.redis_url - - assert "redis://" in redis_url diff --git a/services/scheduler/tests/test_rest_api_executor.py b/services/scheduler/tests/test_rest_api_executor.py deleted file mode 100644 index 816628f..0000000 --- a/services/scheduler/tests/test_rest_api_executor.py +++ /dev/null @@ -1,631 +0,0 @@ -""" -Comprehensive tests for the generic REST API executor. - -Tests cover: -- All HTTP methods (GET, POST, PUT, DELETE, PATCH) -- Authentication types (Bearer, Basic, API Key) -- Environment variable substitution -- Error handling -- Response extraction -- Sensitive data redaction - -Run with: pytest tests/test_rest_api_executor.py -v -s -""" -import pytest -import asyncio -import os -from unittest.mock import AsyncMock, patch, MagicMock -import httpx - -from src.executors import rest_api_executor -from src.config import Settings - - -@pytest.mark.executor -@pytest.mark.unit -class TestRestApiExecutor: - """Tests for rest_api_executor module.""" - - @pytest.fixture - def mock_httpx_client(self): - """Mock httpx AsyncClient.""" - return AsyncMock() - - @pytest.fixture - def base_config(self): - """Base configuration for REST API calls.""" - return { - "url": "http://test-service:8080/api/endpoint", - "method": "POST" - } - - # Basic HTTP Method Tests - - @pytest.mark.asyncio - async def test_post_request_success(self, test_settings: Settings): - """Test successful POST request.""" - config = { - "url": "http://httpbin.org/post", - "method": "POST", - "payload": {"test": "data"}, - "timeout": 10 - } - - # Mock httpx response - with patch('httpx.AsyncClient') as mock_client: - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = {"message": "Success"} - mock_response.text = "Success" - - mock_client.return_value.__aenter__.return_value.post = AsyncMock( - return_value=mock_response - ) - - result = await rest_api_executor.execute(config, test_settings) - - assert "Success" in result - - @pytest.mark.asyncio - async def test_get_request_success(self, test_settings: Settings): - """Test successful GET request.""" - config = { - "url": "http://httpbin.org/get", - "method": "GET", - "timeout": 10 - } - - with patch('httpx.AsyncClient') as mock_client: - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = {"result": "GET success"} - mock_response.text = "Success" - - mock_client.return_value.__aenter__.return_value.get = AsyncMock( - return_value=mock_response - ) - - result = await rest_api_executor.execute(config, test_settings) - - assert result is not None - - @pytest.mark.asyncio - async def test_put_request_success(self, test_settings: Settings): - """Test successful PUT request.""" - config = { - "url": "http://httpbin.org/put", - "method": "PUT", - "payload": {"update": "data"} - } - - with patch('httpx.AsyncClient') as mock_client: - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = {"message": "Updated"} - mock_response.text = "Updated" - - mock_client.return_value.__aenter__.return_value.put = AsyncMock( - return_value=mock_response - ) - - result = await rest_api_executor.execute(config, test_settings) - - assert "Updated" in result - - @pytest.mark.asyncio - async def test_delete_request_success(self, test_settings: Settings): - """Test successful DELETE request.""" - config = { - "url": "http://httpbin.org/delete", - "method": "DELETE" - } - - with patch('httpx.AsyncClient') as mock_client: - mock_response = MagicMock() - mock_response.status_code = 204 - mock_response.json.side_effect = Exception("No JSON") - mock_response.text = "" - - mock_client.return_value.__aenter__.return_value.delete = AsyncMock( - return_value=mock_response - ) - - result = await rest_api_executor.execute(config, test_settings) - - assert "204" in result - - @pytest.mark.asyncio - async def test_patch_request_success(self, test_settings: Settings): - """Test successful PATCH request.""" - config = { - "url": "http://httpbin.org/patch", - "method": "PATCH", - "payload": {"field": "value"} - } - - with patch('httpx.AsyncClient') as mock_client: - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = {"message": "Patched"} - mock_response.text = "Patched" - - mock_client.return_value.__aenter__.return_value.patch = AsyncMock( - return_value=mock_response - ) - - result = await rest_api_executor.execute(config, test_settings) - - assert "Patched" in result - - # Authentication Tests - - @pytest.mark.asyncio - async def test_bearer_auth(self, test_settings: Settings): - """Test Bearer token authentication.""" - config = { - "url": "http://test-service/api", - "method": "GET", - "auth": { - "type": "bearer", - "token": "test-token-123" - } - } - - with patch('httpx.AsyncClient') as mock_client: - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = {"message": "Authenticated"} - mock_response.text = "Authenticated" - - mock_get = AsyncMock(return_value=mock_response) - mock_client.return_value.__aenter__.return_value.get = mock_get - - result = await rest_api_executor.execute(config, test_settings) - - # Verify Authorization header was set - call_args = mock_get.call_args - headers = call_args.kwargs.get('headers', {}) - assert 'Authorization' in headers - assert headers['Authorization'] == 'Bearer test-token-123' - - @pytest.mark.asyncio - async def test_basic_auth(self, test_settings: Settings): - """Test Basic authentication.""" - config = { - "url": "http://test-service/api", - "method": "GET", - "auth": { - "type": "basic", - "username": "testuser", - "password": "testpass" - } - } - - with patch('httpx.AsyncClient') as mock_client: - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = {"message": "Authenticated"} - mock_response.text = "Authenticated" - - mock_get = AsyncMock(return_value=mock_response) - mock_client.return_value.__aenter__.return_value.get = mock_get - - result = await rest_api_executor.execute(config, test_settings) - - # Verify Authorization header was set - call_args = mock_get.call_args - headers = call_args.kwargs.get('headers', {}) - assert 'Authorization' in headers - assert headers['Authorization'].startswith('Basic ') - - @pytest.mark.asyncio - async def test_api_key_auth(self, test_settings: Settings): - """Test API Key authentication.""" - config = { - "url": "http://test-service/api", - "method": "GET", - "auth": { - "type": "api_key", - "key": "my-api-key-123", - "header": "X-API-Key" - } - } - - with patch('httpx.AsyncClient') as mock_client: - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = {"message": "Authenticated"} - mock_response.text = "Authenticated" - - mock_get = AsyncMock(return_value=mock_response) - mock_client.return_value.__aenter__.return_value.get = mock_get - - result = await rest_api_executor.execute(config, test_settings) - - # Verify API key header was set - call_args = mock_get.call_args - headers = call_args.kwargs.get('headers', {}) - assert 'X-API-Key' in headers - assert headers['X-API-Key'] == 'my-api-key-123' - - # Environment Variable Substitution Tests - - @pytest.mark.asyncio - async def test_env_var_substitution_in_url(self, test_settings: Settings): - """Test environment variable substitution in URL.""" - os.environ['TEST_SERVICE_URL'] = 'http://my-service:8080' - - config = { - "url": "${TEST_SERVICE_URL}/api/endpoint", - "method": "GET" - } - - with patch('httpx.AsyncClient') as mock_client: - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = {"message": "Success"} - mock_response.text = "Success" - - mock_get = AsyncMock(return_value=mock_response) - mock_client.return_value.__aenter__.return_value.get = mock_get - - result = await rest_api_executor.execute(config, test_settings) - - # Verify URL was substituted - call_args = mock_get.call_args - url = call_args.args[0] if call_args.args else None - assert url == 'http://my-service:8080/api/endpoint' - - del os.environ['TEST_SERVICE_URL'] - - @pytest.mark.asyncio - async def test_env_var_substitution_in_auth_token(self, test_settings: Settings): - """Test environment variable substitution in auth token.""" - os.environ['API_TOKEN'] = 'secret-token-from-env' - - config = { - "url": "http://test-service/api", - "method": "GET", - "auth": { - "type": "bearer", - "token": "${API_TOKEN}" - } - } - - with patch('httpx.AsyncClient') as mock_client: - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = {} - mock_response.text = "Success" - - mock_get = AsyncMock(return_value=mock_response) - mock_client.return_value.__aenter__.return_value.get = mock_get - - result = await rest_api_executor.execute(config, test_settings) - - # Verify token was substituted - call_args = mock_get.call_args - headers = call_args.kwargs.get('headers', {}) - assert headers['Authorization'] == 'Bearer secret-token-from-env' - - del os.environ['API_TOKEN'] - - @pytest.mark.asyncio - async def test_env_var_substitution_in_payload(self, test_settings: Settings): - """Test environment variable substitution in payload.""" - os.environ['DATABASE_NAME'] = 'test_db' - - config = { - "url": "http://test-service/api", - "method": "POST", - "payload": { - "database": "${DATABASE_NAME}", - "action": "backup" - } - } - - with patch('httpx.AsyncClient') as mock_client: - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = {} - mock_response.text = "Success" - - mock_post = AsyncMock(return_value=mock_response) - mock_client.return_value.__aenter__.return_value.post = mock_post - - result = await rest_api_executor.execute(config, test_settings) - - # Verify payload was substituted - call_args = mock_post.call_args - payload = call_args.kwargs.get('json', {}) - assert payload['database'] == 'test_db' - - del os.environ['DATABASE_NAME'] - - # Response Extraction Tests - - @pytest.mark.asyncio - async def test_response_path_extraction(self, test_settings: Settings): - """Test extracting specific field from response.""" - config = { - "url": "http://test-service/api", - "method": "GET", - "response_path": "result.message" - } - - with patch('httpx.AsyncClient') as mock_client: - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = { - "result": { - "message": "Extracted message", - "other": "ignored" - } - } - mock_response.text = "Success" - - mock_client.return_value.__aenter__.return_value.get = AsyncMock( - return_value=mock_response - ) - - result = await rest_api_executor.execute(config, test_settings) - - assert result == "Extracted message" - - @pytest.mark.asyncio - async def test_response_path_not_found(self, test_settings: Settings): - """Test response path extraction when field doesn't exist.""" - config = { - "url": "http://test-service/api", - "method": "GET", - "response_path": "result.missing" - } - - with patch('httpx.AsyncClient') as mock_client: - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = {"result": {"other": "data"}} - mock_response.text = "Success" - - mock_client.return_value.__aenter__.return_value.get = AsyncMock( - return_value=mock_response - ) - - result = await rest_api_executor.execute(config, test_settings) - - # Should fallback to default message - assert result is not None - - # Error Handling Tests - - @pytest.mark.asyncio - async def test_missing_url_raises_error(self, test_settings: Settings): - """Test that missing URL raises ValueError.""" - config = { - "method": "GET" - } - - with pytest.raises(ValueError, match="Missing required config: 'url'"): - await rest_api_executor.execute(config, test_settings) - - @pytest.mark.asyncio - async def test_invalid_http_method_raises_error(self, test_settings: Settings): - """Test that invalid HTTP method raises ValueError.""" - config = { - "url": "http://test-service/api", - "method": "INVALID" - } - - with pytest.raises(ValueError, match="Invalid HTTP method"): - await rest_api_executor.execute(config, test_settings) - - @pytest.mark.asyncio - async def test_http_error_status_code(self, test_settings: Settings): - """Test handling of HTTP error status codes.""" - config = { - "url": "http://test-service/api", - "method": "GET" - } - - with patch('httpx.AsyncClient') as mock_client: - mock_response = MagicMock() - mock_response.status_code = 500 - mock_response.text = "Internal Server Error" - - mock_client.return_value.__aenter__.return_value.get = AsyncMock( - return_value=mock_response - ) - - with pytest.raises(Exception, match="API call failed with status 500"): - await rest_api_executor.execute(config, test_settings) - - @pytest.mark.asyncio - async def test_custom_success_codes(self, test_settings: Settings): - """Test custom success codes configuration.""" - config = { - "url": "http://test-service/api", - "method": "POST", - "success_codes": [201, 202] - } - - with patch('httpx.AsyncClient') as mock_client: - mock_response = MagicMock() - mock_response.status_code = 201 - mock_response.json.return_value = {"message": "Created"} - mock_response.text = "Created" - - mock_client.return_value.__aenter__.return_value.post = AsyncMock( - return_value=mock_response - ) - - result = await rest_api_executor.execute(config, test_settings) - - assert "Created" in result - - @pytest.mark.asyncio - async def test_network_error_handling(self, test_settings: Settings): - """Test handling of network errors.""" - config = { - "url": "http://unreachable-service/api", - "method": "GET", - "timeout": 1 - } - - with patch('httpx.AsyncClient') as mock_client: - mock_client.return_value.__aenter__.return_value.get = AsyncMock( - side_effect=httpx.ConnectError("Connection refused") - ) - - with pytest.raises(Exception, match="Request failed"): - await rest_api_executor.execute(config, test_settings) - - # Sensitive Data Redaction Tests - - def test_redact_sensitive_data_in_dict(self): - """Test redaction of sensitive fields in dictionaries.""" - data = { - "username": "user", - "password": "secret123", - "api_key": "key123", - "normal_field": "visible" - } - - redacted = rest_api_executor._redact_sensitive(data) - - assert redacted["username"] == "user" - assert redacted["password"] == "***REDACTED***" - assert redacted["api_key"] == "***REDACTED***" - assert redacted["normal_field"] == "visible" - - def test_redact_sensitive_nested(self): - """Test redaction in nested structures.""" - data = { - "config": { - "database": "mydb", - "password": "secret", - "auth": { - "token": "bearer123" - } - } - } - - redacted = rest_api_executor._redact_sensitive(data) - - assert redacted["config"]["database"] == "mydb" - assert redacted["config"]["password"] == "***REDACTED***" - assert redacted["config"]["auth"]["token"] == "***REDACTED***" - - # Helper Function Tests - - def test_extract_json_path_simple(self): - """Test simple JSONPath extraction.""" - data = {"message": "test"} - result = rest_api_executor._extract_json_path(data, "message") - assert result == "test" - - def test_extract_json_path_nested(self): - """Test nested JSONPath extraction.""" - data = {"result": {"status": {"message": "success"}}} - result = rest_api_executor._extract_json_path(data, "result.status.message") - assert result == "success" - - def test_extract_json_path_not_found(self): - """Test JSONPath extraction when path doesn't exist.""" - data = {"message": "test"} - result = rest_api_executor._extract_json_path(data, "missing.path") - assert result is None - - def test_substitute_env_vars(self): - """Test environment variable substitution.""" - os.environ['TEST_VAR'] = 'test_value' - - result = rest_api_executor._substitute_env_vars("Prefix ${TEST_VAR} suffix") - assert result == "Prefix test_value suffix" - - del os.environ['TEST_VAR'] - - def test_substitute_env_vars_missing(self): - """Test substitution with missing environment variable.""" - result = rest_api_executor._substitute_env_vars("Prefix ${MISSING_VAR} suffix") - # Should replace with empty string - assert result == "Prefix suffix" - - def test_substitute_env_vars_recursive(self): - """Test recursive environment variable substitution.""" - os.environ['HOST'] = 'localhost' - os.environ['PORT'] = '8080' - - data = { - "url": "http://${HOST}:${PORT}/api", - "nested": { - "key": "${HOST}" - } - } - - result = rest_api_executor._substitute_env_vars_recursive(data) - - assert result["url"] == "http://localhost:8080/api" - assert result["nested"]["key"] == "localhost" - - del os.environ['HOST'] - del os.environ['PORT'] - - # Configuration Options Tests - - @pytest.mark.asyncio - async def test_custom_headers(self, test_settings: Settings): - """Test adding custom headers.""" - config = { - "url": "http://test-service/api", - "method": "GET", - "headers": { - "X-Custom-Header": "custom-value", - "User-Agent": "Test-Agent" - } - } - - with patch('httpx.AsyncClient') as mock_client: - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = {} - mock_response.text = "Success" - - mock_get = AsyncMock(return_value=mock_response) - mock_client.return_value.__aenter__.return_value.get = mock_get - - result = await rest_api_executor.execute(config, test_settings) - - # Verify custom headers were set - call_args = mock_get.call_args - headers = call_args.kwargs.get('headers', {}) - assert headers['X-Custom-Header'] == 'custom-value' - assert headers['User-Agent'] == 'Test-Agent' - - @pytest.mark.asyncio - async def test_ssl_verification_disabled(self, test_settings: Settings): - """Test disabling SSL verification.""" - config = { - "url": "https://test-service/api", - "method": "GET", - "verify_ssl": False - } - - with patch('httpx.AsyncClient') as mock_client: - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = {} - mock_response.text = "Success" - - mock_client.return_value.__aenter__.return_value.get = AsyncMock( - return_value=mock_response - ) - - result = await rest_api_executor.execute(config, test_settings) - - # Verify SSL verification was disabled - client_call = mock_client.call_args - assert client_call.kwargs.get('verify') is False - - -if __name__ == "__main__": - pytest.main([__file__, "-v", "-s"]) diff --git a/services/scheduler/tests/test_task_executor.py b/services/scheduler/tests/test_task_executor.py deleted file mode 100644 index 3ce6eb2..0000000 --- a/services/scheduler/tests/test_task_executor.py +++ /dev/null @@ -1,307 +0,0 @@ -""" -Tests for the task executor module. -""" -import pytest -from unittest.mock import AsyncMock, MagicMock, patch -from datetime import datetime -from src.tasks.executor import TaskExecutor -from src.config import Settings - - -@pytest.mark.unit -class TestTaskExecutor: - """Tests for TaskExecutor class.""" - - def test_executor_initialization(self, test_settings: Settings): - """Test that executor initializes correctly.""" - executor = TaskExecutor(test_settings) - - assert executor.settings == test_settings - assert executor.max_concurrent == 5 - - @patch('psycopg2.connect') - def test_get_db_connection(self, mock_connect, test_settings: Settings): - """Test database connection creation.""" - mock_conn = MagicMock() - mock_connect.return_value = mock_conn - - executor = TaskExecutor(test_settings) - conn = executor.get_db_connection() - - assert conn == mock_conn - mock_connect.assert_called_once() - - @pytest.mark.asyncio - async def test_process_minute_no_tasks(self, test_settings: Settings): - """Test process_minute with no scheduled tasks.""" - executor = TaskExecutor(test_settings) - - with patch.object(executor, 'get_db_connection') as mock_get_conn: - mock_conn = MagicMock() - mock_cursor = MagicMock() - mock_cursor.fetchall.return_value = [] - - mock_conn.cursor.return_value.__enter__ = MagicMock(return_value=mock_cursor) - mock_conn.cursor.return_value.__exit__ = MagicMock(return_value=None) - mock_get_conn.return_value.__enter__ = MagicMock(return_value=mock_conn) - mock_get_conn.return_value.__exit__ = MagicMock(return_value=None) - - # Should complete without error - await executor.process_minute() - - # Should have queried for tasks - assert mock_cursor.execute.called - - @pytest.mark.asyncio - async def test_process_minute_with_tasks(self, test_settings: Settings, sample_task_data: dict): - """Test process_minute executes tasks.""" - executor = TaskExecutor(test_settings) - - task_from_db = { - **sample_task_data, - 'id': 1, - 'config': sample_task_data['config'] # Already a dict - } - - with patch.object(executor, 'get_db_connection') as mock_get_conn, \ - patch.object(executor, 'execute_task', new_callable=AsyncMock) as mock_execute: - - mock_conn = MagicMock() - mock_cursor = MagicMock() - mock_cursor.fetchall.return_value = [task_from_db] - - mock_conn.cursor.return_value.__enter__ = MagicMock(return_value=mock_cursor) - mock_conn.cursor.return_value.__exit__ = MagicMock(return_value=None) - mock_get_conn.return_value.__enter__ = MagicMock(return_value=mock_conn) - mock_get_conn.return_value.__exit__ = MagicMock(return_value=None) - - await executor.process_minute() - - # Should have executed the task - mock_execute.assert_called_once() - - @pytest.mark.asyncio - async def test_execute_task_success(self, test_settings: Settings, sample_task_data: dict): - """Test successful task execution.""" - executor = TaskExecutor(test_settings) - - task = {**sample_task_data, 'id': 1} - - with patch.object(executor, 'get_db_connection') as mock_get_conn, \ - patch('src.tasks.executor.importlib.import_module') as mock_import: - - # Mock executor module - mock_executor_module = MagicMock() - mock_executor_module.execute = AsyncMock(return_value="Task completed successfully") - mock_import.return_value = mock_executor_module - - # Mock database - mock_conn = MagicMock() - mock_cursor = MagicMock() - mock_conn.cursor.return_value.__enter__ = MagicMock(return_value=mock_cursor) - mock_conn.cursor.return_value.__exit__ = MagicMock(return_value=None) - mock_get_conn.return_value.__enter__ = MagicMock(return_value=mock_conn) - mock_get_conn.return_value.__exit__ = MagicMock(return_value=None) - - await executor.execute_task(task) - - # Should have imported executor module - mock_import.assert_called_with('src.executors.example_executor') - - # Should have updated task status - assert mock_cursor.execute.call_count >= 2 # Insert execution record + update task - - @pytest.mark.asyncio - async def test_execute_task_failure(self, test_settings: Settings, sample_task_data: dict): - """Test task execution handles failures.""" - executor = TaskExecutor(test_settings) - - task = {**sample_task_data, 'id': 1} - - with patch.object(executor, 'get_db_connection') as mock_get_conn, \ - patch('src.tasks.executor.importlib.import_module') as mock_import: - - # Mock executor that raises error - mock_executor_module = MagicMock() - mock_executor_module.execute = AsyncMock(side_effect=Exception("Task failed")) - mock_import.return_value = mock_executor_module - - # Mock database - mock_conn = MagicMock() - mock_cursor = MagicMock() - mock_conn.cursor.return_value.__enter__ = MagicMock(return_value=mock_cursor) - mock_conn.cursor.return_value.__exit__ = MagicMock(return_value=None) - mock_get_conn.return_value.__enter__ = MagicMock(return_value=mock_conn) - mock_get_conn.return_value.__exit__ = MagicMock(return_value=None) - - await executor.execute_task(task) - - # Should have recorded the failure - assert mock_cursor.execute.called - # Check that error status was recorded - calls = [str(call) for call in mock_cursor.execute.call_args_list] - assert any('failed' in str(call).lower() or 'error' in str(call).lower() for call in calls) - - @pytest.mark.asyncio - async def test_execute_task_timeout(self, test_settings: Settings, sample_task_data: dict): - """Test task execution handles timeouts.""" - executor = TaskExecutor(test_settings) - - task = {**sample_task_data, 'id': 1, 'timeout_seconds': 1} - - with patch.object(executor, 'get_db_connection') as mock_get_conn, \ - patch('src.tasks.executor.importlib.import_module') as mock_import: - - # Mock executor that takes too long - import asyncio - mock_executor_module = MagicMock() - - async def slow_execute(*args, **kwargs): - await asyncio.sleep(10) # Longer than timeout - return "Done" - - mock_executor_module.execute = slow_execute - mock_import.return_value = mock_executor_module - - # Mock database - mock_conn = MagicMock() - mock_cursor = MagicMock() - mock_conn.cursor.return_value.__enter__ = MagicMock(return_value=mock_cursor) - mock_conn.cursor.return_value.__exit__ = MagicMock(return_value=None) - mock_get_conn.return_value.__enter__ = MagicMock(return_value=mock_conn) - mock_get_conn.return_value.__exit__ = MagicMock(return_value=None) - - await executor.execute_task(task) - - # Should have recorded timeout - calls = [str(call) for call in mock_cursor.execute.call_args_list] - assert any('timeout' in str(call).lower() for call in calls) - - def test_should_run_task_wildcard(self, test_settings: Settings): - """Test task scheduling with wildcards.""" - executor = TaskExecutor(test_settings) - - # All wildcards should always match - task = { - 'minute': -1, - 'hour': -1, - 'day_of_month': -1, - 'month': -1, - 'day_of_week': -1 - } - - now = datetime(2025, 12, 7, 14, 30, 0) # Saturday - - assert executor._should_run_now(task, now) is True - - def test_should_run_task_specific_time(self, test_settings: Settings): - """Test task scheduling with specific time.""" - executor = TaskExecutor(test_settings) - - # Specific time: every day at 14:30 - task = { - 'minute': 30, - 'hour': 14, - 'day_of_month': -1, - 'month': -1, - 'day_of_week': -1 - } - - # Matching time - now = datetime(2025, 12, 7, 14, 30, 0) - assert executor._should_run_now(task, now) is True - - # Non-matching time - now = datetime(2025, 12, 7, 14, 31, 0) - assert executor._should_run_now(task, now) is False - - def test_should_run_task_specific_day_of_month(self, test_settings: Settings): - """Test task scheduling with specific day of month.""" - executor = TaskExecutor(test_settings) - - # Run on 11th of every month at 04:00 - task = { - 'minute': 0, - 'hour': 4, - 'day_of_month': 11, - 'month': -1, - 'day_of_week': -1 - } - - # Matching date - now = datetime(2025, 12, 11, 4, 0, 0) - assert executor._should_run_now(task, now) is True - - # Wrong day - now = datetime(2025, 12, 12, 4, 0, 0) - assert executor._should_run_now(task, now) is False - - def test_should_run_task_specific_month(self, test_settings: Settings): - """Test task scheduling with specific month.""" - executor = TaskExecutor(test_settings) - - # Run on January 1st at midnight - task = { - 'minute': 0, - 'hour': 0, - 'day_of_month': 1, - 'month': 1, - 'day_of_week': -1 - } - - # Matching date - now = datetime(2025, 1, 1, 0, 0, 0) - assert executor._should_run_now(task, now) is True - - # Wrong month - now = datetime(2025, 2, 1, 0, 0, 0) - assert executor._should_run_now(task, now) is False - - def test_should_run_task_day_of_week(self, test_settings: Settings): - """Test task scheduling with day of week.""" - executor = TaskExecutor(test_settings) - - # Run every Monday at 09:00 - task = { - 'minute': 0, - 'hour': 9, - 'day_of_month': -1, - 'month': -1, - 'day_of_week': 0 # Monday - } - - # Monday - now = datetime(2025, 12, 8, 9, 0, 0) # Monday - assert executor._should_run_now(task, now) is True - - # Tuesday - now = datetime(2025, 12, 9, 9, 0, 0) # Tuesday - assert executor._should_run_now(task, now) is False - - @pytest.mark.asyncio - async def test_concurrent_task_limit(self, test_settings: Settings, sample_task_data: dict): - """Test that executor respects concurrent task limit.""" - executor = TaskExecutor(test_settings) - - # Create 10 tasks - tasks = [ - {**sample_task_data, 'id': i, 'task_name': f'task_{i}'} - for i in range(10) - ] - - with patch.object(executor, 'get_db_connection') as mock_get_conn, \ - patch.object(executor, 'execute_task', new_callable=AsyncMock) as mock_execute: - - mock_conn = MagicMock() - mock_cursor = MagicMock() - mock_cursor.fetchall.return_value = tasks - - mock_conn.cursor.return_value.__enter__ = MagicMock(return_value=mock_cursor) - mock_conn.cursor.return_value.__exit__ = MagicMock(return_value=None) - mock_get_conn.return_value.__enter__ = MagicMock(return_value=mock_conn) - mock_get_conn.return_value.__exit__ = MagicMock(return_value=None) - - await executor.process_minute() - - # Should only execute max_concurrent (5) tasks - assert mock_execute.call_count <= executor.max_concurrent diff --git a/stacks/gitea.yml b/stacks/gitea.yml index 286a243..34fbd39 100644 --- a/stacks/gitea.yml +++ b/stacks/gitea.yml @@ -48,6 +48,24 @@ services: networks: - docker-dataplane + gitea-runner: + image: gitea/act_runner:latest + container_name: gitea-runner + restart: unless-stopped + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - /home/jpmschweitzer/docker-data/gitea/runner:/data + environment: + - CONFIG_FILE=/data/config.yaml + - GITEA_INSTANCE_URL=http://gitea:3000 + - GITEA_RUNNER_REGISTRATION_TOKEN=${GITEA_RUNNER_TOKEN} + - GITEA_RUNNER_NAME=docker-runner + - TZ=Europe/Amsterdam + depends_on: + - gitea + networks: + - docker-dataplane + networks: docker-dataplane: external: true diff --git a/stacks/scheduler.yml b/stacks/scheduler.yml index 79e2e11..24b36ec 100644 --- a/stacks/scheduler.yml +++ b/stacks/scheduler.yml @@ -4,35 +4,14 @@ version: '3.8' # Purpose: System-wide maintenance orchestration - backups, doc mirroring, cleanup, task automation # Port: 8090 (API + UI) # Network: docker-dataplane +# Image: git.schweitz.net/jpmschweitzer/scheduler (private registry) services: scheduler: - image: python:3.12-slim + image: git.schweitz.net/jpmschweitzer/scheduler:latest container_name: scheduler restart: unless-stopped - command: > - sh -c " - echo 'Installing system dependencies...' && - apt-get update -qq && - apt-get install -y --no-install-recommends git ssh postgresql-client curl docker.io >/dev/null 2>&1 && - rm -rf /var/lib/apt/lists/* && - echo 'Setting up Python environment...' && - if [ ! -f /venv/bin/python ]; then - echo 'Initializing venv...' && - python3 -m venv --clear /venv; - fi && - echo 'Upgrading pip...' && - /venv/bin/python -m pip install --upgrade pip --quiet && - echo 'Installing dependencies...' && - /venv/bin/python -m pip install -r /app/requirements.txt --quiet && - echo 'Configuring Git...' && - git config --global user.name 'The Librarian' && - git config --global user.email 'librarian@portainer-core.local' && - echo 'Starting The Scheduler...' && - /venv/bin/python -m uvicorn src.main:app --host 0.0.0.0 --port 8090 --workers 1 - " - ports: - "8090:8090" @@ -65,8 +44,7 @@ services: - PYTHONPATH=/app volumes: - - /home/jpmschweitzer/Projects/portainer-core/services/scheduler:/app - - /home/jpmschweitzer/docker-data/scheduler/venv:/venv + # Data volumes only - source code is baked into image - /home/jpmschweitzer/docker-data/scheduler/logs:/app/logs - /home/jpmschweitzer/docker-data/scheduler/task-data:/app/task-data - /home/jpmschweitzer/docker-data/scheduler/ssh:/root/.ssh:ro