Include full contents of all nested repositories
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Submodule letsbe-orchestrator deleted from 21540e31c3
27
letsbe-orchestrator/.claude/settings.local.json
Normal file
27
letsbe-orchestrator/.claude/settings.local.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"mcp__serena__activate_project",
|
||||
"mcp__serena__list_dir",
|
||||
"mcp__context7__resolve-library-id",
|
||||
"mcp__context7__get-library-docs",
|
||||
"mcp__zen__consensus",
|
||||
"Bash(docker compose build:*)",
|
||||
"Bash(docker compose:*)",
|
||||
"Bash(curl:*)",
|
||||
"Bash(dir \"Z:\\Repos\\LetsBeV2\\orchestrator\\letsbe-orchestrator\\alembic\\versions\" /B)",
|
||||
"Bash(dir:*)",
|
||||
"Bash(timeout:*)",
|
||||
"Bash(python:*)",
|
||||
"Bash(git init:*)",
|
||||
"Bash(git remote add:*)",
|
||||
"Bash(git add:*)",
|
||||
"Bash(git commit -m \"$(cat <<''EOF''\nInitial commit: LetsBe Cloud Orchestrator\n\nFeatures:\n- FastAPI backend with SQLAlchemy 2.0 async ORM\n- Tenant management (CRUD operations)\n- Task management with types: FILE_WRITE, ENV_UPDATE, DOCKER_RELOAD, COMPOSITE\n- Agent registration, heartbeat, and task claiming (/tasks/next)\n- Chatwoot deployment playbook (COMPOSITE task with ENV_UPDATE + DOCKER_RELOAD)\n- Alembic migrations for Postgres\n- Docker Compose setup for local development\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude <noreply@anthropic.com>\nEOF\n)\")",
|
||||
"Bash(git push:*)",
|
||||
"Bash(git remote set-url:*)",
|
||||
"Bash(git commit:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
}
|
||||
}
|
||||
43
letsbe-orchestrator/.env.example
Normal file
43
letsbe-orchestrator/.env.example
Normal file
@@ -0,0 +1,43 @@
|
||||
# LetsBe Orchestrator Environment Variables
|
||||
# Copy this file to .env and fill in the values
|
||||
|
||||
# =============================================================================
|
||||
# ADMIN API KEY (Required for production)
|
||||
# =============================================================================
|
||||
# Used to authenticate admin endpoints like registration token management.
|
||||
#
|
||||
# Generate a secure key with:
|
||||
# python -c "import secrets; print(secrets.token_hex(32))"
|
||||
#
|
||||
# Or using openssl:
|
||||
# openssl rand -hex 32
|
||||
#
|
||||
ADMIN_API_KEY=change-me-in-production
|
||||
|
||||
# =============================================================================
|
||||
# Database
|
||||
# =============================================================================
|
||||
# Port 5434 to avoid conflict with Hub Postgres (5432) and other services
|
||||
DATABASE_URL=postgresql+asyncpg://orchestrator:orchestrator@localhost:5434/orchestrator
|
||||
|
||||
# Postgres password for docker-compose (required)
|
||||
# Generate with: openssl rand -hex 16
|
||||
POSTGRES_PASSWORD=change-me-in-production
|
||||
|
||||
# =============================================================================
|
||||
# Application Settings
|
||||
# =============================================================================
|
||||
DEBUG=true
|
||||
APP_NAME=LetsBe Orchestrator
|
||||
|
||||
# =============================================================================
|
||||
# Hub Telemetry (optional)
|
||||
# =============================================================================
|
||||
# Send periodic health/metrics telemetry to the central Hub.
|
||||
# Requires HUB_URL and HUB_API_KEY to be set.
|
||||
#
|
||||
# HUB_TELEMETRY_ENABLED=false
|
||||
# HUB_URL=https://hub.letsbe.solutions
|
||||
# HUB_API_KEY=
|
||||
# Interval in seconds between telemetry submissions (min 10, max 600, default 60)
|
||||
# HUB_TELEMETRY_INTERVAL_SECONDS=60
|
||||
76
letsbe-orchestrator/.gitea/workflows/build.yml
Normal file
76
letsbe-orchestrator/.gitea/workflows/build.yml
Normal file
@@ -0,0 +1,76 @@
|
||||
name: Build and Push Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
tags:
|
||||
- 'v*'
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
|
||||
env:
|
||||
REGISTRY: code.letsbe.solutions
|
||||
IMAGE_NAME: letsbe/orchestrator
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
pip install pytest pytest-asyncio aiosqlite
|
||||
|
||||
- name: Run tests
|
||||
run: pytest -v --tb=short
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
needs: test
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Gitea Container Registry
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels)
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
64
letsbe-orchestrator/.gitignore
vendored
Normal file
64
letsbe-orchestrator/.gitignore
vendored
Normal file
@@ -0,0 +1,64 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# Virtual environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Testing
|
||||
.tox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
htmlcov/
|
||||
.pytest_cache/
|
||||
.hypothesis/
|
||||
|
||||
# Mypy
|
||||
.mypy_cache/
|
||||
|
||||
# Local development
|
||||
*.log
|
||||
*.sqlite
|
||||
*.db
|
||||
.env.test
|
||||
|
||||
# Docker
|
||||
.docker/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Serena (local MCP tool data)
|
||||
.serena/
|
||||
64
letsbe-orchestrator/CLAUDE.md
Normal file
64
letsbe-orchestrator/CLAUDE.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# CLAUDE.md — LetsBe Cloud Orchestrator
|
||||
|
||||
## Overview
|
||||
|
||||
You are the engineering assistant for the **LetsBe Cloud Orchestrator**, the core control-plane backend for the LetsBe Cloud platform.
|
||||
|
||||
The platform automatically provisions per-tenant servers, deploys a suite of open-source tools (Poste, Keycloak, MinIO, Passbolt, Vikunja, etc.), and coordinates an **AI SysAdmin agent** that configures those tools autonomously. The Orchestrator provides APIs for:
|
||||
|
||||
- Tenant lifecycle management
|
||||
- Server provisioning state
|
||||
- Task scheduling
|
||||
- Agent registration & heartbeat
|
||||
- Event logging
|
||||
- DNS operations (via Entri integration)
|
||||
- Secrets storage (via Vault)
|
||||
|
||||
This repository contains ONLY the **Orchestrator service**, not the SysAdmin agent.
|
||||
|
||||
Claude Code should generate code that is:
|
||||
|
||||
- Clean, strongly typed
|
||||
- Production-ready
|
||||
- Following Python 3.11 best practices
|
||||
- Using FastAPI + SQLAlchemy + Alembic
|
||||
- Modular, scalable, logically structured
|
||||
|
||||
---
|
||||
|
||||
## Project Goals
|
||||
|
||||
1. Provide a REST API for managing:
|
||||
- Tenants
|
||||
- Servers
|
||||
- Tasks
|
||||
- Agents
|
||||
- Events
|
||||
|
||||
2. Integrate with:
|
||||
- Postgres
|
||||
- Vault (later)
|
||||
- Entri DNS (later)
|
||||
- SysAdmin agent (remote automation worker)
|
||||
|
||||
3. Serve as the **central state and coordination system** for the entire LetsBe Cloud platform.
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
|
||||
**Backend framework:** FastAPI
|
||||
**Language:** Python 3.11
|
||||
**Database:** Postgres (via Docker Compose)
|
||||
**ORM:** SQLAlchemy 2.0
|
||||
**Migrations:** Alembic
|
||||
**Serialization:** Pydantic v2
|
||||
**Containerization:** Docker Compose
|
||||
**Testing:** Pytest (later)
|
||||
**Architecture style:** Modular monolith (service modules inside `app/`)
|
||||
|
||||
Everything MUST run via Docker using:
|
||||
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
22
letsbe-orchestrator/Dockerfile
Normal file
22
letsbe-orchestrator/Dockerfile
Normal file
@@ -0,0 +1,22 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
libpq-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy requirements first for better caching
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8000
|
||||
|
||||
# Default command
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
103
letsbe-orchestrator/ROADMAP.md
Normal file
103
letsbe-orchestrator/ROADMAP.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# Orchestrator Roadmap
|
||||
|
||||
This document tracks Orchestrator-specific work for the AI SysAdmin system.
|
||||
|
||||
## Completed Work
|
||||
|
||||
### Core Infrastructure
|
||||
- [x] Task model, statuses, DB tables
|
||||
- [x] Agent model + registration flow
|
||||
- [x] Task dispatching (`/tasks/next` polling)
|
||||
- [x] Heartbeat system
|
||||
- [x] Result ingestion
|
||||
- [x] Tenant validation, permission flow
|
||||
- [x] FastAPI app with complete routing structure
|
||||
- [x] Deployment on live server (nginx + SSL)
|
||||
|
||||
### API Routes
|
||||
- [x] `/api/v1/agents/*` - Agent registration, heartbeat
|
||||
- [x] `/api/v1/tasks/*` - Task creation, polling, results
|
||||
- [x] `/api/v1/tenants/*` - Tenant management
|
||||
- [x] `/api/v1/env/*` - ENV inspection and update
|
||||
- [x] `/api/v1/agents/{id}/files/inspect` - File inspection
|
||||
|
||||
### Playbooks
|
||||
- [x] Chatwoot playbook (`/api/v1/tenants/{id}/chatwoot/setup`)
|
||||
|
||||
---
|
||||
|
||||
## Remaining Work
|
||||
|
||||
### Phase 1: Tool-Specific Playbooks
|
||||
|
||||
Create a playbook module for each tool in `app/playbooks/`:
|
||||
|
||||
| Tool | Module | API Endpoint | Status |
|
||||
|------|--------|--------------|--------|
|
||||
| Chatwoot | `chatwoot.py` | `/tenants/{id}/chatwoot/setup` | ✅ Done |
|
||||
| NocoDB | `nocodb.py` | `/tenants/{id}/nocodb/setup` | ⬚ Todo |
|
||||
| Directus | `directus.py` | `/tenants/{id}/directus/setup` | ⬚ Todo |
|
||||
| Ghost CMS | `ghost.py` | `/tenants/{id}/ghost/setup` | ⬚ Todo |
|
||||
| MinIO | `minio.py` | `/tenants/{id}/minio/setup` | ⬚ Todo |
|
||||
| Keycloak | `keycloak.py` | `/tenants/{id}/keycloak/setup` | ⬚ Todo |
|
||||
| Nextcloud | `nextcloud.py` | `/tenants/{id}/nextcloud/setup` | ⬚ Todo |
|
||||
| Activepieces | `activepieces.py` | `/tenants/{id}/activepieces/setup` | ⬚ Todo |
|
||||
| Listmonk | `listmonk.py` | `/tenants/{id}/listmonk/setup` | ⬚ Todo |
|
||||
| Odoo | `odoo.py` | `/tenants/{id}/odoo/setup` | ⬚ Todo |
|
||||
| Mixpost | `mixpost.py` | `/tenants/{id}/mixpost/setup` | ⬚ Todo |
|
||||
|
||||
**Each playbook creates a COMPOSITE task with:**
|
||||
1. `ENV_INSPECT` - Read current configuration
|
||||
2. `ENV_UPDATE` - Update URLs, domains, settings
|
||||
3. `DOCKER_RELOAD` - Restart the stack
|
||||
|
||||
**Each playbook module needs:**
|
||||
- Pydantic request/response schemas
|
||||
- Route handler in `app/routes/playbooks.py`
|
||||
- Tests in `tests/test_playbooks/`
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: Introspection APIs
|
||||
|
||||
- [ ] `/api/v1/servers/{id}/scan` - Discover all services and their state
|
||||
- [ ] `/api/v1/servers/{id}/diagnose` - Find configuration issues
|
||||
- [ ] `/api/v1/servers/{id}/health` - Aggregate health check
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: New Task Types
|
||||
|
||||
Support for new executor types from the agent:
|
||||
|
||||
| Task Type | Purpose | Status |
|
||||
|-----------|---------|--------|
|
||||
| NGINX_RELOAD | Reload nginx after config changes | ⬚ Todo |
|
||||
| HEALTHCHECK | Check service status | ⬚ Todo |
|
||||
| STACK_HEALTH | Verify docker compose stack | ⬚ Todo |
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: Autonomous Operations
|
||||
|
||||
- [ ] LLM integration for natural language commands
|
||||
- [ ] Task chaining based on results
|
||||
- [ ] Automatic remediation workflows
|
||||
|
||||
---
|
||||
|
||||
### Phase 5: Dashboard & UI
|
||||
|
||||
- [ ] Task history and logs viewer
|
||||
- [ ] Agent status dashboard
|
||||
- [ ] Playbook marketplace
|
||||
- [ ] RBAC and multi-tenant UI
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Create `app/playbooks/nocodb.py`
|
||||
2. Add route: `POST /api/v1/tenants/{tenant_id}/nocodb/setup`
|
||||
3. Create COMPOSITE task with ENV_INSPECT, ENV_UPDATE, DOCKER_RELOAD
|
||||
4. Write tests in `tests/test_playbooks/test_nocodb.py`
|
||||
116
letsbe-orchestrator/alembic.ini
Normal file
116
letsbe-orchestrator/alembic.ini
Normal file
@@ -0,0 +1,116 @@
|
||||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# path to migration scripts
|
||||
script_location = alembic
|
||||
|
||||
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||
# Uncomment the line below if you want the files to be prepended with date and time
|
||||
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||
|
||||
# sys.path path, will be prepended to sys.path if present.
|
||||
# defaults to the current working directory.
|
||||
prepend_sys_path = .
|
||||
|
||||
# timezone to use when rendering the date within the migration file
|
||||
# as well as the filename.
|
||||
# If specified, requires the python>=3.9 or backports.zoneinfo library.
|
||||
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
|
||||
# string value is passed to ZoneInfo()
|
||||
# leave blank for localtime
|
||||
# timezone =
|
||||
|
||||
# max length of characters to apply to the "slug" field
|
||||
# truncate_slug_length = 40
|
||||
|
||||
# set to 'true' to run the environment during
|
||||
# the 'revision' command, regardless of autogenerate
|
||||
# revision_environment = false
|
||||
|
||||
# set to 'true' to allow .pyc and .pyo files without
|
||||
# a source .py file to be detected as revisions in the
|
||||
# versions/ directory
|
||||
# sourceless = false
|
||||
|
||||
# version location specification; This defaults
|
||||
# to alembic/versions. When using multiple version
|
||||
# directories, initial revisions must be specified with --version-path.
|
||||
# The path separator used here should be the separator specified by "version_path_separator" below.
|
||||
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
|
||||
|
||||
# version path separator; As mentioned above, this is the character used to split
|
||||
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
|
||||
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
|
||||
# Valid values for version_path_separator are:
|
||||
#
|
||||
# version_path_separator = :
|
||||
# version_path_separator = ;
|
||||
# version_path_separator = space
|
||||
# version_path_separator = newline
|
||||
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
|
||||
|
||||
# set to 'true' to search source files recursively
|
||||
# in each "version_locations" directory
|
||||
# new in Alembic version 1.10
|
||||
# recursive_version_locations = false
|
||||
|
||||
# the output encoding used when revision files
|
||||
# are written from script.py.mako
|
||||
# output_encoding = utf-8
|
||||
|
||||
# Database URL - will be overridden by env.py from environment variable
|
||||
# Host port is 5433 to avoid conflict with existing Postgres instances
|
||||
sqlalchemy.url = postgresql+asyncpg://orchestrator:orchestrator@localhost:5433/orchestrator
|
||||
|
||||
|
||||
[post_write_hooks]
|
||||
# post_write_hooks defines scripts or Python functions that are run
|
||||
# on newly generated revision scripts. See the documentation for further
|
||||
# detail and examples
|
||||
|
||||
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||
# hooks = black
|
||||
# black.type = console_scripts
|
||||
# black.entrypoint = black
|
||||
# black.options = -q
|
||||
|
||||
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
|
||||
# hooks = ruff
|
||||
# ruff.type = exec
|
||||
# ruff.executable = %(here)s/.venv/bin/ruff
|
||||
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Logging configuration
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
94
letsbe-orchestrator/alembic/env.py
Normal file
94
letsbe-orchestrator/alembic/env.py
Normal file
@@ -0,0 +1,94 @@
|
||||
"""Alembic migration environment configuration for async SQLAlchemy."""
|
||||
|
||||
import asyncio
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import pool
|
||||
from sqlalchemy.engine import Connection
|
||||
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||
|
||||
from app.config import settings
|
||||
from app.models import Base
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
# Override sqlalchemy.url with environment variable
|
||||
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
# This line sets up loggers basically.
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
# add your model's MetaData object here
|
||||
# for 'autogenerate' support
|
||||
target_metadata = Base.metadata
|
||||
|
||||
# other values from the config, defined by the needs of env.py,
|
||||
# can be acquired:
|
||||
# my_important_option = config.get_main_option("my_important_option")
|
||||
# ... etc.
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Run migrations in 'offline' mode.
|
||||
|
||||
This configures the context with just a URL
|
||||
and not an Engine, though an Engine is acceptable
|
||||
here as well. By skipping the Engine creation
|
||||
we don't even need a DBAPI to be available.
|
||||
|
||||
Calls to context.execute() here emit the given string to the
|
||||
script output.
|
||||
"""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def do_run_migrations(connection: Connection) -> None:
|
||||
"""Run migrations with a connection."""
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
compare_type=True,
|
||||
compare_server_default=True,
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def run_async_migrations() -> None:
|
||||
"""Run migrations in 'online' mode with async engine."""
|
||||
connectable = async_engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
async with connectable.connect() as connection:
|
||||
await connection.run_sync(do_run_migrations)
|
||||
|
||||
await connectable.dispose()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""Run migrations in 'online' mode."""
|
||||
asyncio.run(run_async_migrations())
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
26
letsbe-orchestrator/alembic/script.py.mako
Normal file
26
letsbe-orchestrator/alembic/script.py.mako
Normal file
@@ -0,0 +1,26 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
0
letsbe-orchestrator/alembic/versions/.gitkeep
Normal file
0
letsbe-orchestrator/alembic/versions/.gitkeep
Normal file
@@ -0,0 +1,110 @@
|
||||
"""initial_schema
|
||||
|
||||
Revision ID: 4ca4b9958baf
|
||||
Revises:
|
||||
Create Date: 2025-12-02 18:50:17.377481
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '4ca4b9958baf'
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('tenants',
|
||||
sa.Column('name', sa.String(length=255), nullable=False),
|
||||
sa.Column('domain', sa.String(length=255), nullable=True),
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('domain')
|
||||
)
|
||||
op.create_index(op.f('ix_tenants_name'), 'tenants', ['name'], unique=True)
|
||||
op.create_table('agents',
|
||||
sa.Column('tenant_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('name', sa.String(length=255), nullable=False),
|
||||
sa.Column('last_heartbeat', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_agents_tenant_id'), 'agents', ['tenant_id'], unique=False)
|
||||
op.create_table('servers',
|
||||
sa.Column('tenant_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('hostname', sa.String(length=255), nullable=False),
|
||||
sa.Column('ip_address', sa.String(length=45), nullable=True),
|
||||
sa.Column('status', sa.String(length=50), nullable=False),
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_servers_tenant_id'), 'servers', ['tenant_id'], unique=False)
|
||||
op.create_table('tasks',
|
||||
sa.Column('tenant_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('agent_id', sa.Uuid(), nullable=True),
|
||||
sa.Column('type', sa.String(length=100), nullable=False),
|
||||
sa.Column('payload', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||
sa.Column('status', sa.String(length=50), nullable=False),
|
||||
sa.Column('result', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(['agent_id'], ['agents.id'], ondelete='SET NULL'),
|
||||
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_tasks_agent_id'), 'tasks', ['agent_id'], unique=False)
|
||||
op.create_index(op.f('ix_tasks_status'), 'tasks', ['status'], unique=False)
|
||||
op.create_index(op.f('ix_tasks_tenant_id'), 'tasks', ['tenant_id'], unique=False)
|
||||
op.create_index(op.f('ix_tasks_type'), 'tasks', ['type'], unique=False)
|
||||
op.create_table('events',
|
||||
sa.Column('tenant_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('task_id', sa.Uuid(), nullable=True),
|
||||
sa.Column('event_type', sa.String(length=100), nullable=False),
|
||||
sa.Column('payload', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['task_id'], ['tasks.id'], ondelete='SET NULL'),
|
||||
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_events_created_at'), 'events', ['created_at'], unique=False)
|
||||
op.create_index(op.f('ix_events_event_type'), 'events', ['event_type'], unique=False)
|
||||
op.create_index(op.f('ix_events_task_id'), 'events', ['task_id'], unique=False)
|
||||
op.create_index(op.f('ix_events_tenant_id'), 'events', ['tenant_id'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_events_tenant_id'), table_name='events')
|
||||
op.drop_index(op.f('ix_events_task_id'), table_name='events')
|
||||
op.drop_index(op.f('ix_events_event_type'), table_name='events')
|
||||
op.drop_index(op.f('ix_events_created_at'), table_name='events')
|
||||
op.drop_table('events')
|
||||
op.drop_index(op.f('ix_tasks_type'), table_name='tasks')
|
||||
op.drop_index(op.f('ix_tasks_tenant_id'), table_name='tasks')
|
||||
op.drop_index(op.f('ix_tasks_status'), table_name='tasks')
|
||||
op.drop_index(op.f('ix_tasks_agent_id'), table_name='tasks')
|
||||
op.drop_table('tasks')
|
||||
op.drop_index(op.f('ix_servers_tenant_id'), table_name='servers')
|
||||
op.drop_table('servers')
|
||||
op.drop_index(op.f('ix_agents_tenant_id'), table_name='agents')
|
||||
op.drop_table('agents')
|
||||
op.drop_index(op.f('ix_tenants_name'), table_name='tenants')
|
||||
op.drop_table('tenants')
|
||||
# ### end Alembic commands ###
|
||||
48
letsbe-orchestrator/alembic/versions/add_agent_fields.py
Normal file
48
letsbe-orchestrator/alembic/versions/add_agent_fields.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""add_agent_fields_and_nullable_tenant
|
||||
|
||||
Revision ID: add_agent_fields
|
||||
Revises: 4ca4b9958baf
|
||||
Create Date: 2025-12-02 19:30:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'add_agent_fields'
|
||||
down_revision: Union[str, None] = '4ca4b9958baf'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Add new columns to agents table
|
||||
op.add_column('agents', sa.Column('version', sa.String(length=50), nullable=False, server_default=''))
|
||||
op.add_column('agents', sa.Column('status', sa.String(length=20), nullable=False, server_default='offline'))
|
||||
op.add_column('agents', sa.Column('token', sa.Text(), nullable=False, server_default=''))
|
||||
|
||||
# Create index on status for efficient queries
|
||||
op.create_index(op.f('ix_agents_status'), 'agents', ['status'], unique=False)
|
||||
|
||||
# Make tenant_id nullable (agents can register without a tenant)
|
||||
op.alter_column('agents', 'tenant_id',
|
||||
existing_type=sa.UUID(),
|
||||
nullable=True)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Make tenant_id NOT NULL again (will fail if there are rows with NULL tenant_id)
|
||||
op.alter_column('agents', 'tenant_id',
|
||||
existing_type=sa.UUID(),
|
||||
nullable=False)
|
||||
|
||||
# Drop the status index
|
||||
op.drop_index(op.f('ix_agents_status'), table_name='agents')
|
||||
|
||||
# Drop new columns
|
||||
op.drop_column('agents', 'token')
|
||||
op.drop_column('agents', 'status')
|
||||
op.drop_column('agents', 'version')
|
||||
@@ -0,0 +1,36 @@
|
||||
"""add_dashboard_token_hash_to_tenants
|
||||
|
||||
Revision ID: add_dashboard_token_hash
|
||||
Revises: add_registration_tokens
|
||||
Create Date: 2025-01-05 12:00:00.000000
|
||||
|
||||
This migration adds dashboard_token_hash column to tenants table
|
||||
for authenticating dashboard-to-orchestrator communication.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'add_dashboard_token_hash'
|
||||
down_revision: Union[str, None] = 'add_registration_tokens'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
'tenants',
|
||||
sa.Column(
|
||||
'dashboard_token_hash',
|
||||
sa.String(length=64),
|
||||
nullable=True,
|
||||
comment='SHA-256 hash of dashboard authentication token'
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('tenants', 'dashboard_token_hash')
|
||||
@@ -0,0 +1,94 @@
|
||||
"""add_registration_tokens_and_agent_secret_hash
|
||||
|
||||
Revision ID: add_registration_tokens
|
||||
Revises: add_agent_fields
|
||||
Create Date: 2025-12-06 10:00:00.000000
|
||||
|
||||
This migration adds:
|
||||
1. registration_tokens table for secure agent registration
|
||||
2. secret_hash column to agents for new auth scheme
|
||||
3. registration_token_id FK in agents to track token usage
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
import hashlib
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'add_registration_tokens'
|
||||
down_revision: Union[str, None] = 'add_agent_fields'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 1. Create registration_tokens table
|
||||
op.create_table(
|
||||
'registration_tokens',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('tenant_id', sa.UUID(), nullable=False),
|
||||
sa.Column('token_hash', sa.String(length=64), nullable=False, comment='SHA-256 hash of the registration token'),
|
||||
sa.Column('description', sa.String(length=255), nullable=True, comment='Human-readable description for the token'),
|
||||
sa.Column('max_uses', sa.Integer(), nullable=False, server_default='1', comment='Maximum number of uses (0 = unlimited)'),
|
||||
sa.Column('use_count', sa.Integer(), nullable=False, server_default='0', comment='Current number of times this token has been used'),
|
||||
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True, comment='Optional expiration timestamp'),
|
||||
sa.Column('revoked', sa.Boolean(), nullable=False, server_default='false', comment='Whether this token has been manually revoked'),
|
||||
sa.Column('created_by', sa.String(length=255), nullable=True, comment='Identifier of who created this token (for audit)'),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ondelete='CASCADE'),
|
||||
)
|
||||
op.create_index(op.f('ix_registration_tokens_tenant_id'), 'registration_tokens', ['tenant_id'], unique=False)
|
||||
op.create_index(op.f('ix_registration_tokens_token_hash'), 'registration_tokens', ['token_hash'], unique=False)
|
||||
|
||||
# 2. Add secret_hash column to agents (for new auth scheme)
|
||||
# Initialize with empty string, will be populated during agent migration
|
||||
op.add_column(
|
||||
'agents',
|
||||
sa.Column('secret_hash', sa.String(length=64), nullable=False, server_default='', comment='SHA-256 hash of the agent secret')
|
||||
)
|
||||
|
||||
# 3. Add registration_token_id FK to agents
|
||||
op.add_column(
|
||||
'agents',
|
||||
sa.Column('registration_token_id', sa.UUID(), nullable=True)
|
||||
)
|
||||
op.create_foreign_key(
|
||||
'fk_agents_registration_token_id',
|
||||
'agents', 'registration_tokens',
|
||||
['registration_token_id'], ['id'],
|
||||
ondelete='SET NULL'
|
||||
)
|
||||
op.create_index(op.f('ix_agents_registration_token_id'), 'agents', ['registration_token_id'], unique=False)
|
||||
|
||||
# 4. Migrate existing agent tokens to secret_hash
|
||||
# For existing agents, we'll hash their current token and store it as secret_hash
|
||||
# This allows backward compatibility during the transition period
|
||||
connection = op.get_bind()
|
||||
agents = connection.execute(sa.text("SELECT id, token FROM agents WHERE token != ''"))
|
||||
for agent in agents:
|
||||
if agent.token:
|
||||
hashed = hashlib.sha256(agent.token.encode()).hexdigest()
|
||||
connection.execute(
|
||||
sa.text("UPDATE agents SET secret_hash = :hash WHERE id = :id"),
|
||||
{"hash": hashed, "id": agent.id}
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Drop registration_token_id FK and index from agents
|
||||
op.drop_index(op.f('ix_agents_registration_token_id'), table_name='agents')
|
||||
op.drop_constraint('fk_agents_registration_token_id', 'agents', type_='foreignkey')
|
||||
op.drop_column('agents', 'registration_token_id')
|
||||
|
||||
# Drop secret_hash column from agents
|
||||
op.drop_column('agents', 'secret_hash')
|
||||
|
||||
# Drop registration_tokens table
|
||||
op.drop_index(op.f('ix_registration_tokens_token_hash'), table_name='registration_tokens')
|
||||
op.drop_index(op.f('ix_registration_tokens_tenant_id'), table_name='registration_tokens')
|
||||
op.drop_table('registration_tokens')
|
||||
1
letsbe-orchestrator/app/__init__.py
Normal file
1
letsbe-orchestrator/app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# LetsBe Cloud Orchestrator
|
||||
104
letsbe-orchestrator/app/config.py
Normal file
104
letsbe-orchestrator/app/config.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""Application configuration using Pydantic Settings."""
|
||||
|
||||
from functools import lru_cache
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Application settings loaded from environment variables."""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=False,
|
||||
)
|
||||
|
||||
# Database (port 5434 to avoid conflict with Hub Postgres and other services)
|
||||
DATABASE_URL: str = "postgresql+asyncpg://orchestrator:orchestrator@localhost:5434/orchestrator"
|
||||
|
||||
# Application
|
||||
DEBUG: bool = False
|
||||
APP_NAME: str = "LetsBe Orchestrator"
|
||||
APP_VERSION: str = "0.1.0"
|
||||
|
||||
# Connection pool settings
|
||||
DB_POOL_SIZE: int = 5
|
||||
DB_MAX_OVERFLOW: int = 10
|
||||
DB_POOL_TIMEOUT: int = 30
|
||||
DB_POOL_RECYCLE: int = 1800
|
||||
|
||||
# Authentication
|
||||
# Admin API key for protected endpoints (registration token management)
|
||||
# MUST be set via ADMIN_API_KEY environment variable
|
||||
ADMIN_API_KEY: str = Field(
|
||||
description="API key for admin endpoints. MUST be set via ADMIN_API_KEY env var.",
|
||||
)
|
||||
|
||||
# ============================================================
|
||||
# LOCAL MODE SETTINGS
|
||||
# When LOCAL_MODE=true, orchestrator runs in single-tenant mode
|
||||
# with automatic tenant bootstrap on startup.
|
||||
# When LOCAL_MODE=false (default), multi-tenant behavior is unchanged.
|
||||
# ============================================================
|
||||
LOCAL_MODE: bool = Field(
|
||||
default=False,
|
||||
description="Enable single-tenant local mode. When true, auto-creates tenant on startup.",
|
||||
)
|
||||
|
||||
# Instance identification (from Hub activation)
|
||||
INSTANCE_ID: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Unique instance identifier from Hub activation. Required in LOCAL_MODE.",
|
||||
)
|
||||
|
||||
# Hub integration for telemetry (optional)
|
||||
HUB_URL: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Hub API URL for telemetry. Optional even in LOCAL_MODE.",
|
||||
)
|
||||
HUB_API_KEY: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Hub API key for telemetry authentication. Required if HUB_URL is set.",
|
||||
)
|
||||
HUB_TELEMETRY_ENABLED: bool = Field(
|
||||
default=False,
|
||||
description="Whether to send telemetry to Hub. Requires HUB_URL and HUB_API_KEY.",
|
||||
)
|
||||
HUB_TELEMETRY_INTERVAL_SECONDS: int = Field(
|
||||
default=60,
|
||||
ge=10,
|
||||
le=600,
|
||||
description="Interval between telemetry submissions in seconds.",
|
||||
)
|
||||
|
||||
# Local tenant settings (used when LOCAL_MODE=true)
|
||||
LOCAL_TENANT_DOMAIN: str = Field(
|
||||
default="local.letsbe.cloud",
|
||||
description="Domain for auto-created local tenant.",
|
||||
)
|
||||
|
||||
# CORS
|
||||
CORS_ALLOWED_ORIGINS: str = Field(
|
||||
default="",
|
||||
description="Comma-separated list of allowed CORS origins. Empty disables CORS.",
|
||||
)
|
||||
|
||||
# Dedicated key for local agent registration (Phase 2)
|
||||
# More restrictive than ADMIN_API_KEY - can ONLY register the local agent
|
||||
LOCAL_AGENT_KEY: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Key for local agent registration. Required when LOCAL_MODE=true.",
|
||||
)
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
"""Get cached settings instance."""
|
||||
return Settings()
|
||||
|
||||
|
||||
# For backward compatibility
|
||||
settings = get_settings()
|
||||
52
letsbe-orchestrator/app/db.py
Normal file
52
letsbe-orchestrator/app/db.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""Database configuration and session management."""
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncSession,
|
||||
async_sessionmaker,
|
||||
create_async_engine,
|
||||
)
|
||||
|
||||
from app.config import settings
|
||||
|
||||
# Create async engine with connection pooling
|
||||
engine = create_async_engine(
|
||||
settings.DATABASE_URL,
|
||||
pool_size=settings.DB_POOL_SIZE,
|
||||
max_overflow=settings.DB_MAX_OVERFLOW,
|
||||
pool_timeout=settings.DB_POOL_TIMEOUT,
|
||||
pool_recycle=settings.DB_POOL_RECYCLE,
|
||||
echo=settings.DEBUG,
|
||||
)
|
||||
|
||||
# Create async session factory
|
||||
async_session_maker = async_sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
autocommit=False,
|
||||
autoflush=False,
|
||||
)
|
||||
|
||||
|
||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""
|
||||
Dependency that provides an async database session.
|
||||
|
||||
Yields a session and ensures proper cleanup via finally block.
|
||||
"""
|
||||
async with async_session_maker() as session:
|
||||
try:
|
||||
yield session
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
# Type alias for dependency injection
|
||||
AsyncSessionDep = Annotated[AsyncSession, Depends(get_db)]
|
||||
19
letsbe-orchestrator/app/dependencies/__init__.py
Normal file
19
letsbe-orchestrator/app/dependencies/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
||||
"""FastAPI dependencies for the Orchestrator."""
|
||||
|
||||
from app.dependencies.auth import (
|
||||
CurrentAgentDep,
|
||||
get_current_agent,
|
||||
)
|
||||
from app.dependencies.admin_auth import AdminAuthDep, verify_admin_api_key
|
||||
from app.dependencies.dashboard_auth import DashboardAuthDep, verify_dashboard_token
|
||||
from app.dependencies.local_agent_auth import verify_local_agent_key
|
||||
|
||||
__all__ = [
|
||||
"CurrentAgentDep",
|
||||
"get_current_agent",
|
||||
"AdminAuthDep",
|
||||
"verify_admin_api_key",
|
||||
"DashboardAuthDep",
|
||||
"verify_dashboard_token",
|
||||
"verify_local_agent_key",
|
||||
]
|
||||
33
letsbe-orchestrator/app/dependencies/admin_auth.py
Normal file
33
letsbe-orchestrator/app/dependencies/admin_auth.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""Admin authentication dependency for protected endpoints."""
|
||||
|
||||
import secrets
|
||||
|
||||
from fastapi import Depends, Header, HTTPException, status
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
|
||||
async def verify_admin_api_key(
|
||||
x_admin_api_key: str = Header(..., alias="X-Admin-Api-Key"),
|
||||
) -> None:
|
||||
"""
|
||||
Verify admin API key for protected endpoints.
|
||||
|
||||
Used to protect sensitive operations like registration token management.
|
||||
|
||||
Raises:
|
||||
HTTPException: 401 if API key is missing or invalid
|
||||
"""
|
||||
settings = get_settings()
|
||||
|
||||
# Use timing-safe comparison to prevent timing attacks
|
||||
if not secrets.compare_digest(x_admin_api_key, settings.ADMIN_API_KEY):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid admin API key",
|
||||
headers={"WWW-Authenticate": "ApiKey"},
|
||||
)
|
||||
|
||||
|
||||
# Dependency that can be used in route decorators
|
||||
AdminAuthDep = Depends(verify_admin_api_key)
|
||||
65
letsbe-orchestrator/app/dependencies/auth.py
Normal file
65
letsbe-orchestrator/app/dependencies/auth.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""Agent authentication dependencies."""
|
||||
|
||||
import hashlib
|
||||
import secrets
|
||||
import uuid
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, Header, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db import AsyncSessionDep
|
||||
from app.models.agent import Agent
|
||||
|
||||
|
||||
async def get_current_agent(
|
||||
db: AsyncSessionDep,
|
||||
x_agent_id: str = Header(..., alias="X-Agent-Id"),
|
||||
x_agent_secret: str = Header(..., alias="X-Agent-Secret"),
|
||||
) -> Agent:
|
||||
"""
|
||||
Validate agent credentials using X-Agent-Id/X-Agent-Secret headers.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
x_agent_id: Agent UUID from X-Agent-Id header
|
||||
x_agent_secret: Agent secret from X-Agent-Secret header
|
||||
|
||||
Returns:
|
||||
Agent if credentials are valid
|
||||
|
||||
Raises:
|
||||
HTTPException: 401 if credentials are invalid
|
||||
"""
|
||||
# Parse agent ID
|
||||
try:
|
||||
agent_id = uuid.UUID(x_agent_id)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid Agent ID format",
|
||||
)
|
||||
|
||||
# Look up agent
|
||||
result = await db.execute(select(Agent).where(Agent.id == agent_id))
|
||||
agent = result.scalar_one_or_none()
|
||||
|
||||
if agent is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid agent credentials",
|
||||
)
|
||||
|
||||
# Verify secret using timing-safe comparison
|
||||
provided_hash = hashlib.sha256(x_agent_secret.encode()).hexdigest()
|
||||
if not secrets.compare_digest(agent.secret_hash, provided_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid agent credentials",
|
||||
)
|
||||
|
||||
return agent
|
||||
|
||||
|
||||
# Type alias for dependency injection
|
||||
CurrentAgentDep = Annotated[Agent, Depends(get_current_agent)]
|
||||
73
letsbe-orchestrator/app/dependencies/dashboard_auth.py
Normal file
73
letsbe-orchestrator/app/dependencies/dashboard_auth.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""Dashboard authentication dependency for tenant dashboard-to-orchestrator communication."""
|
||||
|
||||
import hashlib
|
||||
import secrets
|
||||
from typing import Annotated
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import Depends, Header, HTTPException, Path, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db import get_db
|
||||
from app.models.tenant import Tenant
|
||||
|
||||
|
||||
async def verify_dashboard_token(
|
||||
tenant_id: Annotated[UUID, Path(...)],
|
||||
x_dashboard_token: Annotated[str, Header(alias="X-Dashboard-Token")],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Tenant:
|
||||
"""
|
||||
Verify per-tenant dashboard token for tenant dashboard endpoints.
|
||||
|
||||
The dashboard token is used by the tenant's dashboard application
|
||||
(Hub Dashboard or Control Panel) to authenticate requests to the
|
||||
Orchestrator for task execution and status queries.
|
||||
|
||||
Args:
|
||||
tenant_id: The tenant UUID from the path
|
||||
x_dashboard_token: The raw dashboard token from header
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
The verified Tenant object
|
||||
|
||||
Raises:
|
||||
HTTPException: 401 if token is missing, invalid, or tenant not found
|
||||
"""
|
||||
# Fetch tenant
|
||||
result = await db.execute(
|
||||
select(Tenant).where(Tenant.id == tenant_id)
|
||||
)
|
||||
tenant = result.scalar_one_or_none()
|
||||
|
||||
if not tenant:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Tenant not found",
|
||||
)
|
||||
|
||||
if not tenant.dashboard_token_hash:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Dashboard token not configured for this tenant",
|
||||
headers={"WWW-Authenticate": "DashboardToken"},
|
||||
)
|
||||
|
||||
# Compute SHA-256 hash of provided token
|
||||
provided_hash = hashlib.sha256(x_dashboard_token.encode()).hexdigest()
|
||||
|
||||
# Use timing-safe comparison to prevent timing attacks
|
||||
if not secrets.compare_digest(tenant.dashboard_token_hash, provided_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid dashboard token",
|
||||
headers={"WWW-Authenticate": "DashboardToken"},
|
||||
)
|
||||
|
||||
return tenant
|
||||
|
||||
|
||||
# Type alias for dependency injection
|
||||
DashboardAuthDep = Annotated[Tenant, Depends(verify_dashboard_token)]
|
||||
49
letsbe-orchestrator/app/dependencies/local_agent_auth.py
Normal file
49
letsbe-orchestrator/app/dependencies/local_agent_auth.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""Local agent authentication dependency for LOCAL_MODE registration."""
|
||||
|
||||
import secrets
|
||||
|
||||
from fastapi import Header, HTTPException, status
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
|
||||
async def verify_local_agent_key(
|
||||
x_local_agent_key: str = Header(..., alias="X-Local-Agent-Key"),
|
||||
) -> None:
|
||||
"""
|
||||
Verify LOCAL_AGENT_KEY for local agent registration.
|
||||
|
||||
This is a narrow-scope credential that can ONLY register the local agent.
|
||||
It is NOT the same as ADMIN_API_KEY.
|
||||
|
||||
HTTP Status Codes:
|
||||
- 404: LOCAL_MODE is false (endpoint hidden by design)
|
||||
- 401: LOCAL_AGENT_KEY is missing, invalid, or not configured
|
||||
|
||||
Raises:
|
||||
HTTPException: 404 if not in LOCAL_MODE, 401 if key is invalid
|
||||
"""
|
||||
settings = get_settings()
|
||||
|
||||
# Endpoint only exists in LOCAL_MODE (security by obscurity)
|
||||
if not settings.LOCAL_MODE:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Not found",
|
||||
)
|
||||
|
||||
# LOCAL_AGENT_KEY must be configured
|
||||
if not settings.LOCAL_AGENT_KEY:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Local agent key not configured",
|
||||
headers={"WWW-Authenticate": "ApiKey"},
|
||||
)
|
||||
|
||||
# Use timing-safe comparison to prevent timing attacks
|
||||
if not secrets.compare_digest(x_local_agent_key, settings.LOCAL_AGENT_KEY):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid local agent key",
|
||||
headers={"WWW-Authenticate": "ApiKey"},
|
||||
)
|
||||
163
letsbe-orchestrator/app/main.py
Normal file
163
letsbe-orchestrator/app/main.py
Normal file
@@ -0,0 +1,163 @@
|
||||
"""FastAPI application entry point."""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from slowapi import Limiter, _rate_limit_exceeded_handler
|
||||
from slowapi.errors import RateLimitExceeded
|
||||
from slowapi.util import get_remote_address
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
from app.config import settings
|
||||
from app.db import engine
|
||||
from app.routes import (
|
||||
agents_router,
|
||||
env_router,
|
||||
events_router,
|
||||
files_router,
|
||||
health_router,
|
||||
meta_router,
|
||||
playbooks_router,
|
||||
registration_tokens_router,
|
||||
tasks_router,
|
||||
tenants_router,
|
||||
)
|
||||
from app.services.hub_telemetry import HubTelemetryService
|
||||
from app.services.local_bootstrap import LocalBootstrapService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# --- Middleware ---
|
||||
|
||||
|
||||
class RequestIDMiddleware(BaseHTTPMiddleware):
|
||||
"""Middleware that adds a unique request ID to each request."""
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
request_id = str(uuid.uuid4())
|
||||
request.state.request_id = request_id
|
||||
response = await call_next(request)
|
||||
response.headers["X-Request-ID"] = request_id
|
||||
return response
|
||||
|
||||
|
||||
# --- Lifespan ---
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
"""Application lifespan handler for startup and shutdown."""
|
||||
# Startup
|
||||
logger.info(f"Starting {settings.APP_NAME} v{settings.APP_VERSION}")
|
||||
logger.info(f"LOCAL_MODE={settings.LOCAL_MODE}")
|
||||
|
||||
# Run local bootstrap if LOCAL_MODE is enabled
|
||||
# This is migration-safe: handles missing tables gracefully
|
||||
if settings.LOCAL_MODE:
|
||||
await LocalBootstrapService.run()
|
||||
|
||||
# Start Hub telemetry service (if enabled via HUB_TELEMETRY_ENABLED)
|
||||
# This runs in background and never blocks startup
|
||||
await HubTelemetryService.start()
|
||||
|
||||
yield
|
||||
|
||||
# Shutdown
|
||||
logger.info("Shutting down...")
|
||||
await HubTelemetryService.stop()
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
# --- Application ---
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title=settings.APP_NAME,
|
||||
version=settings.APP_VERSION,
|
||||
description="Control-plane backend for the LetsBe Cloud platform",
|
||||
lifespan=lifespan,
|
||||
docs_url="/docs" if settings.DEBUG else None,
|
||||
redoc_url="/redoc" if settings.DEBUG else None,
|
||||
)
|
||||
|
||||
|
||||
# Rate limiting
|
||||
limiter = Limiter(key_func=get_remote_address)
|
||||
app.state.limiter = limiter
|
||||
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def normalize_trailing_slashes(request: Request, call_next):
|
||||
"""Strip trailing slashes from URLs to normalize routing."""
|
||||
if request.url.path != "/" and request.url.path.endswith("/"):
|
||||
# Modify the scope to remove trailing slash
|
||||
request.scope["path"] = request.url.path.rstrip("/")
|
||||
return await call_next(request)
|
||||
|
||||
# Add middleware
|
||||
app.add_middleware(RequestIDMiddleware)
|
||||
|
||||
# Add CORS middleware if origins are configured
|
||||
if settings.CORS_ALLOWED_ORIGINS:
|
||||
origins = [o.strip() for o in settings.CORS_ALLOWED_ORIGINS.split(",")]
|
||||
else:
|
||||
origins = []
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["GET", "POST", "PATCH", "DELETE"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
# --- Exception Handlers ---
|
||||
|
||||
|
||||
@app.exception_handler(IntegrityError)
|
||||
async def integrity_error_handler(request: Request, exc: IntegrityError) -> JSONResponse:
|
||||
"""Handle database integrity errors (unique constraint violations, etc.)."""
|
||||
return JSONResponse(
|
||||
status_code=409,
|
||||
content={
|
||||
"detail": "Resource conflict: a record with these values already exists",
|
||||
"request_id": getattr(request.state, "request_id", None),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# --- Routers ---
|
||||
|
||||
|
||||
app.include_router(health_router)
|
||||
app.include_router(meta_router, prefix="/api/v1")
|
||||
app.include_router(tenants_router, prefix="/api/v1")
|
||||
app.include_router(tasks_router, prefix="/api/v1")
|
||||
app.include_router(agents_router, prefix="/api/v1")
|
||||
app.include_router(playbooks_router, prefix="/api/v1")
|
||||
app.include_router(env_router, prefix="/api/v1")
|
||||
app.include_router(files_router, prefix="/api/v1")
|
||||
app.include_router(registration_tokens_router, prefix="/api/v1")
|
||||
app.include_router(events_router, prefix="/api/v1")
|
||||
|
||||
|
||||
# --- Root endpoint ---
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
"""Root endpoint redirecting to docs."""
|
||||
return {
|
||||
"message": f"Welcome to {settings.APP_NAME}",
|
||||
"docs": "/docs",
|
||||
"health": "/health",
|
||||
}
|
||||
21
letsbe-orchestrator/app/models/__init__.py
Normal file
21
letsbe-orchestrator/app/models/__init__.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""SQLAlchemy models for the Orchestrator."""
|
||||
|
||||
from app.models.base import Base
|
||||
from app.models.tenant import Tenant
|
||||
from app.models.server import Server
|
||||
from app.models.task import Task, TaskStatus
|
||||
from app.models.agent import Agent, AgentStatus
|
||||
from app.models.event import Event
|
||||
from app.models.registration_token import RegistrationToken
|
||||
|
||||
__all__ = [
|
||||
"Base",
|
||||
"Tenant",
|
||||
"Server",
|
||||
"Task",
|
||||
"TaskStatus",
|
||||
"Agent",
|
||||
"AgentStatus",
|
||||
"Event",
|
||||
"RegistrationToken",
|
||||
]
|
||||
92
letsbe-orchestrator/app/models/agent.py
Normal file
92
letsbe-orchestrator/app/models/agent.py
Normal file
@@ -0,0 +1,92 @@
|
||||
"""Agent model for SysAdmin automation workers."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base, TimestampMixin, UUIDMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.registration_token import RegistrationToken
|
||||
from app.models.task import Task
|
||||
from app.models.tenant import Tenant
|
||||
|
||||
|
||||
class AgentStatus(str, Enum):
|
||||
"""Agent status values."""
|
||||
|
||||
ONLINE = "online"
|
||||
OFFLINE = "offline"
|
||||
INVALID = "invalid" # Agent with NULL tenant_id, must re-register
|
||||
|
||||
|
||||
class Agent(UUIDMixin, TimestampMixin, Base):
|
||||
"""
|
||||
Agent model representing a SysAdmin automation worker.
|
||||
|
||||
Agents register with the orchestrator and receive tasks to execute.
|
||||
"""
|
||||
|
||||
__tablename__ = "agents"
|
||||
|
||||
tenant_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
name: Mapped[str] = mapped_column(
|
||||
String(255),
|
||||
nullable=False,
|
||||
)
|
||||
version: Mapped[str] = mapped_column(
|
||||
String(50),
|
||||
nullable=False,
|
||||
default="",
|
||||
)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
nullable=False,
|
||||
default=AgentStatus.OFFLINE.value,
|
||||
index=True,
|
||||
)
|
||||
last_heartbeat: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
)
|
||||
# Legacy field - kept for backward compatibility during migration
|
||||
# Will be removed after all agents migrate to new auth scheme
|
||||
token: Mapped[str] = mapped_column(
|
||||
Text,
|
||||
nullable=False,
|
||||
default="",
|
||||
)
|
||||
# New secure credential storage - SHA-256 hash of agent secret
|
||||
secret_hash: Mapped[str] = mapped_column(
|
||||
String(64),
|
||||
nullable=False,
|
||||
default="",
|
||||
comment="SHA-256 hash of the agent secret",
|
||||
)
|
||||
# Reference to the registration token used to create this agent
|
||||
registration_token_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
ForeignKey("registration_tokens.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# Relationships
|
||||
tenant: Mapped["Tenant | None"] = relationship(
|
||||
back_populates="agents",
|
||||
)
|
||||
tasks: Mapped[list["Task"]] = relationship(
|
||||
back_populates="agent",
|
||||
lazy="selectin",
|
||||
)
|
||||
registration_token: Mapped["RegistrationToken | None"] = relationship()
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Agent(id={self.id}, name={self.name}, status={self.status})>"
|
||||
44
letsbe-orchestrator/app/models/base.py
Normal file
44
letsbe-orchestrator/app/models/base.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""Base model and mixins for SQLAlchemy ORM."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import DateTime
|
||||
from sqlalchemy.ext.asyncio import AsyncAttrs
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
|
||||
def utc_now() -> datetime:
|
||||
"""Return current UTC datetime."""
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class Base(AsyncAttrs, DeclarativeBase):
|
||||
"""Base class for all SQLAlchemy models."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class UUIDMixin:
|
||||
"""Mixin that adds a UUID primary key."""
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
primary_key=True,
|
||||
default=uuid.uuid4,
|
||||
)
|
||||
|
||||
|
||||
class TimestampMixin:
|
||||
"""Mixin that adds created_at and updated_at timestamps."""
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=utc_now,
|
||||
nullable=False,
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=utc_now,
|
||||
onupdate=utc_now,
|
||||
nullable=False,
|
||||
)
|
||||
72
letsbe-orchestrator/app/models/event.py
Normal file
72
letsbe-orchestrator/app/models/event.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""Event model for audit logging."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, JSON, String
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
# Use JSONB on PostgreSQL, JSON on other databases (SQLite for tests)
|
||||
JSONType = JSON().with_variant(JSONB, "postgresql")
|
||||
|
||||
from app.models.base import Base, UUIDMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.task import Task
|
||||
from app.models.tenant import Tenant
|
||||
|
||||
|
||||
def utc_now() -> datetime:
|
||||
"""Return current UTC datetime."""
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class Event(UUIDMixin, Base):
|
||||
"""
|
||||
Event model for audit logging and activity tracking.
|
||||
|
||||
Events are immutable records of system activity.
|
||||
Only has created_at (no updated_at since events are immutable).
|
||||
"""
|
||||
|
||||
__tablename__ = "events"
|
||||
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
task_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
ForeignKey("tasks.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
event_type: Mapped[str] = mapped_column(
|
||||
String(100),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
payload: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSONType,
|
||||
nullable=False,
|
||||
default=dict,
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=utc_now,
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# Relationships
|
||||
tenant: Mapped["Tenant"] = relationship(
|
||||
back_populates="events",
|
||||
)
|
||||
task: Mapped["Task | None"] = relationship(
|
||||
back_populates="events",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Event(id={self.id}, type={self.event_type})>"
|
||||
101
letsbe-orchestrator/app/models/registration_token.py
Normal file
101
letsbe-orchestrator/app/models/registration_token.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""Registration token model for secure agent registration."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base, TimestampMixin, UUIDMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.tenant import Tenant
|
||||
|
||||
|
||||
class RegistrationToken(UUIDMixin, TimestampMixin, Base):
|
||||
"""
|
||||
Registration token for secure agent registration.
|
||||
|
||||
Tokens are pre-provisioned by admins and map to specific tenants.
|
||||
Agents use these tokens during initial registration to:
|
||||
1. Authenticate the registration request
|
||||
2. Associate themselves with the correct tenant
|
||||
|
||||
Tokens can be:
|
||||
- Single-use (max_uses=1, default)
|
||||
- Limited-use (max_uses > 1)
|
||||
- Unlimited (max_uses=0)
|
||||
- Time-limited (expires_at set)
|
||||
- Manually revoked (revoked=True)
|
||||
"""
|
||||
|
||||
__tablename__ = "registration_tokens"
|
||||
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
token_hash: Mapped[str] = mapped_column(
|
||||
String(64),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="SHA-256 hash of the registration token",
|
||||
)
|
||||
description: Mapped[str | None] = mapped_column(
|
||||
String(255),
|
||||
nullable=True,
|
||||
comment="Human-readable description for the token",
|
||||
)
|
||||
max_uses: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
default=1,
|
||||
comment="Maximum number of uses (0 = unlimited)",
|
||||
)
|
||||
use_count: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
default=0,
|
||||
comment="Current number of times this token has been used",
|
||||
)
|
||||
expires_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
comment="Optional expiration timestamp",
|
||||
)
|
||||
revoked: Mapped[bool] = mapped_column(
|
||||
Boolean,
|
||||
nullable=False,
|
||||
default=False,
|
||||
comment="Whether this token has been manually revoked",
|
||||
)
|
||||
created_by: Mapped[str | None] = mapped_column(
|
||||
String(255),
|
||||
nullable=True,
|
||||
comment="Identifier of who created this token (for audit)",
|
||||
)
|
||||
|
||||
# Relationships
|
||||
tenant: Mapped["Tenant"] = relationship(
|
||||
back_populates="registration_tokens",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<RegistrationToken(id={self.id}, tenant_id={self.tenant_id}, uses={self.use_count}/{self.max_uses})>"
|
||||
|
||||
def is_valid(self, now: datetime | None = None) -> bool:
|
||||
"""Check if the token can still be used for registration."""
|
||||
from app.models.base import utc_now
|
||||
|
||||
if now is None:
|
||||
now = utc_now()
|
||||
|
||||
if self.revoked:
|
||||
return False
|
||||
if self.expires_at is not None and self.expires_at < now:
|
||||
return False
|
||||
if self.max_uses > 0 and self.use_count >= self.max_uses:
|
||||
return False
|
||||
return True
|
||||
59
letsbe-orchestrator/app/models/server.py
Normal file
59
letsbe-orchestrator/app/models/server.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""Server model for provisioned infrastructure."""
|
||||
|
||||
import uuid
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import ForeignKey, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base, TimestampMixin, UUIDMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.tenant import Tenant
|
||||
|
||||
|
||||
class ServerStatus(str, Enum):
|
||||
"""Server provisioning status."""
|
||||
|
||||
PROVISIONING = "provisioning"
|
||||
READY = "ready"
|
||||
ERROR = "error"
|
||||
TERMINATED = "terminated"
|
||||
|
||||
|
||||
class Server(UUIDMixin, TimestampMixin, Base):
|
||||
"""
|
||||
Server model representing a provisioned VM or container.
|
||||
|
||||
Tracks provisioning state and network configuration.
|
||||
"""
|
||||
|
||||
__tablename__ = "servers"
|
||||
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
hostname: Mapped[str] = mapped_column(
|
||||
String(255),
|
||||
nullable=False,
|
||||
)
|
||||
ip_address: Mapped[str | None] = mapped_column(
|
||||
String(45), # Supports IPv6
|
||||
nullable=True,
|
||||
)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(50),
|
||||
default=ServerStatus.PROVISIONING.value,
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# Relationships
|
||||
tenant: Mapped["Tenant"] = relationship(
|
||||
back_populates="servers",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Server(id={self.id}, hostname={self.hostname}, status={self.status})>"
|
||||
85
letsbe-orchestrator/app/models/task.py
Normal file
85
letsbe-orchestrator/app/models/task.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""Task model for orchestration jobs."""
|
||||
|
||||
import uuid
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from sqlalchemy import ForeignKey, JSON, String
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
# Use JSONB on PostgreSQL, JSON on other databases (SQLite for tests)
|
||||
JSONType = JSON().with_variant(JSONB, "postgresql")
|
||||
|
||||
from app.models.base import Base, TimestampMixin, UUIDMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.agent import Agent
|
||||
from app.models.event import Event
|
||||
from app.models.tenant import Tenant
|
||||
|
||||
|
||||
class TaskStatus(str, Enum):
|
||||
"""Task execution status."""
|
||||
|
||||
PENDING = "pending"
|
||||
RUNNING = "running"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class Task(UUIDMixin, TimestampMixin, Base):
|
||||
"""
|
||||
Task model representing an orchestration job.
|
||||
|
||||
Tasks are assigned to agents and track execution state.
|
||||
Payload and result use JSONB for flexible, queryable storage.
|
||||
"""
|
||||
|
||||
__tablename__ = "tasks"
|
||||
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
agent_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
ForeignKey("agents.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
type: Mapped[str] = mapped_column(
|
||||
String(100),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
payload: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSONType,
|
||||
nullable=False,
|
||||
default=dict,
|
||||
)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(50),
|
||||
default=TaskStatus.PENDING.value,
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
result: Mapped[dict[str, Any] | None] = mapped_column(
|
||||
JSONType,
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
# Relationships
|
||||
tenant: Mapped["Tenant"] = relationship(
|
||||
back_populates="tasks",
|
||||
)
|
||||
agent: Mapped["Agent | None"] = relationship(
|
||||
back_populates="tasks",
|
||||
)
|
||||
events: Mapped[list["Event"]] = relationship(
|
||||
back_populates="task",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Task(id={self.id}, type={self.type}, status={self.status})>"
|
||||
67
letsbe-orchestrator/app/models/tenant.py
Normal file
67
letsbe-orchestrator/app/models/tenant.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""Tenant model for multi-tenancy support."""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base, TimestampMixin, UUIDMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.agent import Agent
|
||||
from app.models.event import Event
|
||||
from app.models.registration_token import RegistrationToken
|
||||
from app.models.server import Server
|
||||
from app.models.task import Task
|
||||
|
||||
|
||||
class Tenant(UUIDMixin, TimestampMixin, Base):
|
||||
"""
|
||||
Tenant model representing a customer organization.
|
||||
|
||||
Each tenant has isolated servers, tasks, agents, and events.
|
||||
"""
|
||||
|
||||
__tablename__ = "tenants"
|
||||
|
||||
name: Mapped[str] = mapped_column(
|
||||
String(255),
|
||||
unique=True,
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
domain: Mapped[str | None] = mapped_column(
|
||||
String(255),
|
||||
unique=True,
|
||||
nullable=True,
|
||||
)
|
||||
dashboard_token_hash: Mapped[str | None] = mapped_column(
|
||||
String(64), # SHA-256 hex = 64 characters
|
||||
nullable=True,
|
||||
comment="SHA-256 hash of dashboard authentication token",
|
||||
)
|
||||
|
||||
# Relationships
|
||||
servers: Mapped[list["Server"]] = relationship(
|
||||
back_populates="tenant",
|
||||
lazy="selectin",
|
||||
)
|
||||
tasks: Mapped[list["Task"]] = relationship(
|
||||
back_populates="tenant",
|
||||
lazy="selectin",
|
||||
)
|
||||
agents: Mapped[list["Agent"]] = relationship(
|
||||
back_populates="tenant",
|
||||
lazy="selectin",
|
||||
)
|
||||
events: Mapped[list["Event"]] = relationship(
|
||||
back_populates="tenant",
|
||||
lazy="selectin",
|
||||
)
|
||||
registration_tokens: Mapped[list["RegistrationToken"]] = relationship(
|
||||
back_populates="tenant",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Tenant(id={self.id}, name={self.name})>"
|
||||
74
letsbe-orchestrator/app/playbooks/__init__.py
Normal file
74
letsbe-orchestrator/app/playbooks/__init__.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""Playbooks module for infrastructure automation tasks.
|
||||
|
||||
Playbooks define reusable sequences of steps (COMPOSITE tasks) for
|
||||
deploying and configuring services on tenant servers.
|
||||
"""
|
||||
|
||||
from app.playbooks.chatwoot import (
|
||||
CompositeStep,
|
||||
build_chatwoot_setup_steps,
|
||||
create_chatwoot_setup_task,
|
||||
)
|
||||
from app.playbooks.nextcloud import (
|
||||
build_nextcloud_set_domain_steps,
|
||||
create_nextcloud_set_domain_task,
|
||||
)
|
||||
from app.playbooks.keycloak import (
|
||||
build_keycloak_setup_steps,
|
||||
create_keycloak_setup_task,
|
||||
)
|
||||
from app.playbooks.n8n import (
|
||||
build_n8n_setup_steps,
|
||||
create_n8n_setup_task,
|
||||
)
|
||||
from app.playbooks.calcom import (
|
||||
build_calcom_setup_steps,
|
||||
create_calcom_setup_task,
|
||||
)
|
||||
from app.playbooks.umami import (
|
||||
build_umami_setup_steps,
|
||||
create_umami_setup_task,
|
||||
)
|
||||
from app.playbooks.uptime_kuma import (
|
||||
build_uptime_kuma_setup_steps,
|
||||
create_uptime_kuma_setup_task,
|
||||
)
|
||||
from app.playbooks.vaultwarden import (
|
||||
build_vaultwarden_setup_steps,
|
||||
create_vaultwarden_setup_task,
|
||||
)
|
||||
from app.playbooks.portainer import (
|
||||
build_portainer_setup_steps,
|
||||
create_portainer_setup_task,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"CompositeStep",
|
||||
# Chatwoot
|
||||
"build_chatwoot_setup_steps",
|
||||
"create_chatwoot_setup_task",
|
||||
# Nextcloud
|
||||
"build_nextcloud_set_domain_steps",
|
||||
"create_nextcloud_set_domain_task",
|
||||
# Keycloak
|
||||
"build_keycloak_setup_steps",
|
||||
"create_keycloak_setup_task",
|
||||
# n8n
|
||||
"build_n8n_setup_steps",
|
||||
"create_n8n_setup_task",
|
||||
# Cal.com
|
||||
"build_calcom_setup_steps",
|
||||
"create_calcom_setup_task",
|
||||
# Umami
|
||||
"build_umami_setup_steps",
|
||||
"create_umami_setup_task",
|
||||
# Uptime Kuma
|
||||
"build_uptime_kuma_setup_steps",
|
||||
"create_uptime_kuma_setup_task",
|
||||
# Vaultwarden
|
||||
"build_vaultwarden_setup_steps",
|
||||
"create_vaultwarden_setup_task",
|
||||
# Portainer
|
||||
"build_portainer_setup_steps",
|
||||
"create_portainer_setup_task",
|
||||
]
|
||||
207
letsbe-orchestrator/app/playbooks/calcom.py
Normal file
207
letsbe-orchestrator/app/playbooks/calcom.py
Normal file
@@ -0,0 +1,207 @@
|
||||
"""Cal.com scheduling deployment playbook.
|
||||
|
||||
Defines the steps required to:
|
||||
1. Set up Cal.com on a tenant server (ENV_UPDATE + DOCKER_RELOAD)
|
||||
2. Perform initial setup via Playwright automation (create admin account)
|
||||
|
||||
Tenant servers must have stacks and env templates under /opt/letsbe.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.task import Task, TaskStatus
|
||||
|
||||
|
||||
class CompositeStep(BaseModel):
|
||||
"""A single step in a composite playbook."""
|
||||
|
||||
type: str = Field(..., description="Task type (e.g., ENV_UPDATE, DOCKER_RELOAD)")
|
||||
payload: dict[str, Any] = Field(
|
||||
default_factory=dict, description="Payload for this step"
|
||||
)
|
||||
|
||||
|
||||
# LetsBe standard paths
|
||||
CALCOM_ENV_PATH = "/opt/letsbe/env/calcom.env"
|
||||
CALCOM_STACK_DIR = "/opt/letsbe/stacks/calcom"
|
||||
|
||||
|
||||
def build_calcom_setup_steps(*, domain: str) -> list[CompositeStep]:
|
||||
"""
|
||||
Build the sequence of steps required to set up Cal.com.
|
||||
|
||||
Assumes the env file already exists at /opt/letsbe/env/calcom.env
|
||||
(created by provisioning/env_setup.sh).
|
||||
|
||||
Args:
|
||||
domain: The domain for Cal.com (e.g., "cal.example.com")
|
||||
|
||||
Returns:
|
||||
List of 2 CompositeStep objects:
|
||||
1. ENV_UPDATE - patches NEXT_PUBLIC_WEBAPP_URL, NEXTAUTH_URL
|
||||
2. DOCKER_RELOAD - restarts the calcom stack with pull=True
|
||||
"""
|
||||
steps = [
|
||||
# Step 1: Update environment variables
|
||||
CompositeStep(
|
||||
type="ENV_UPDATE",
|
||||
payload={
|
||||
"path": CALCOM_ENV_PATH,
|
||||
"updates": {
|
||||
"NEXT_PUBLIC_WEBAPP_URL": f"https://{domain}",
|
||||
"NEXTAUTH_URL": f"https://{domain}",
|
||||
},
|
||||
},
|
||||
),
|
||||
# Step 2: Reload Docker stack
|
||||
CompositeStep(
|
||||
type="DOCKER_RELOAD",
|
||||
payload={
|
||||
"compose_dir": CALCOM_STACK_DIR,
|
||||
"pull": True,
|
||||
},
|
||||
),
|
||||
]
|
||||
return steps
|
||||
|
||||
|
||||
async def create_calcom_setup_task(
|
||||
*,
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
agent_id: uuid.UUID | None,
|
||||
domain: str,
|
||||
) -> Task:
|
||||
"""
|
||||
Create and persist a COMPOSITE task for Cal.com setup.
|
||||
|
||||
Args:
|
||||
db: Async database session
|
||||
tenant_id: UUID of the tenant
|
||||
agent_id: Optional UUID of the agent to assign the task to
|
||||
domain: The domain for Cal.com
|
||||
|
||||
Returns:
|
||||
The created Task object with type="COMPOSITE"
|
||||
"""
|
||||
steps = build_calcom_setup_steps(domain=domain)
|
||||
|
||||
task = Task(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
type="COMPOSITE",
|
||||
payload={"steps": [step.model_dump() for step in steps]},
|
||||
status=TaskStatus.PENDING.value,
|
||||
)
|
||||
|
||||
db.add(task)
|
||||
await db.commit()
|
||||
await db.refresh(task)
|
||||
|
||||
return task
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Initial Setup via Playwright
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def build_calcom_initial_setup_step(
|
||||
*,
|
||||
base_url: str,
|
||||
admin_email: str,
|
||||
admin_password: str | None = None,
|
||||
admin_username: str = "admin",
|
||||
admin_name: str = "Admin",
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Build a PLAYWRIGHT task payload for Cal.com initial setup.
|
||||
|
||||
This creates the admin account on a fresh Cal.com installation.
|
||||
|
||||
Args:
|
||||
base_url: The base URL for Cal.com (e.g., "https://cal.example.com")
|
||||
admin_email: Email address for the admin account
|
||||
admin_password: Password for admin (auto-generated if None)
|
||||
admin_username: Username for the admin account
|
||||
admin_name: Display name for the admin account
|
||||
|
||||
Returns:
|
||||
Task payload dict with type="PLAYWRIGHT"
|
||||
"""
|
||||
parsed = urlparse(base_url)
|
||||
allowed_domain = parsed.netloc
|
||||
|
||||
inputs: dict[str, Any] = {
|
||||
"base_url": base_url,
|
||||
"admin_email": admin_email,
|
||||
"admin_username": admin_username,
|
||||
"admin_name": admin_name,
|
||||
}
|
||||
|
||||
if admin_password:
|
||||
inputs["admin_password"] = admin_password
|
||||
|
||||
return {
|
||||
"scenario": "calcom_initial_setup",
|
||||
"inputs": inputs,
|
||||
"options": {
|
||||
"allowed_domains": [allowed_domain],
|
||||
},
|
||||
"timeout": 120,
|
||||
}
|
||||
|
||||
|
||||
async def create_calcom_initial_setup_task(
|
||||
*,
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
agent_id: uuid.UUID,
|
||||
base_url: str,
|
||||
admin_email: str,
|
||||
admin_password: str | None = None,
|
||||
admin_username: str = "admin",
|
||||
admin_name: str = "Admin",
|
||||
) -> Task:
|
||||
"""
|
||||
Create and persist a PLAYWRIGHT task for Cal.com initial setup.
|
||||
|
||||
Args:
|
||||
db: Async database session
|
||||
tenant_id: UUID of the tenant
|
||||
agent_id: UUID of the agent to assign the task to
|
||||
base_url: The base URL for Cal.com
|
||||
admin_email: Email address for the admin account
|
||||
admin_password: Password for admin (auto-generated if None)
|
||||
admin_username: Username for the admin account
|
||||
admin_name: Display name for the admin account
|
||||
|
||||
Returns:
|
||||
The created Task object with type="PLAYWRIGHT"
|
||||
"""
|
||||
payload = build_calcom_initial_setup_step(
|
||||
base_url=base_url,
|
||||
admin_email=admin_email,
|
||||
admin_password=admin_password,
|
||||
admin_username=admin_username,
|
||||
admin_name=admin_name,
|
||||
)
|
||||
|
||||
task = Task(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
type="PLAYWRIGHT",
|
||||
payload=payload,
|
||||
status=TaskStatus.PENDING.value,
|
||||
)
|
||||
|
||||
db.add(task)
|
||||
await db.commit()
|
||||
await db.refresh(task)
|
||||
|
||||
return task
|
||||
207
letsbe-orchestrator/app/playbooks/chatwoot.py
Normal file
207
letsbe-orchestrator/app/playbooks/chatwoot.py
Normal file
@@ -0,0 +1,207 @@
|
||||
"""Chatwoot deployment playbook.
|
||||
|
||||
Defines the steps required to set up Chatwoot on a tenant server
|
||||
that already has stacks and env templates under /opt/letsbe.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.task import Task, TaskStatus
|
||||
|
||||
|
||||
class CompositeStep(BaseModel):
|
||||
"""A single step in a composite playbook."""
|
||||
|
||||
type: str = Field(..., description="Task type (e.g., ENV_UPDATE, DOCKER_RELOAD)")
|
||||
payload: dict[str, Any] = Field(
|
||||
default_factory=dict, description="Payload for this step"
|
||||
)
|
||||
|
||||
|
||||
# LetsBe standard paths
|
||||
CHATWOOT_ENV_PATH = "/opt/letsbe/env/chatwoot.env"
|
||||
CHATWOOT_STACK_DIR = "/opt/letsbe/stacks/chatwoot"
|
||||
|
||||
|
||||
def build_chatwoot_setup_steps(*, domain: str) -> list[CompositeStep]:
|
||||
"""
|
||||
Build the sequence of steps required to set up Chatwoot.
|
||||
|
||||
Assumes the env file already exists at /opt/letsbe/env/chatwoot.env
|
||||
(created by provisioning/env_setup.sh).
|
||||
|
||||
Args:
|
||||
domain: The domain for Chatwoot (e.g., "support.example.com")
|
||||
|
||||
Returns:
|
||||
List of 2 CompositeStep objects:
|
||||
1. ENV_UPDATE - patches FRONTEND_URL and BACKEND_URL
|
||||
2. DOCKER_RELOAD - restarts the chatwoot stack with pull=True
|
||||
"""
|
||||
steps = [
|
||||
# Step 1: Update environment variables
|
||||
CompositeStep(
|
||||
type="ENV_UPDATE",
|
||||
payload={
|
||||
"path": CHATWOOT_ENV_PATH,
|
||||
"updates": {
|
||||
"FRONTEND_URL": f"https://{domain}",
|
||||
"BACKEND_URL": f"https://{domain}",
|
||||
},
|
||||
},
|
||||
),
|
||||
# Step 2: Reload Docker stack
|
||||
CompositeStep(
|
||||
type="DOCKER_RELOAD",
|
||||
payload={
|
||||
"compose_dir": CHATWOOT_STACK_DIR,
|
||||
"pull": True,
|
||||
},
|
||||
),
|
||||
]
|
||||
return steps
|
||||
|
||||
|
||||
async def create_chatwoot_setup_task(
|
||||
*,
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
agent_id: uuid.UUID | None,
|
||||
domain: str,
|
||||
) -> Task:
|
||||
"""
|
||||
Create and persist a COMPOSITE task for Chatwoot setup.
|
||||
|
||||
Args:
|
||||
db: Async database session
|
||||
tenant_id: UUID of the tenant
|
||||
agent_id: Optional UUID of the agent to assign the task to
|
||||
domain: The domain for Chatwoot
|
||||
|
||||
Returns:
|
||||
The created Task object with type="COMPOSITE"
|
||||
"""
|
||||
steps = build_chatwoot_setup_steps(domain=domain)
|
||||
|
||||
task = Task(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
type="COMPOSITE",
|
||||
payload={"steps": [step.model_dump() for step in steps]},
|
||||
status=TaskStatus.PENDING.value,
|
||||
)
|
||||
|
||||
db.add(task)
|
||||
await db.commit()
|
||||
await db.refresh(task)
|
||||
|
||||
return task
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Initial Setup via Playwright
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def build_chatwoot_initial_setup_step(
|
||||
*,
|
||||
base_url: str,
|
||||
admin_name: str,
|
||||
company_name: str,
|
||||
admin_email: str,
|
||||
admin_password: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Build a PLAYWRIGHT task payload for Chatwoot initial setup.
|
||||
|
||||
This creates the super admin account on a fresh Chatwoot installation.
|
||||
|
||||
Args:
|
||||
base_url: The base URL for Chatwoot (e.g., "https://chatwoot.example.com")
|
||||
admin_name: Full name for the admin account
|
||||
company_name: Company/organization name
|
||||
admin_email: Email address for the admin account
|
||||
admin_password: Password for admin (auto-generated if None)
|
||||
|
||||
Returns:
|
||||
Task payload dict with type="PLAYWRIGHT"
|
||||
"""
|
||||
from urllib.parse import urlparse
|
||||
|
||||
# Extract domain from URL for allowlist
|
||||
parsed = urlparse(base_url)
|
||||
allowed_domain = parsed.netloc
|
||||
|
||||
inputs: dict[str, Any] = {
|
||||
"base_url": base_url,
|
||||
"admin_name": admin_name,
|
||||
"company_name": company_name,
|
||||
"admin_email": admin_email,
|
||||
}
|
||||
|
||||
# Only include password if provided
|
||||
if admin_password:
|
||||
inputs["admin_password"] = admin_password
|
||||
|
||||
return {
|
||||
"scenario": "chatwoot_initial_setup",
|
||||
"inputs": inputs,
|
||||
"options": {
|
||||
"allowed_domains": [allowed_domain],
|
||||
},
|
||||
"timeout": 120,
|
||||
}
|
||||
|
||||
|
||||
async def create_chatwoot_initial_setup_task(
|
||||
*,
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
agent_id: uuid.UUID,
|
||||
base_url: str,
|
||||
admin_name: str,
|
||||
company_name: str,
|
||||
admin_email: str,
|
||||
admin_password: str | None = None,
|
||||
) -> Task:
|
||||
"""
|
||||
Create and persist a PLAYWRIGHT task for Chatwoot initial setup.
|
||||
|
||||
Args:
|
||||
db: Async database session
|
||||
tenant_id: UUID of the tenant
|
||||
agent_id: UUID of the agent to assign the task to
|
||||
base_url: The base URL for Chatwoot
|
||||
admin_name: Full name for the admin account
|
||||
company_name: Company/organization name
|
||||
admin_email: Email address for the admin account
|
||||
admin_password: Password for admin (auto-generated if None)
|
||||
|
||||
Returns:
|
||||
The created Task object with type="PLAYWRIGHT"
|
||||
"""
|
||||
payload = build_chatwoot_initial_setup_step(
|
||||
base_url=base_url,
|
||||
admin_name=admin_name,
|
||||
company_name=company_name,
|
||||
admin_email=admin_email,
|
||||
admin_password=admin_password,
|
||||
)
|
||||
|
||||
task = Task(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
type="PLAYWRIGHT",
|
||||
payload=payload,
|
||||
status=TaskStatus.PENDING.value,
|
||||
)
|
||||
|
||||
db.add(task)
|
||||
await db.commit()
|
||||
await db.refresh(task)
|
||||
|
||||
return task
|
||||
214
letsbe-orchestrator/app/playbooks/keycloak.py
Normal file
214
letsbe-orchestrator/app/playbooks/keycloak.py
Normal file
@@ -0,0 +1,214 @@
|
||||
"""Keycloak SSO deployment playbook.
|
||||
|
||||
Defines the steps required to:
|
||||
1. Set up Keycloak on a tenant server (ENV_UPDATE + DOCKER_RELOAD)
|
||||
2. Perform initial setup via Playwright automation (create admin, configure realm)
|
||||
|
||||
Tenant servers must have stacks and env templates under /opt/letsbe.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.task import Task, TaskStatus
|
||||
|
||||
|
||||
class CompositeStep(BaseModel):
|
||||
"""A single step in a composite playbook."""
|
||||
|
||||
type: str = Field(..., description="Task type (e.g., ENV_UPDATE, DOCKER_RELOAD)")
|
||||
payload: dict[str, Any] = Field(
|
||||
default_factory=dict, description="Payload for this step"
|
||||
)
|
||||
|
||||
|
||||
# LetsBe standard paths
|
||||
KEYCLOAK_ENV_PATH = "/opt/letsbe/env/keycloak.env"
|
||||
KEYCLOAK_STACK_DIR = "/opt/letsbe/stacks/keycloak"
|
||||
|
||||
|
||||
def build_keycloak_setup_steps(
|
||||
*,
|
||||
domain: str,
|
||||
admin_user: str = "admin",
|
||||
admin_password: str,
|
||||
) -> list[CompositeStep]:
|
||||
"""
|
||||
Build the sequence of steps required to set up Keycloak.
|
||||
|
||||
Assumes the env file already exists at /opt/letsbe/env/keycloak.env
|
||||
(created by provisioning/env_setup.sh).
|
||||
|
||||
Args:
|
||||
domain: The domain for Keycloak (e.g., "auth.example.com")
|
||||
admin_user: Admin username (default: "admin")
|
||||
admin_password: Admin password
|
||||
|
||||
Returns:
|
||||
List of 2 CompositeStep objects:
|
||||
1. ENV_UPDATE - patches KC_HOSTNAME, KEYCLOAK_ADMIN, KEYCLOAK_ADMIN_PASSWORD
|
||||
2. DOCKER_RELOAD - restarts the keycloak stack with pull=True
|
||||
"""
|
||||
steps = [
|
||||
# Step 1: Update environment variables
|
||||
CompositeStep(
|
||||
type="ENV_UPDATE",
|
||||
payload={
|
||||
"path": KEYCLOAK_ENV_PATH,
|
||||
"updates": {
|
||||
"KC_HOSTNAME": domain,
|
||||
"KEYCLOAK_ADMIN": admin_user,
|
||||
"KEYCLOAK_ADMIN_PASSWORD": admin_password,
|
||||
},
|
||||
},
|
||||
),
|
||||
# Step 2: Reload Docker stack
|
||||
CompositeStep(
|
||||
type="DOCKER_RELOAD",
|
||||
payload={
|
||||
"compose_dir": KEYCLOAK_STACK_DIR,
|
||||
"pull": True,
|
||||
},
|
||||
),
|
||||
]
|
||||
return steps
|
||||
|
||||
|
||||
async def create_keycloak_setup_task(
|
||||
*,
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
agent_id: uuid.UUID | None,
|
||||
domain: str,
|
||||
admin_user: str = "admin",
|
||||
admin_password: str,
|
||||
) -> Task:
|
||||
"""
|
||||
Create and persist a COMPOSITE task for Keycloak setup.
|
||||
|
||||
Args:
|
||||
db: Async database session
|
||||
tenant_id: UUID of the tenant
|
||||
agent_id: Optional UUID of the agent to assign the task to
|
||||
domain: The domain for Keycloak
|
||||
admin_user: Admin username
|
||||
admin_password: Admin password
|
||||
|
||||
Returns:
|
||||
The created Task object with type="COMPOSITE"
|
||||
"""
|
||||
steps = build_keycloak_setup_steps(
|
||||
domain=domain,
|
||||
admin_user=admin_user,
|
||||
admin_password=admin_password,
|
||||
)
|
||||
|
||||
task = Task(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
type="COMPOSITE",
|
||||
payload={"steps": [step.model_dump() for step in steps]},
|
||||
status=TaskStatus.PENDING.value,
|
||||
)
|
||||
|
||||
db.add(task)
|
||||
await db.commit()
|
||||
await db.refresh(task)
|
||||
|
||||
return task
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Initial Setup via Playwright
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def build_keycloak_initial_setup_step(
|
||||
*,
|
||||
base_url: str,
|
||||
admin_user: str,
|
||||
admin_password: str,
|
||||
realm_name: str = "letsbe",
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Build a PLAYWRIGHT task payload for Keycloak initial setup.
|
||||
|
||||
This creates the admin account and configures the "letsbe" realm
|
||||
on a fresh Keycloak installation.
|
||||
|
||||
Args:
|
||||
base_url: The base URL for Keycloak (e.g., "https://auth.example.com")
|
||||
admin_user: Username for the admin account
|
||||
admin_password: Password for the admin account
|
||||
realm_name: Name of the realm to create (default: "letsbe")
|
||||
|
||||
Returns:
|
||||
Task payload dict with type="PLAYWRIGHT"
|
||||
"""
|
||||
parsed = urlparse(base_url)
|
||||
allowed_domain = parsed.netloc
|
||||
|
||||
return {
|
||||
"scenario": "keycloak_initial_setup",
|
||||
"inputs": {
|
||||
"base_url": base_url,
|
||||
"admin_user": admin_user,
|
||||
"admin_password": admin_password,
|
||||
"realm_name": realm_name,
|
||||
},
|
||||
"options": {
|
||||
"allowed_domains": [allowed_domain],
|
||||
},
|
||||
"timeout": 120,
|
||||
}
|
||||
|
||||
|
||||
async def create_keycloak_initial_setup_task(
|
||||
*,
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
agent_id: uuid.UUID,
|
||||
base_url: str,
|
||||
admin_user: str,
|
||||
admin_password: str,
|
||||
realm_name: str = "letsbe",
|
||||
) -> Task:
|
||||
"""
|
||||
Create and persist a PLAYWRIGHT task for Keycloak initial setup.
|
||||
|
||||
Args:
|
||||
db: Async database session
|
||||
tenant_id: UUID of the tenant
|
||||
agent_id: UUID of the agent to assign the task to
|
||||
base_url: The base URL for Keycloak
|
||||
admin_user: Username for the admin account
|
||||
admin_password: Password for the admin account
|
||||
realm_name: Name of the realm to create
|
||||
|
||||
Returns:
|
||||
The created Task object with type="PLAYWRIGHT"
|
||||
"""
|
||||
payload = build_keycloak_initial_setup_step(
|
||||
base_url=base_url,
|
||||
admin_user=admin_user,
|
||||
admin_password=admin_password,
|
||||
realm_name=realm_name,
|
||||
)
|
||||
|
||||
task = Task(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
type="PLAYWRIGHT",
|
||||
payload=payload,
|
||||
status=TaskStatus.PENDING.value,
|
||||
)
|
||||
|
||||
db.add(task)
|
||||
await db.commit()
|
||||
await db.refresh(task)
|
||||
|
||||
return task
|
||||
208
letsbe-orchestrator/app/playbooks/n8n.py
Normal file
208
letsbe-orchestrator/app/playbooks/n8n.py
Normal file
@@ -0,0 +1,208 @@
|
||||
"""n8n workflow automation deployment playbook.
|
||||
|
||||
Defines the steps required to:
|
||||
1. Set up n8n on a tenant server (ENV_UPDATE + DOCKER_RELOAD)
|
||||
2. Perform initial setup via Playwright automation (create owner account)
|
||||
|
||||
Tenant servers must have stacks and env templates under /opt/letsbe.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.task import Task, TaskStatus
|
||||
|
||||
|
||||
class CompositeStep(BaseModel):
|
||||
"""A single step in a composite playbook."""
|
||||
|
||||
type: str = Field(..., description="Task type (e.g., ENV_UPDATE, DOCKER_RELOAD)")
|
||||
payload: dict[str, Any] = Field(
|
||||
default_factory=dict, description="Payload for this step"
|
||||
)
|
||||
|
||||
|
||||
# LetsBe standard paths
|
||||
N8N_ENV_PATH = "/opt/letsbe/env/n8n.env"
|
||||
N8N_STACK_DIR = "/opt/letsbe/stacks/n8n"
|
||||
|
||||
|
||||
def build_n8n_setup_steps(*, domain: str) -> list[CompositeStep]:
|
||||
"""
|
||||
Build the sequence of steps required to set up n8n.
|
||||
|
||||
Assumes the env file already exists at /opt/letsbe/env/n8n.env
|
||||
(created by provisioning/env_setup.sh).
|
||||
|
||||
Args:
|
||||
domain: The domain for n8n (e.g., "n8n.example.com")
|
||||
|
||||
Returns:
|
||||
List of 2 CompositeStep objects:
|
||||
1. ENV_UPDATE - patches N8N_HOST, N8N_PROTOCOL, WEBHOOK_URL
|
||||
2. DOCKER_RELOAD - restarts the n8n stack with pull=True
|
||||
"""
|
||||
steps = [
|
||||
# Step 1: Update environment variables
|
||||
CompositeStep(
|
||||
type="ENV_UPDATE",
|
||||
payload={
|
||||
"path": N8N_ENV_PATH,
|
||||
"updates": {
|
||||
"N8N_HOST": domain,
|
||||
"N8N_PROTOCOL": "https",
|
||||
"WEBHOOK_URL": f"https://{domain}/",
|
||||
},
|
||||
},
|
||||
),
|
||||
# Step 2: Reload Docker stack
|
||||
CompositeStep(
|
||||
type="DOCKER_RELOAD",
|
||||
payload={
|
||||
"compose_dir": N8N_STACK_DIR,
|
||||
"pull": True,
|
||||
},
|
||||
),
|
||||
]
|
||||
return steps
|
||||
|
||||
|
||||
async def create_n8n_setup_task(
|
||||
*,
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
agent_id: uuid.UUID | None,
|
||||
domain: str,
|
||||
) -> Task:
|
||||
"""
|
||||
Create and persist a COMPOSITE task for n8n setup.
|
||||
|
||||
Args:
|
||||
db: Async database session
|
||||
tenant_id: UUID of the tenant
|
||||
agent_id: Optional UUID of the agent to assign the task to
|
||||
domain: The domain for n8n
|
||||
|
||||
Returns:
|
||||
The created Task object with type="COMPOSITE"
|
||||
"""
|
||||
steps = build_n8n_setup_steps(domain=domain)
|
||||
|
||||
task = Task(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
type="COMPOSITE",
|
||||
payload={"steps": [step.model_dump() for step in steps]},
|
||||
status=TaskStatus.PENDING.value,
|
||||
)
|
||||
|
||||
db.add(task)
|
||||
await db.commit()
|
||||
await db.refresh(task)
|
||||
|
||||
return task
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Initial Setup via Playwright
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def build_n8n_initial_setup_step(
|
||||
*,
|
||||
base_url: str,
|
||||
admin_email: str,
|
||||
admin_password: str | None = None,
|
||||
admin_first_name: str = "Admin",
|
||||
admin_last_name: str = "User",
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Build a PLAYWRIGHT task payload for n8n initial setup.
|
||||
|
||||
This creates the owner account on a fresh n8n installation.
|
||||
|
||||
Args:
|
||||
base_url: The base URL for n8n (e.g., "https://n8n.example.com")
|
||||
admin_email: Email address for the owner account
|
||||
admin_password: Password for owner (auto-generated if None)
|
||||
admin_first_name: First name for the owner account
|
||||
admin_last_name: Last name for the owner account
|
||||
|
||||
Returns:
|
||||
Task payload dict with type="PLAYWRIGHT"
|
||||
"""
|
||||
parsed = urlparse(base_url)
|
||||
allowed_domain = parsed.netloc
|
||||
|
||||
inputs: dict[str, Any] = {
|
||||
"base_url": base_url,
|
||||
"admin_email": admin_email,
|
||||
"admin_first_name": admin_first_name,
|
||||
"admin_last_name": admin_last_name,
|
||||
}
|
||||
|
||||
if admin_password:
|
||||
inputs["admin_password"] = admin_password
|
||||
|
||||
return {
|
||||
"scenario": "n8n_initial_setup",
|
||||
"inputs": inputs,
|
||||
"options": {
|
||||
"allowed_domains": [allowed_domain],
|
||||
},
|
||||
"timeout": 120,
|
||||
}
|
||||
|
||||
|
||||
async def create_n8n_initial_setup_task(
|
||||
*,
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
agent_id: uuid.UUID,
|
||||
base_url: str,
|
||||
admin_email: str,
|
||||
admin_password: str | None = None,
|
||||
admin_first_name: str = "Admin",
|
||||
admin_last_name: str = "User",
|
||||
) -> Task:
|
||||
"""
|
||||
Create and persist a PLAYWRIGHT task for n8n initial setup.
|
||||
|
||||
Args:
|
||||
db: Async database session
|
||||
tenant_id: UUID of the tenant
|
||||
agent_id: UUID of the agent to assign the task to
|
||||
base_url: The base URL for n8n
|
||||
admin_email: Email address for the owner account
|
||||
admin_password: Password for owner (auto-generated if None)
|
||||
admin_first_name: First name for the owner account
|
||||
admin_last_name: Last name for the owner account
|
||||
|
||||
Returns:
|
||||
The created Task object with type="PLAYWRIGHT"
|
||||
"""
|
||||
payload = build_n8n_initial_setup_step(
|
||||
base_url=base_url,
|
||||
admin_email=admin_email,
|
||||
admin_password=admin_password,
|
||||
admin_first_name=admin_first_name,
|
||||
admin_last_name=admin_last_name,
|
||||
)
|
||||
|
||||
task = Task(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
type="PLAYWRIGHT",
|
||||
payload=payload,
|
||||
status=TaskStatus.PENDING.value,
|
||||
)
|
||||
|
||||
db.add(task)
|
||||
await db.commit()
|
||||
await db.refresh(task)
|
||||
|
||||
return task
|
||||
192
letsbe-orchestrator/app/playbooks/nextcloud.py
Normal file
192
letsbe-orchestrator/app/playbooks/nextcloud.py
Normal file
@@ -0,0 +1,192 @@
|
||||
"""Nextcloud deployment playbook.
|
||||
|
||||
Defines the steps required to:
|
||||
1. Set Nextcloud domain on a tenant server (v2: via NEXTCLOUD_SET_DOMAIN task)
|
||||
2. Perform initial setup via Playwright automation (create admin account)
|
||||
|
||||
Tenant servers must have stacks and env templates under /opt/letsbe.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.task import Task, TaskStatus
|
||||
|
||||
|
||||
class CompositeStep(BaseModel):
|
||||
"""A single step in a composite playbook."""
|
||||
|
||||
type: str = Field(..., description="Task type (e.g., ENV_UPDATE, DOCKER_RELOAD)")
|
||||
payload: dict[str, Any] = Field(
|
||||
default_factory=dict, description="Payload for this step"
|
||||
)
|
||||
|
||||
|
||||
# LetsBe standard paths
|
||||
NEXTCLOUD_STACK_DIR = "/opt/letsbe/stacks/nextcloud"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Initial Setup via Playwright
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def build_nextcloud_initial_setup_step(
|
||||
*,
|
||||
base_url: str,
|
||||
admin_username: str,
|
||||
admin_password: str,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Build a PLAYWRIGHT task payload for Nextcloud initial setup.
|
||||
|
||||
This creates the admin account on a fresh Nextcloud installation.
|
||||
|
||||
Args:
|
||||
base_url: The base URL for Nextcloud (e.g., "https://cloud.example.com")
|
||||
admin_username: Username for the admin account
|
||||
admin_password: Password for the admin account
|
||||
|
||||
Returns:
|
||||
Task payload dict with type="PLAYWRIGHT"
|
||||
"""
|
||||
# Extract domain from URL for allowlist
|
||||
parsed = urlparse(base_url)
|
||||
allowed_domain = parsed.netloc # e.g., "cloud.example.com"
|
||||
|
||||
return {
|
||||
"scenario": "nextcloud_initial_setup",
|
||||
"inputs": {
|
||||
"base_url": base_url,
|
||||
"admin_username": admin_username,
|
||||
"admin_password": admin_password,
|
||||
},
|
||||
"options": {
|
||||
"allowed_domains": [allowed_domain],
|
||||
},
|
||||
"timeout": 120,
|
||||
}
|
||||
|
||||
|
||||
async def create_nextcloud_initial_setup_task(
|
||||
*,
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
agent_id: uuid.UUID,
|
||||
base_url: str,
|
||||
admin_username: str,
|
||||
admin_password: str,
|
||||
) -> Task:
|
||||
"""
|
||||
Create and persist a PLAYWRIGHT task for Nextcloud initial setup.
|
||||
|
||||
Args:
|
||||
db: Async database session
|
||||
tenant_id: UUID of the tenant
|
||||
agent_id: UUID of the agent to assign the task to
|
||||
base_url: The base URL for Nextcloud
|
||||
admin_username: Username for the admin account
|
||||
admin_password: Password for the admin account
|
||||
|
||||
Returns:
|
||||
The created Task object with type="PLAYWRIGHT"
|
||||
"""
|
||||
payload = build_nextcloud_initial_setup_step(
|
||||
base_url=base_url,
|
||||
admin_username=admin_username,
|
||||
admin_password=admin_password,
|
||||
)
|
||||
|
||||
task = Task(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
type="PLAYWRIGHT",
|
||||
payload=payload,
|
||||
status=TaskStatus.PENDING.value,
|
||||
)
|
||||
|
||||
db.add(task)
|
||||
await db.commit()
|
||||
await db.refresh(task)
|
||||
|
||||
return task
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Set Domain via NEXTCLOUD_SET_DOMAIN
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def build_nextcloud_set_domain_steps(*, public_url: str, pull: bool) -> list[CompositeStep]:
|
||||
"""
|
||||
Build the sequence of steps required to set Nextcloud domain (v2).
|
||||
|
||||
Args:
|
||||
public_url: The public URL for Nextcloud (e.g., "https://cloud.example.com")
|
||||
pull: Whether to pull images before reloading the stack
|
||||
|
||||
Returns:
|
||||
List of 2 CompositeStep objects:
|
||||
1. NEXTCLOUD_SET_DOMAIN - configures Nextcloud via occ commands
|
||||
2. DOCKER_RELOAD - restarts the Nextcloud stack
|
||||
"""
|
||||
steps = [
|
||||
# Step 1: Configure Nextcloud domain via occ
|
||||
CompositeStep(
|
||||
type="NEXTCLOUD_SET_DOMAIN",
|
||||
payload={
|
||||
"public_url": public_url,
|
||||
},
|
||||
),
|
||||
# Step 2: Reload Docker stack
|
||||
CompositeStep(
|
||||
type="DOCKER_RELOAD",
|
||||
payload={
|
||||
"compose_dir": NEXTCLOUD_STACK_DIR,
|
||||
"pull": pull,
|
||||
},
|
||||
),
|
||||
]
|
||||
return steps
|
||||
|
||||
|
||||
async def create_nextcloud_set_domain_task(
|
||||
*,
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
agent_id: uuid.UUID,
|
||||
public_url: str,
|
||||
pull: bool,
|
||||
) -> Task:
|
||||
"""
|
||||
Create and persist a COMPOSITE task for Nextcloud set-domain.
|
||||
|
||||
Args:
|
||||
db: Async database session
|
||||
tenant_id: UUID of the tenant
|
||||
agent_id: UUID of the agent to assign the task to
|
||||
public_url: The public URL for Nextcloud
|
||||
pull: Whether to pull images before reloading
|
||||
|
||||
Returns:
|
||||
The created Task object with type="COMPOSITE"
|
||||
"""
|
||||
steps = build_nextcloud_set_domain_steps(public_url=public_url, pull=pull)
|
||||
|
||||
task = Task(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
type="COMPOSITE",
|
||||
payload={"steps": [step.model_dump() for step in steps]},
|
||||
status=TaskStatus.PENDING.value,
|
||||
)
|
||||
|
||||
db.add(task)
|
||||
await db.commit()
|
||||
await db.refresh(task)
|
||||
|
||||
return task
|
||||
105
letsbe-orchestrator/app/playbooks/portainer.py
Normal file
105
letsbe-orchestrator/app/playbooks/portainer.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""Portainer container management deployment playbook.
|
||||
|
||||
Defines the steps required to set up Portainer on a tenant server
|
||||
(ENV_UPDATE + DOCKER_RELOAD). No Playwright setup needed - Portainer's
|
||||
admin account is created via its first-use web UI which is already
|
||||
handled during provisioning.
|
||||
|
||||
Tenant servers must have stacks and env templates under /opt/letsbe.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.task import Task, TaskStatus
|
||||
|
||||
|
||||
class CompositeStep(BaseModel):
|
||||
"""A single step in a composite playbook."""
|
||||
|
||||
type: str = Field(..., description="Task type (e.g., ENV_UPDATE, DOCKER_RELOAD)")
|
||||
payload: dict[str, Any] = Field(
|
||||
default_factory=dict, description="Payload for this step"
|
||||
)
|
||||
|
||||
|
||||
# LetsBe standard paths
|
||||
PORTAINER_ENV_PATH = "/opt/letsbe/env/portainer.env"
|
||||
PORTAINER_STACK_DIR = "/opt/letsbe/stacks/portainer"
|
||||
|
||||
|
||||
def build_portainer_setup_steps(*, domain: str) -> list[CompositeStep]:
|
||||
"""
|
||||
Build the sequence of steps required to set up Portainer.
|
||||
|
||||
Assumes the env file already exists at /opt/letsbe/env/portainer.env
|
||||
(created by provisioning/env_setup.sh).
|
||||
|
||||
Args:
|
||||
domain: The domain for Portainer (e.g., "portainer.example.com")
|
||||
|
||||
Returns:
|
||||
List of 2 CompositeStep objects:
|
||||
1. ENV_UPDATE - patches PORTAINER_DOMAIN
|
||||
2. DOCKER_RELOAD - restarts the portainer stack with pull=True
|
||||
"""
|
||||
steps = [
|
||||
# Step 1: Update environment variables
|
||||
CompositeStep(
|
||||
type="ENV_UPDATE",
|
||||
payload={
|
||||
"path": PORTAINER_ENV_PATH,
|
||||
"updates": {
|
||||
"PORTAINER_DOMAIN": domain,
|
||||
},
|
||||
},
|
||||
),
|
||||
# Step 2: Reload Docker stack
|
||||
CompositeStep(
|
||||
type="DOCKER_RELOAD",
|
||||
payload={
|
||||
"compose_dir": PORTAINER_STACK_DIR,
|
||||
"pull": True,
|
||||
},
|
||||
),
|
||||
]
|
||||
return steps
|
||||
|
||||
|
||||
async def create_portainer_setup_task(
|
||||
*,
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
agent_id: uuid.UUID | None,
|
||||
domain: str,
|
||||
) -> Task:
|
||||
"""
|
||||
Create and persist a COMPOSITE task for Portainer setup.
|
||||
|
||||
Args:
|
||||
db: Async database session
|
||||
tenant_id: UUID of the tenant
|
||||
agent_id: Optional UUID of the agent to assign the task to
|
||||
domain: The domain for Portainer
|
||||
|
||||
Returns:
|
||||
The created Task object with type="COMPOSITE"
|
||||
"""
|
||||
steps = build_portainer_setup_steps(domain=domain)
|
||||
|
||||
task = Task(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
type="COMPOSITE",
|
||||
payload={"steps": [step.model_dump() for step in steps]},
|
||||
status=TaskStatus.PENDING.value,
|
||||
)
|
||||
|
||||
db.add(task)
|
||||
await db.commit()
|
||||
await db.refresh(task)
|
||||
|
||||
return task
|
||||
111
letsbe-orchestrator/app/playbooks/poste.py
Normal file
111
letsbe-orchestrator/app/playbooks/poste.py
Normal file
@@ -0,0 +1,111 @@
|
||||
"""Poste.io mail server deployment playbook.
|
||||
|
||||
Defines the steps required to:
|
||||
1. Perform initial setup via Playwright automation (configure hostname, create admin account)
|
||||
|
||||
Tenant servers must have stacks and env templates under /opt/letsbe.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.task import Task, TaskStatus
|
||||
|
||||
|
||||
# LetsBe standard paths
|
||||
POSTE_STACK_DIR = "/opt/letsbe/stacks/poste"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Initial Setup via Playwright
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def build_poste_initial_setup_step(
|
||||
*,
|
||||
base_url: str,
|
||||
admin_email: str,
|
||||
admin_password: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Build a PLAYWRIGHT task payload for Poste.io initial setup.
|
||||
|
||||
This configures the mail server hostname and creates the admin account
|
||||
on a fresh Poste.io installation.
|
||||
|
||||
Args:
|
||||
base_url: The base URL for Poste.io (e.g., "https://mail.example.com")
|
||||
admin_email: Email address for the admin account (e.g., admin@example.com)
|
||||
admin_password: Password for the admin account (auto-generated if None)
|
||||
|
||||
Returns:
|
||||
Task payload dict with type="PLAYWRIGHT"
|
||||
"""
|
||||
# Extract domain from URL for allowlist
|
||||
parsed = urlparse(base_url)
|
||||
allowed_domain = parsed.netloc # e.g., "mail.example.com"
|
||||
|
||||
inputs: dict[str, Any] = {
|
||||
"base_url": base_url,
|
||||
"admin_email": admin_email,
|
||||
}
|
||||
|
||||
# Only include password if provided - scenario will auto-generate if missing
|
||||
if admin_password:
|
||||
inputs["admin_password"] = admin_password
|
||||
|
||||
return {
|
||||
"scenario": "poste_initial_setup",
|
||||
"inputs": inputs,
|
||||
"options": {
|
||||
"allowed_domains": [allowed_domain],
|
||||
},
|
||||
"timeout": 120,
|
||||
}
|
||||
|
||||
|
||||
async def create_poste_initial_setup_task(
|
||||
*,
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
agent_id: uuid.UUID,
|
||||
base_url: str,
|
||||
admin_email: str,
|
||||
admin_password: str | None = None,
|
||||
) -> Task:
|
||||
"""
|
||||
Create and persist a PLAYWRIGHT task for Poste.io initial setup.
|
||||
|
||||
Args:
|
||||
db: Async database session
|
||||
tenant_id: UUID of the tenant
|
||||
agent_id: UUID of the agent to assign the task to
|
||||
base_url: The base URL for Poste.io
|
||||
admin_email: Email address for the admin account
|
||||
admin_password: Password for admin (auto-generated if None)
|
||||
|
||||
Returns:
|
||||
The created Task object with type="PLAYWRIGHT"
|
||||
"""
|
||||
payload = build_poste_initial_setup_step(
|
||||
base_url=base_url,
|
||||
admin_email=admin_email,
|
||||
admin_password=admin_password,
|
||||
)
|
||||
|
||||
task = Task(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
type="PLAYWRIGHT",
|
||||
payload=payload,
|
||||
status=TaskStatus.PENDING.value,
|
||||
)
|
||||
|
||||
db.add(task)
|
||||
await db.commit()
|
||||
await db.refresh(task)
|
||||
|
||||
return task
|
||||
205
letsbe-orchestrator/app/playbooks/umami.py
Normal file
205
letsbe-orchestrator/app/playbooks/umami.py
Normal file
@@ -0,0 +1,205 @@
|
||||
"""Umami analytics deployment playbook.
|
||||
|
||||
Defines the steps required to:
|
||||
1. Set up Umami on a tenant server (ENV_UPDATE + DOCKER_RELOAD)
|
||||
2. Perform initial setup via Playwright automation (create admin, add first website)
|
||||
|
||||
Tenant servers must have stacks and env templates under /opt/letsbe.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.task import Task, TaskStatus
|
||||
|
||||
|
||||
class CompositeStep(BaseModel):
|
||||
"""A single step in a composite playbook."""
|
||||
|
||||
type: str = Field(..., description="Task type (e.g., ENV_UPDATE, DOCKER_RELOAD)")
|
||||
payload: dict[str, Any] = Field(
|
||||
default_factory=dict, description="Payload for this step"
|
||||
)
|
||||
|
||||
|
||||
# LetsBe standard paths
|
||||
UMAMI_ENV_PATH = "/opt/letsbe/env/umami.env"
|
||||
UMAMI_STACK_DIR = "/opt/letsbe/stacks/umami"
|
||||
|
||||
|
||||
def build_umami_setup_steps(*, domain: str) -> list[CompositeStep]:
|
||||
"""
|
||||
Build the sequence of steps required to set up Umami.
|
||||
|
||||
Assumes the env file already exists at /opt/letsbe/env/umami.env
|
||||
(created by provisioning/env_setup.sh).
|
||||
|
||||
Args:
|
||||
domain: The domain for Umami (e.g., "analytics.example.com")
|
||||
|
||||
Returns:
|
||||
List of 2 CompositeStep objects:
|
||||
1. ENV_UPDATE - patches APP_URL
|
||||
2. DOCKER_RELOAD - restarts the umami stack with pull=True
|
||||
"""
|
||||
steps = [
|
||||
# Step 1: Update environment variables
|
||||
CompositeStep(
|
||||
type="ENV_UPDATE",
|
||||
payload={
|
||||
"path": UMAMI_ENV_PATH,
|
||||
"updates": {
|
||||
"APP_URL": f"https://{domain}",
|
||||
},
|
||||
},
|
||||
),
|
||||
# Step 2: Reload Docker stack
|
||||
CompositeStep(
|
||||
type="DOCKER_RELOAD",
|
||||
payload={
|
||||
"compose_dir": UMAMI_STACK_DIR,
|
||||
"pull": True,
|
||||
},
|
||||
),
|
||||
]
|
||||
return steps
|
||||
|
||||
|
||||
async def create_umami_setup_task(
|
||||
*,
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
agent_id: uuid.UUID | None,
|
||||
domain: str,
|
||||
) -> Task:
|
||||
"""
|
||||
Create and persist a COMPOSITE task for Umami setup.
|
||||
|
||||
Args:
|
||||
db: Async database session
|
||||
tenant_id: UUID of the tenant
|
||||
agent_id: Optional UUID of the agent to assign the task to
|
||||
domain: The domain for Umami
|
||||
|
||||
Returns:
|
||||
The created Task object with type="COMPOSITE"
|
||||
"""
|
||||
steps = build_umami_setup_steps(domain=domain)
|
||||
|
||||
task = Task(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
type="COMPOSITE",
|
||||
payload={"steps": [step.model_dump() for step in steps]},
|
||||
status=TaskStatus.PENDING.value,
|
||||
)
|
||||
|
||||
db.add(task)
|
||||
await db.commit()
|
||||
await db.refresh(task)
|
||||
|
||||
return task
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Initial Setup via Playwright
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def build_umami_initial_setup_step(
|
||||
*,
|
||||
base_url: str,
|
||||
admin_password: str | None = None,
|
||||
website_name: str | None = None,
|
||||
website_url: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Build a PLAYWRIGHT task payload for Umami initial setup.
|
||||
|
||||
This logs in with default credentials, changes the admin password,
|
||||
and optionally adds the first website to track.
|
||||
|
||||
Umami ships with default credentials: admin / umami
|
||||
|
||||
Args:
|
||||
base_url: The base URL for Umami (e.g., "https://analytics.example.com")
|
||||
admin_password: New password for admin (auto-generated if None)
|
||||
website_name: Optional name of the first website to add
|
||||
website_url: Optional URL of the first website to track
|
||||
|
||||
Returns:
|
||||
Task payload dict with type="PLAYWRIGHT"
|
||||
"""
|
||||
parsed = urlparse(base_url)
|
||||
allowed_domain = parsed.netloc
|
||||
|
||||
inputs: dict[str, Any] = {
|
||||
"base_url": base_url,
|
||||
}
|
||||
|
||||
if admin_password:
|
||||
inputs["admin_password"] = admin_password
|
||||
if website_name:
|
||||
inputs["website_name"] = website_name
|
||||
if website_url:
|
||||
inputs["website_url"] = website_url
|
||||
|
||||
return {
|
||||
"scenario": "umami_initial_setup",
|
||||
"inputs": inputs,
|
||||
"options": {
|
||||
"allowed_domains": [allowed_domain],
|
||||
},
|
||||
"timeout": 120,
|
||||
}
|
||||
|
||||
|
||||
async def create_umami_initial_setup_task(
|
||||
*,
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
agent_id: uuid.UUID,
|
||||
base_url: str,
|
||||
admin_password: str | None = None,
|
||||
website_name: str | None = None,
|
||||
website_url: str | None = None,
|
||||
) -> Task:
|
||||
"""
|
||||
Create and persist a PLAYWRIGHT task for Umami initial setup.
|
||||
|
||||
Args:
|
||||
db: Async database session
|
||||
tenant_id: UUID of the tenant
|
||||
agent_id: UUID of the agent to assign the task to
|
||||
base_url: The base URL for Umami
|
||||
admin_password: New password for admin (auto-generated if None)
|
||||
website_name: Optional name of the first website to add
|
||||
website_url: Optional URL of the first website to track
|
||||
|
||||
Returns:
|
||||
The created Task object with type="PLAYWRIGHT"
|
||||
"""
|
||||
payload = build_umami_initial_setup_step(
|
||||
base_url=base_url,
|
||||
admin_password=admin_password,
|
||||
website_name=website_name,
|
||||
website_url=website_url,
|
||||
)
|
||||
|
||||
task = Task(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
type="PLAYWRIGHT",
|
||||
payload=payload,
|
||||
status=TaskStatus.PENDING.value,
|
||||
)
|
||||
|
||||
db.add(task)
|
||||
await db.commit()
|
||||
await db.refresh(task)
|
||||
|
||||
return task
|
||||
194
letsbe-orchestrator/app/playbooks/uptime_kuma.py
Normal file
194
letsbe-orchestrator/app/playbooks/uptime_kuma.py
Normal file
@@ -0,0 +1,194 @@
|
||||
"""Uptime Kuma monitoring deployment playbook.
|
||||
|
||||
Defines the steps required to:
|
||||
1. Set up Uptime Kuma on a tenant server (ENV_UPDATE + DOCKER_RELOAD)
|
||||
2. Perform initial setup via Playwright automation (create admin account)
|
||||
|
||||
Tenant servers must have stacks and env templates under /opt/letsbe.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.task import Task, TaskStatus
|
||||
|
||||
|
||||
class CompositeStep(BaseModel):
|
||||
"""A single step in a composite playbook."""
|
||||
|
||||
type: str = Field(..., description="Task type (e.g., ENV_UPDATE, DOCKER_RELOAD)")
|
||||
payload: dict[str, Any] = Field(
|
||||
default_factory=dict, description="Payload for this step"
|
||||
)
|
||||
|
||||
|
||||
# LetsBe standard paths
|
||||
UPTIME_KUMA_ENV_PATH = "/opt/letsbe/env/uptime-kuma.env"
|
||||
UPTIME_KUMA_STACK_DIR = "/opt/letsbe/stacks/uptime-kuma"
|
||||
|
||||
|
||||
def build_uptime_kuma_setup_steps(*, domain: str) -> list[CompositeStep]:
|
||||
"""
|
||||
Build the sequence of steps required to set up Uptime Kuma.
|
||||
|
||||
Assumes the env file already exists at /opt/letsbe/env/uptime-kuma.env
|
||||
(created by provisioning/env_setup.sh).
|
||||
|
||||
Args:
|
||||
domain: The domain for Uptime Kuma (e.g., "status.example.com")
|
||||
|
||||
Returns:
|
||||
List of 2 CompositeStep objects:
|
||||
1. ENV_UPDATE - patches UPTIME_KUMA_DOMAIN
|
||||
2. DOCKER_RELOAD - restarts the uptime-kuma stack with pull=True
|
||||
"""
|
||||
steps = [
|
||||
# Step 1: Update environment variables
|
||||
CompositeStep(
|
||||
type="ENV_UPDATE",
|
||||
payload={
|
||||
"path": UPTIME_KUMA_ENV_PATH,
|
||||
"updates": {
|
||||
"UPTIME_KUMA_DOMAIN": domain,
|
||||
},
|
||||
},
|
||||
),
|
||||
# Step 2: Reload Docker stack
|
||||
CompositeStep(
|
||||
type="DOCKER_RELOAD",
|
||||
payload={
|
||||
"compose_dir": UPTIME_KUMA_STACK_DIR,
|
||||
"pull": True,
|
||||
},
|
||||
),
|
||||
]
|
||||
return steps
|
||||
|
||||
|
||||
async def create_uptime_kuma_setup_task(
|
||||
*,
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
agent_id: uuid.UUID | None,
|
||||
domain: str,
|
||||
) -> Task:
|
||||
"""
|
||||
Create and persist a COMPOSITE task for Uptime Kuma setup.
|
||||
|
||||
Args:
|
||||
db: Async database session
|
||||
tenant_id: UUID of the tenant
|
||||
agent_id: Optional UUID of the agent to assign the task to
|
||||
domain: The domain for Uptime Kuma
|
||||
|
||||
Returns:
|
||||
The created Task object with type="COMPOSITE"
|
||||
"""
|
||||
steps = build_uptime_kuma_setup_steps(domain=domain)
|
||||
|
||||
task = Task(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
type="COMPOSITE",
|
||||
payload={"steps": [step.model_dump() for step in steps]},
|
||||
status=TaskStatus.PENDING.value,
|
||||
)
|
||||
|
||||
db.add(task)
|
||||
await db.commit()
|
||||
await db.refresh(task)
|
||||
|
||||
return task
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Initial Setup via Playwright
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def build_uptime_kuma_initial_setup_step(
|
||||
*,
|
||||
base_url: str,
|
||||
admin_username: str = "admin",
|
||||
admin_password: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Build a PLAYWRIGHT task payload for Uptime Kuma initial setup.
|
||||
|
||||
This creates the admin account on a fresh Uptime Kuma installation.
|
||||
|
||||
Args:
|
||||
base_url: The base URL for Uptime Kuma (e.g., "https://status.example.com")
|
||||
admin_username: Username for the admin account
|
||||
admin_password: Password for admin (auto-generated if None)
|
||||
|
||||
Returns:
|
||||
Task payload dict with type="PLAYWRIGHT"
|
||||
"""
|
||||
parsed = urlparse(base_url)
|
||||
allowed_domain = parsed.netloc
|
||||
|
||||
inputs: dict[str, Any] = {
|
||||
"base_url": base_url,
|
||||
"admin_username": admin_username,
|
||||
}
|
||||
|
||||
if admin_password:
|
||||
inputs["admin_password"] = admin_password
|
||||
|
||||
return {
|
||||
"scenario": "uptime_kuma_initial_setup",
|
||||
"inputs": inputs,
|
||||
"options": {
|
||||
"allowed_domains": [allowed_domain],
|
||||
},
|
||||
"timeout": 120,
|
||||
}
|
||||
|
||||
|
||||
async def create_uptime_kuma_initial_setup_task(
|
||||
*,
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
agent_id: uuid.UUID,
|
||||
base_url: str,
|
||||
admin_username: str = "admin",
|
||||
admin_password: str | None = None,
|
||||
) -> Task:
|
||||
"""
|
||||
Create and persist a PLAYWRIGHT task for Uptime Kuma initial setup.
|
||||
|
||||
Args:
|
||||
db: Async database session
|
||||
tenant_id: UUID of the tenant
|
||||
agent_id: UUID of the agent to assign the task to
|
||||
base_url: The base URL for Uptime Kuma
|
||||
admin_username: Username for the admin account
|
||||
admin_password: Password for admin (auto-generated if None)
|
||||
|
||||
Returns:
|
||||
The created Task object with type="PLAYWRIGHT"
|
||||
"""
|
||||
payload = build_uptime_kuma_initial_setup_step(
|
||||
base_url=base_url,
|
||||
admin_username=admin_username,
|
||||
admin_password=admin_password,
|
||||
)
|
||||
|
||||
task = Task(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
type="PLAYWRIGHT",
|
||||
payload=payload,
|
||||
status=TaskStatus.PENDING.value,
|
||||
)
|
||||
|
||||
db.add(task)
|
||||
await db.commit()
|
||||
await db.refresh(task)
|
||||
|
||||
return task
|
||||
121
letsbe-orchestrator/app/playbooks/vaultwarden.py
Normal file
121
letsbe-orchestrator/app/playbooks/vaultwarden.py
Normal file
@@ -0,0 +1,121 @@
|
||||
"""Vaultwarden password manager deployment playbook.
|
||||
|
||||
Defines the steps required to set up Vaultwarden on a tenant server
|
||||
(ENV_UPDATE + DOCKER_RELOAD). No Playwright setup needed - Vaultwarden
|
||||
uses a web-based registration flow that doesn't require automation.
|
||||
|
||||
Tenant servers must have stacks and env templates under /opt/letsbe.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.task import Task, TaskStatus
|
||||
|
||||
|
||||
class CompositeStep(BaseModel):
|
||||
"""A single step in a composite playbook."""
|
||||
|
||||
type: str = Field(..., description="Task type (e.g., ENV_UPDATE, DOCKER_RELOAD)")
|
||||
payload: dict[str, Any] = Field(
|
||||
default_factory=dict, description="Payload for this step"
|
||||
)
|
||||
|
||||
|
||||
# LetsBe standard paths
|
||||
VAULTWARDEN_ENV_PATH = "/opt/letsbe/env/vaultwarden.env"
|
||||
VAULTWARDEN_STACK_DIR = "/opt/letsbe/stacks/vaultwarden"
|
||||
|
||||
|
||||
def build_vaultwarden_setup_steps(
|
||||
*,
|
||||
domain: str,
|
||||
admin_token: str,
|
||||
signups_allowed: bool = True,
|
||||
) -> list[CompositeStep]:
|
||||
"""
|
||||
Build the sequence of steps required to set up Vaultwarden.
|
||||
|
||||
Assumes the env file already exists at /opt/letsbe/env/vaultwarden.env
|
||||
(created by provisioning/env_setup.sh).
|
||||
|
||||
Args:
|
||||
domain: The domain for Vaultwarden (e.g., "vault.example.com")
|
||||
admin_token: Admin panel access token
|
||||
signups_allowed: Whether new user registration is allowed
|
||||
|
||||
Returns:
|
||||
List of 2 CompositeStep objects:
|
||||
1. ENV_UPDATE - patches DOMAIN, ADMIN_TOKEN, SIGNUPS_ALLOWED
|
||||
2. DOCKER_RELOAD - restarts the vaultwarden stack with pull=True
|
||||
"""
|
||||
steps = [
|
||||
# Step 1: Update environment variables
|
||||
CompositeStep(
|
||||
type="ENV_UPDATE",
|
||||
payload={
|
||||
"path": VAULTWARDEN_ENV_PATH,
|
||||
"updates": {
|
||||
"DOMAIN": f"https://{domain}",
|
||||
"ADMIN_TOKEN": admin_token,
|
||||
"SIGNUPS_ALLOWED": str(signups_allowed).lower(),
|
||||
},
|
||||
},
|
||||
),
|
||||
# Step 2: Reload Docker stack
|
||||
CompositeStep(
|
||||
type="DOCKER_RELOAD",
|
||||
payload={
|
||||
"compose_dir": VAULTWARDEN_STACK_DIR,
|
||||
"pull": True,
|
||||
},
|
||||
),
|
||||
]
|
||||
return steps
|
||||
|
||||
|
||||
async def create_vaultwarden_setup_task(
|
||||
*,
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
agent_id: uuid.UUID | None,
|
||||
domain: str,
|
||||
admin_token: str,
|
||||
signups_allowed: bool = True,
|
||||
) -> Task:
|
||||
"""
|
||||
Create and persist a COMPOSITE task for Vaultwarden setup.
|
||||
|
||||
Args:
|
||||
db: Async database session
|
||||
tenant_id: UUID of the tenant
|
||||
agent_id: Optional UUID of the agent to assign the task to
|
||||
domain: The domain for Vaultwarden
|
||||
admin_token: Admin panel access token
|
||||
signups_allowed: Whether new user registration is allowed
|
||||
|
||||
Returns:
|
||||
The created Task object with type="COMPOSITE"
|
||||
"""
|
||||
steps = build_vaultwarden_setup_steps(
|
||||
domain=domain,
|
||||
admin_token=admin_token,
|
||||
signups_allowed=signups_allowed,
|
||||
)
|
||||
|
||||
task = Task(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
type="COMPOSITE",
|
||||
payload={"steps": [step.model_dump() for step in steps]},
|
||||
status=TaskStatus.PENDING.value,
|
||||
)
|
||||
|
||||
db.add(task)
|
||||
await db.commit()
|
||||
await db.refresh(task)
|
||||
|
||||
return task
|
||||
25
letsbe-orchestrator/app/routes/__init__.py
Normal file
25
letsbe-orchestrator/app/routes/__init__.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""FastAPI route modules."""
|
||||
|
||||
from app.routes.health import router as health_router
|
||||
from app.routes.tasks import router as tasks_router
|
||||
from app.routes.tenants import router as tenants_router
|
||||
from app.routes.agents import router as agents_router
|
||||
from app.routes.playbooks import router as playbooks_router
|
||||
from app.routes.env import router as env_router
|
||||
from app.routes.events import router as events_router
|
||||
from app.routes.files import router as files_router
|
||||
from app.routes.registration_tokens import router as registration_tokens_router
|
||||
from app.routes.meta import router as meta_router
|
||||
|
||||
__all__ = [
|
||||
"health_router",
|
||||
"tenants_router",
|
||||
"tasks_router",
|
||||
"agents_router",
|
||||
"playbooks_router",
|
||||
"env_router",
|
||||
"events_router",
|
||||
"files_router",
|
||||
"registration_tokens_router",
|
||||
"meta_router",
|
||||
]
|
||||
529
letsbe-orchestrator/app/routes/agents.py
Normal file
529
letsbe-orchestrator/app/routes/agents.py
Normal file
@@ -0,0 +1,529 @@
|
||||
"""Agent management endpoints."""
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import secrets
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, Response, status
|
||||
from pydantic import ValidationError
|
||||
from slowapi import Limiter
|
||||
from slowapi.util import get_remote_address
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db import AsyncSessionDep
|
||||
from app.dependencies.auth import CurrentAgentDep
|
||||
from app.dependencies.local_agent_auth import verify_local_agent_key
|
||||
from app.models.agent import Agent, AgentStatus
|
||||
from app.models.base import utc_now
|
||||
from app.models.registration_token import RegistrationToken
|
||||
from app.models.tenant import Tenant
|
||||
from app.schemas.agent import (
|
||||
AgentHeartbeatResponse,
|
||||
AgentRegisterRequest,
|
||||
AgentRegisterRequestLegacy,
|
||||
AgentRegisterResponse,
|
||||
AgentRegisterResponseLegacy,
|
||||
AgentResponse,
|
||||
LocalAgentRegisterRequest,
|
||||
LocalAgentRegisterResponse,
|
||||
)
|
||||
from app.services.local_bootstrap import LocalBootstrapService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
limiter = Limiter(key_func=get_remote_address)
|
||||
router = APIRouter(prefix="/agents", tags=["Agents"])
|
||||
|
||||
|
||||
# --- Helper functions (embryonic service layer) ---
|
||||
|
||||
|
||||
async def get_agent_by_id(db: AsyncSessionDep, agent_id: uuid.UUID) -> Agent | None:
|
||||
"""Retrieve an agent by ID."""
|
||||
result = await db.execute(select(Agent).where(Agent.id == agent_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_tenant_by_id(db: AsyncSessionDep, tenant_id: uuid.UUID) -> Tenant | None:
|
||||
"""Retrieve a tenant by ID."""
|
||||
result = await db.execute(select(Tenant).where(Tenant.id == tenant_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_registration_token_by_hash(
|
||||
db: AsyncSessionDep, token_hash: str
|
||||
) -> RegistrationToken | None:
|
||||
"""Retrieve a registration token by its hash."""
|
||||
result = await db.execute(
|
||||
select(RegistrationToken).where(RegistrationToken.token_hash == token_hash)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_agent_by_tenant(
|
||||
db: AsyncSessionDep, tenant_id: uuid.UUID
|
||||
) -> Agent | None:
|
||||
"""Retrieve the first agent for a tenant (used for local mode single-agent)."""
|
||||
result = await db.execute(
|
||||
select(Agent).where(Agent.tenant_id == tenant_id).limit(1)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def validate_agent_token(
|
||||
db: AsyncSessionDep,
|
||||
agent_id: uuid.UUID,
|
||||
authorization: str | None,
|
||||
) -> Agent:
|
||||
"""
|
||||
Validate agent exists and token matches (legacy method).
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
agent_id: Agent UUID
|
||||
authorization: Authorization header value
|
||||
|
||||
Returns:
|
||||
Agent if valid
|
||||
|
||||
Raises:
|
||||
HTTPException: 401 if invalid
|
||||
"""
|
||||
if authorization is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Missing Authorization header",
|
||||
)
|
||||
|
||||
# Parse Bearer token
|
||||
parts = authorization.split(" ", 1)
|
||||
if len(parts) != 2 or parts[0].lower() != "bearer":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid Authorization header format. Expected: Bearer <token>",
|
||||
)
|
||||
|
||||
token = parts[1]
|
||||
|
||||
# Find and validate agent
|
||||
agent = await get_agent_by_id(db, agent_id)
|
||||
if agent is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid agent credentials",
|
||||
)
|
||||
|
||||
# Use secrets.compare_digest for timing-attack-safe comparison
|
||||
if not secrets.compare_digest(agent.token, token):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid agent credentials",
|
||||
)
|
||||
|
||||
return agent
|
||||
|
||||
|
||||
# --- Route handlers (thin controllers) ---
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=list[AgentResponse],
|
||||
summary="List all agents",
|
||||
description="Retrieve all registered agents, optionally filtered by tenant.",
|
||||
)
|
||||
async def list_agents(
|
||||
db: AsyncSessionDep,
|
||||
tenant_id: uuid.UUID | None = None,
|
||||
) -> list[Agent]:
|
||||
"""List all agents, optionally filtered by tenant."""
|
||||
query = select(Agent)
|
||||
if tenant_id:
|
||||
query = query.where(Agent.tenant_id == tenant_id)
|
||||
query = query.order_by(Agent.created_at.desc())
|
||||
|
||||
result = await db.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{agent_id}",
|
||||
response_model=AgentResponse,
|
||||
summary="Get agent by ID",
|
||||
description="Retrieve a specific agent by its UUID.",
|
||||
)
|
||||
async def get_agent(
|
||||
agent_id: uuid.UUID,
|
||||
db: AsyncSessionDep,
|
||||
) -> Agent:
|
||||
"""Get a specific agent by ID."""
|
||||
agent = await get_agent_by_id(db, agent_id)
|
||||
if agent is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Agent {agent_id} not found",
|
||||
)
|
||||
return agent
|
||||
|
||||
|
||||
@router.post(
|
||||
"/register",
|
||||
response_model=AgentRegisterResponse | AgentRegisterResponseLegacy,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Register a new agent",
|
||||
description="""
|
||||
Register a new SysAdmin agent with the orchestrator.
|
||||
|
||||
**New Secure Flow (Recommended):**
|
||||
- Provide `registration_token` obtained from `/api/v1/tenants/{id}/registration-tokens`
|
||||
- The token determines which tenant the agent belongs to
|
||||
- Returns `agent_id`, `agent_secret`, and `tenant_id`
|
||||
- Store `agent_secret` securely - it's only shown once
|
||||
|
||||
**Legacy Flow (Deprecated):**
|
||||
- Provide optional `tenant_id` directly
|
||||
- Returns `agent_id` and `token`
|
||||
- This flow will be removed in a future version
|
||||
""",
|
||||
)
|
||||
@limiter.limit("5/minute")
|
||||
async def register_agent(
|
||||
request: Request,
|
||||
body: dict,
|
||||
db: AsyncSessionDep,
|
||||
) -> AgentRegisterResponse | AgentRegisterResponseLegacy:
|
||||
"""
|
||||
Register a new SysAdmin agent.
|
||||
|
||||
Supports both new (registration_token) and legacy (tenant_id) flows.
|
||||
"""
|
||||
# Determine which registration flow to use
|
||||
if "registration_token" in body:
|
||||
# New secure registration flow
|
||||
try:
|
||||
parsed = AgentRegisterRequest.model_validate(body)
|
||||
except ValidationError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=e.errors(),
|
||||
)
|
||||
return await _register_agent_secure(parsed, db)
|
||||
else:
|
||||
# Legacy registration flow (deprecated)
|
||||
logger.warning(
|
||||
"legacy_registration_used",
|
||||
extra={"message": "Agent using deprecated registration without token"},
|
||||
)
|
||||
try:
|
||||
parsed = AgentRegisterRequestLegacy.model_validate(body)
|
||||
except ValidationError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=e.errors(),
|
||||
)
|
||||
return await _register_agent_legacy(parsed, db)
|
||||
|
||||
|
||||
async def _register_agent_secure(
|
||||
request: AgentRegisterRequest,
|
||||
db: AsyncSessionDep,
|
||||
) -> AgentRegisterResponse:
|
||||
"""Register agent using the new secure token-based flow."""
|
||||
# Hash the provided registration token
|
||||
token_hash = hashlib.sha256(request.registration_token.encode()).hexdigest()
|
||||
|
||||
# Look up the registration token
|
||||
reg_token = await get_registration_token_by_hash(db, token_hash)
|
||||
if reg_token is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid registration token",
|
||||
)
|
||||
|
||||
# Validate token state
|
||||
if not reg_token.is_valid():
|
||||
if reg_token.revoked:
|
||||
detail = "Registration token has been revoked"
|
||||
elif reg_token.expires_at and reg_token.expires_at < utc_now():
|
||||
detail = "Registration token has expired"
|
||||
else:
|
||||
detail = "Registration token has been exhausted"
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=detail,
|
||||
)
|
||||
|
||||
# Increment use count
|
||||
reg_token.use_count += 1
|
||||
|
||||
# Generate agent credentials
|
||||
agent_id = uuid.uuid4()
|
||||
agent_secret = secrets.token_hex(32)
|
||||
secret_hash = hashlib.sha256(agent_secret.encode()).hexdigest()
|
||||
|
||||
# Create agent with tenant from token
|
||||
agent = Agent(
|
||||
id=agent_id,
|
||||
name=request.hostname,
|
||||
version=request.version,
|
||||
status=AgentStatus.ONLINE.value,
|
||||
last_heartbeat=utc_now(),
|
||||
token="", # Legacy field - empty for new agents
|
||||
secret_hash=secret_hash,
|
||||
tenant_id=reg_token.tenant_id,
|
||||
registration_token_id=reg_token.id,
|
||||
)
|
||||
|
||||
db.add(agent)
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
"agent_registered",
|
||||
extra={
|
||||
"agent_id": str(agent_id),
|
||||
"tenant_id": str(reg_token.tenant_id),
|
||||
"hostname": request.hostname,
|
||||
"registration_token_id": str(reg_token.id),
|
||||
},
|
||||
)
|
||||
|
||||
return AgentRegisterResponse(
|
||||
agent_id=agent_id,
|
||||
agent_secret=agent_secret,
|
||||
tenant_id=reg_token.tenant_id,
|
||||
)
|
||||
|
||||
|
||||
async def _register_agent_legacy(
|
||||
request: AgentRegisterRequestLegacy,
|
||||
db: AsyncSessionDep,
|
||||
) -> AgentRegisterResponseLegacy:
|
||||
"""Register agent using the legacy flow (deprecated)."""
|
||||
# Validate tenant exists if provided
|
||||
if request.tenant_id is not None:
|
||||
tenant = await get_tenant_by_id(db, request.tenant_id)
|
||||
if tenant is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Tenant {request.tenant_id} not found",
|
||||
)
|
||||
|
||||
agent_id = uuid.uuid4()
|
||||
token = secrets.token_hex(32)
|
||||
|
||||
# For legacy agents, also compute the secret_hash from the token
|
||||
# This allows them to work with the new auth scheme
|
||||
secret_hash = hashlib.sha256(token.encode()).hexdigest()
|
||||
|
||||
agent = Agent(
|
||||
id=agent_id,
|
||||
name=request.hostname,
|
||||
version=request.version,
|
||||
status=AgentStatus.ONLINE.value,
|
||||
last_heartbeat=utc_now(),
|
||||
token=token, # Legacy field - used for backward compatibility
|
||||
secret_hash=secret_hash, # Also set for new auth scheme
|
||||
tenant_id=request.tenant_id,
|
||||
)
|
||||
|
||||
db.add(agent)
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
"agent_registered_legacy",
|
||||
extra={
|
||||
"agent_id": str(agent_id),
|
||||
"tenant_id": str(request.tenant_id) if request.tenant_id else None,
|
||||
"hostname": request.hostname,
|
||||
},
|
||||
)
|
||||
|
||||
return AgentRegisterResponseLegacy(agent_id=agent_id, token=token)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/register-local",
|
||||
response_model=LocalAgentRegisterResponse,
|
||||
summary="Register agent in LOCAL_MODE",
|
||||
description="""
|
||||
Register the local SysAdmin agent in LOCAL_MODE.
|
||||
|
||||
**Important:** This endpoint only exists when `LOCAL_MODE=true`.
|
||||
|
||||
**Authentication:**
|
||||
- Requires `X-Local-Agent-Key` header (NOT `X-Admin-Api-Key`)
|
||||
- LOCAL_AGENT_KEY has minimal scope - can only register the local agent
|
||||
|
||||
**Idempotent Behavior:**
|
||||
- First call: Creates agent, returns `agent_secret` (201 Created)
|
||||
- Subsequent calls: Returns existing `agent_id`, NO secret (200 OK)
|
||||
- With `rotate=true`: Deletes existing agent, returns new credentials (201 Created)
|
||||
|
||||
**HTTP Status Codes:**
|
||||
- 201: New agent created (or rotated)
|
||||
- 200: Existing agent returned (no secret)
|
||||
- 404: Endpoint hidden (LOCAL_MODE is false)
|
||||
- 401: Invalid or missing LOCAL_AGENT_KEY
|
||||
- 503: Local tenant not bootstrapped yet
|
||||
|
||||
**Security:**
|
||||
- LOCAL_AGENT_KEY is separate from ADMIN_API_KEY (principle of least privilege)
|
||||
- Agent secret is only shown once (on first registration or rotation)
|
||||
- Rotation is logged as a security event
|
||||
""",
|
||||
responses={
|
||||
201: {"description": "Agent created or rotated"},
|
||||
200: {"description": "Existing agent returned (no secret)"},
|
||||
401: {"description": "Invalid LOCAL_AGENT_KEY"},
|
||||
404: {"description": "Endpoint hidden (LOCAL_MODE=false)"},
|
||||
503: {"description": "Local tenant not bootstrapped"},
|
||||
},
|
||||
)
|
||||
@limiter.limit("5/minute")
|
||||
async def register_agent_local(
|
||||
request: Request,
|
||||
body: LocalAgentRegisterRequest,
|
||||
response: Response,
|
||||
db: AsyncSessionDep,
|
||||
rotate: bool = Query(
|
||||
default=False,
|
||||
description="Force credential rotation (deletes existing agent, creates new one)",
|
||||
),
|
||||
_auth: None = Depends(verify_local_agent_key),
|
||||
) -> LocalAgentRegisterResponse:
|
||||
"""
|
||||
Register an agent in LOCAL_MODE using LOCAL_AGENT_KEY.
|
||||
|
||||
This endpoint:
|
||||
- Only works when LOCAL_MODE=true
|
||||
- Requires valid LOCAL_AGENT_KEY (NOT ADMIN_API_KEY)
|
||||
- Creates agent for the auto-bootstrapped local tenant
|
||||
- Idempotent: if agent exists, returns existing agent_id (no new secret)
|
||||
- With rotate=true: deletes existing, creates new with fresh credentials
|
||||
|
||||
Security: LOCAL_AGENT_KEY has minimal scope - can only register
|
||||
the local agent, nothing else.
|
||||
"""
|
||||
# Get local tenant ID from bootstrap service
|
||||
local_tenant_id = LocalBootstrapService.get_local_tenant_id()
|
||||
if local_tenant_id is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Local tenant not bootstrapped. Orchestrator is starting up.",
|
||||
headers={"Retry-After": "5"},
|
||||
)
|
||||
|
||||
# Check if agent already exists for this tenant
|
||||
existing_agent = await get_agent_by_tenant(db, local_tenant_id)
|
||||
|
||||
# Handle rotation request
|
||||
if rotate and existing_agent:
|
||||
logger.warning(
|
||||
"local_agent_credentials_rotated",
|
||||
extra={
|
||||
"agent_id": str(existing_agent.id),
|
||||
"tenant_id": str(local_tenant_id),
|
||||
"hostname": existing_agent.name,
|
||||
"new_hostname": body.hostname,
|
||||
},
|
||||
)
|
||||
await db.delete(existing_agent)
|
||||
await db.commit()
|
||||
existing_agent = None # Proceed to create new agent
|
||||
|
||||
# Idempotent: return existing agent without secret
|
||||
if existing_agent:
|
||||
logger.info(
|
||||
"local_agent_already_registered",
|
||||
extra={
|
||||
"agent_id": str(existing_agent.id),
|
||||
"tenant_id": str(local_tenant_id),
|
||||
"hostname": existing_agent.name,
|
||||
},
|
||||
)
|
||||
response.status_code = status.HTTP_200_OK
|
||||
return LocalAgentRegisterResponse(
|
||||
agent_id=existing_agent.id,
|
||||
tenant_id=local_tenant_id,
|
||||
agent_secret=None,
|
||||
already_registered=True,
|
||||
)
|
||||
|
||||
# Create new agent
|
||||
agent_id = uuid.uuid4()
|
||||
agent_secret = secrets.token_hex(32)
|
||||
secret_hash = hashlib.sha256(agent_secret.encode()).hexdigest()
|
||||
|
||||
agent = Agent(
|
||||
id=agent_id,
|
||||
name=body.hostname,
|
||||
version=body.version,
|
||||
status=AgentStatus.ONLINE.value,
|
||||
last_heartbeat=utc_now(),
|
||||
token="", # Legacy field - empty for new agents
|
||||
secret_hash=secret_hash,
|
||||
tenant_id=local_tenant_id,
|
||||
registration_token_id=None, # No registration token in local mode
|
||||
)
|
||||
|
||||
db.add(agent)
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
"local_agent_registered",
|
||||
extra={
|
||||
"agent_id": str(agent_id),
|
||||
"tenant_id": str(local_tenant_id),
|
||||
"hostname": body.hostname,
|
||||
"rotated": rotate,
|
||||
},
|
||||
)
|
||||
|
||||
response.status_code = status.HTTP_201_CREATED
|
||||
return LocalAgentRegisterResponse(
|
||||
agent_id=agent_id,
|
||||
tenant_id=local_tenant_id,
|
||||
agent_secret=agent_secret,
|
||||
already_registered=False,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{agent_id}/heartbeat",
|
||||
response_model=AgentHeartbeatResponse,
|
||||
summary="Send agent heartbeat",
|
||||
description="""
|
||||
Send a heartbeat from an agent.
|
||||
|
||||
Updates the agent's last_heartbeat timestamp and sets status to online.
|
||||
|
||||
**Authentication:**
|
||||
- New: X-Agent-Id and X-Agent-Secret headers
|
||||
- Legacy: Authorization: Bearer <token> header
|
||||
""",
|
||||
)
|
||||
async def agent_heartbeat(
|
||||
agent_id: uuid.UUID,
|
||||
db: AsyncSessionDep,
|
||||
current_agent: CurrentAgentDep,
|
||||
) -> AgentHeartbeatResponse:
|
||||
"""
|
||||
Send heartbeat from agent.
|
||||
|
||||
Updates last_heartbeat timestamp and sets status to online.
|
||||
"""
|
||||
# Verify the path agent_id matches the authenticated agent
|
||||
if agent_id != current_agent.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Agent ID mismatch",
|
||||
)
|
||||
|
||||
# Update heartbeat
|
||||
current_agent.last_heartbeat = utc_now()
|
||||
current_agent.status = AgentStatus.ONLINE.value
|
||||
|
||||
await db.commit()
|
||||
|
||||
return AgentHeartbeatResponse(status="ok")
|
||||
158
letsbe-orchestrator/app/routes/env.py
Normal file
158
letsbe-orchestrator/app/routes/env.py
Normal file
@@ -0,0 +1,158 @@
|
||||
"""Env management endpoints for creating ENV_INSPECT and ENV_UPDATE tasks."""
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db import AsyncSessionDep
|
||||
from app.models.agent import Agent
|
||||
from app.models.task import Task, TaskStatus
|
||||
from app.models.tenant import Tenant
|
||||
from app.schemas.env import EnvInspectRequest, EnvUpdateRequest
|
||||
from app.schemas.task import TaskResponse
|
||||
|
||||
router = APIRouter(prefix="/agents/{agent_id}/env", tags=["Env Management"])
|
||||
|
||||
|
||||
# --- Helper functions ---
|
||||
|
||||
|
||||
async def get_tenant_by_id(db: AsyncSessionDep, tenant_id: uuid.UUID) -> Tenant | None:
|
||||
"""Retrieve a tenant by ID."""
|
||||
result = await db.execute(select(Tenant).where(Tenant.id == tenant_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_agent_by_id(db: AsyncSessionDep, agent_id: uuid.UUID) -> Agent | None:
|
||||
"""Retrieve an agent by ID."""
|
||||
result = await db.execute(select(Agent).where(Agent.id == agent_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
# --- Route handlers ---
|
||||
|
||||
|
||||
@router.post(
|
||||
"/inspect",
|
||||
response_model=TaskResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def inspect_env(
|
||||
agent_id: uuid.UUID,
|
||||
request: EnvInspectRequest,
|
||||
db: AsyncSessionDep,
|
||||
) -> Task:
|
||||
"""
|
||||
Create an ENV_INSPECT task to read env file contents.
|
||||
|
||||
The SysAdmin Agent will execute this task and return the env file
|
||||
key-value pairs in the task result.
|
||||
|
||||
## Request Body
|
||||
- **tenant_id**: UUID of the tenant
|
||||
- **path**: Path to the env file (e.g., `/opt/letsbe/env/chatwoot.env`)
|
||||
- **keys**: Optional list of specific keys to inspect (returns all if omitted)
|
||||
|
||||
## Response
|
||||
Returns the created Task with type="ENV_INSPECT" and status="pending".
|
||||
"""
|
||||
# Validate tenant exists
|
||||
tenant = await get_tenant_by_id(db, request.tenant_id)
|
||||
if tenant is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Tenant {request.tenant_id} not found",
|
||||
)
|
||||
|
||||
# Validate agent exists
|
||||
agent = await get_agent_by_id(db, agent_id)
|
||||
if agent is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Agent {agent_id} not found",
|
||||
)
|
||||
|
||||
# Build payload
|
||||
payload: dict = {"path": request.path}
|
||||
if request.keys is not None:
|
||||
payload["keys"] = request.keys
|
||||
|
||||
# Create the task
|
||||
task = Task(
|
||||
tenant_id=request.tenant_id,
|
||||
agent_id=agent_id,
|
||||
type="ENV_INSPECT",
|
||||
payload=payload,
|
||||
status=TaskStatus.PENDING.value,
|
||||
)
|
||||
|
||||
db.add(task)
|
||||
await db.commit()
|
||||
await db.refresh(task)
|
||||
|
||||
return task
|
||||
|
||||
|
||||
@router.post(
|
||||
"/update",
|
||||
response_model=TaskResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def update_env(
|
||||
agent_id: uuid.UUID,
|
||||
request: EnvUpdateRequest,
|
||||
db: AsyncSessionDep,
|
||||
) -> Task:
|
||||
"""
|
||||
Create an ENV_UPDATE task to modify env file contents.
|
||||
|
||||
The SysAdmin Agent will execute this task to update or remove
|
||||
key-value pairs in the specified env file.
|
||||
|
||||
## Request Body
|
||||
- **tenant_id**: UUID of the tenant
|
||||
- **path**: Path to the env file (e.g., `/opt/letsbe/env/chatwoot.env`)
|
||||
- **updates**: Optional dict of key-value pairs to set or update
|
||||
- **remove_keys**: Optional list of keys to remove from the env file
|
||||
|
||||
## Response
|
||||
Returns the created Task with type="ENV_UPDATE" and status="pending".
|
||||
"""
|
||||
# Validate tenant exists
|
||||
tenant = await get_tenant_by_id(db, request.tenant_id)
|
||||
if tenant is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Tenant {request.tenant_id} not found",
|
||||
)
|
||||
|
||||
# Validate agent exists
|
||||
agent = await get_agent_by_id(db, agent_id)
|
||||
if agent is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Agent {agent_id} not found",
|
||||
)
|
||||
|
||||
# Build payload
|
||||
payload: dict = {"path": request.path}
|
||||
if request.updates is not None:
|
||||
payload["updates"] = request.updates
|
||||
if request.remove_keys is not None:
|
||||
payload["remove_keys"] = request.remove_keys
|
||||
|
||||
# Create the task
|
||||
task = Task(
|
||||
tenant_id=request.tenant_id,
|
||||
agent_id=agent_id,
|
||||
type="ENV_UPDATE",
|
||||
payload=payload,
|
||||
status=TaskStatus.PENDING.value,
|
||||
)
|
||||
|
||||
db.add(task)
|
||||
await db.commit()
|
||||
await db.refresh(task)
|
||||
|
||||
return task
|
||||
77
letsbe-orchestrator/app/routes/events.py
Normal file
77
letsbe-orchestrator/app/routes/events.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""Event management endpoints."""
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db import AsyncSessionDep
|
||||
from app.dependencies.admin_auth import verify_admin_api_key
|
||||
from app.models.event import Event
|
||||
from app.schemas.event import EventCreate, EventResponse
|
||||
|
||||
router = APIRouter(prefix="/events", tags=["Events"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[EventResponse])
|
||||
async def list_events(
|
||||
db: AsyncSessionDep,
|
||||
event_type: str | None = Query(None, description="Filter by event type"),
|
||||
tenant_id: uuid.UUID | None = Query(None, description="Filter by tenant ID"),
|
||||
limit: int = Query(50, ge=1, le=200, description="Maximum number of events to return"),
|
||||
offset: int = Query(0, ge=0, description="Number of events to skip"),
|
||||
) -> list[Event]:
|
||||
"""List events with optional filtering and pagination."""
|
||||
query = select(Event).order_by(Event.created_at.desc())
|
||||
|
||||
if event_type is not None:
|
||||
query = query.where(Event.event_type == event_type)
|
||||
|
||||
if tenant_id is not None:
|
||||
query = query.where(Event.tenant_id == tenant_id)
|
||||
|
||||
query = query.offset(offset).limit(limit)
|
||||
|
||||
result = await db.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.get("/{event_id}", response_model=EventResponse)
|
||||
async def get_event(
|
||||
event_id: uuid.UUID,
|
||||
db: AsyncSessionDep,
|
||||
) -> Event:
|
||||
"""Get an event by ID."""
|
||||
result = await db.execute(select(Event).where(Event.id == event_id))
|
||||
event = result.scalar_one_or_none()
|
||||
|
||||
if event is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Event {event_id} not found",
|
||||
)
|
||||
|
||||
return event
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=EventResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
dependencies=[Depends(verify_admin_api_key)],
|
||||
)
|
||||
async def create_event(
|
||||
event_in: EventCreate,
|
||||
db: AsyncSessionDep,
|
||||
) -> Event:
|
||||
"""Create a new event (admin auth required)."""
|
||||
event = Event(
|
||||
tenant_id=event_in.tenant_id,
|
||||
task_id=event_in.task_id,
|
||||
event_type=event_in.event_type,
|
||||
payload=event_in.payload,
|
||||
)
|
||||
db.add(event)
|
||||
await db.commit()
|
||||
await db.refresh(event)
|
||||
return event
|
||||
94
letsbe-orchestrator/app/routes/files.py
Normal file
94
letsbe-orchestrator/app/routes/files.py
Normal file
@@ -0,0 +1,94 @@
|
||||
"""File management endpoints for creating FILE_INSPECT tasks."""
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db import AsyncSessionDep
|
||||
from app.models.agent import Agent
|
||||
from app.models.task import Task, TaskStatus
|
||||
from app.models.tenant import Tenant
|
||||
from app.schemas.file import FileInspectRequest
|
||||
from app.schemas.task import TaskResponse
|
||||
|
||||
router = APIRouter(prefix="/agents/{agent_id}/files", tags=["File Management"])
|
||||
|
||||
|
||||
# --- Helper functions ---
|
||||
|
||||
|
||||
async def get_tenant_by_id(db: AsyncSessionDep, tenant_id: uuid.UUID) -> Tenant | None:
|
||||
"""Retrieve a tenant by ID."""
|
||||
result = await db.execute(select(Tenant).where(Tenant.id == tenant_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_agent_by_id(db: AsyncSessionDep, agent_id: uuid.UUID) -> Agent | None:
|
||||
"""Retrieve an agent by ID."""
|
||||
result = await db.execute(select(Agent).where(Agent.id == agent_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
# --- Route handlers ---
|
||||
|
||||
|
||||
@router.post(
|
||||
"/inspect",
|
||||
response_model=TaskResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def inspect_file(
|
||||
agent_id: uuid.UUID,
|
||||
request: FileInspectRequest,
|
||||
db: AsyncSessionDep,
|
||||
) -> Task:
|
||||
"""
|
||||
Create a FILE_INSPECT task to read file contents.
|
||||
|
||||
The SysAdmin Agent will execute this task and return the file
|
||||
contents (up to max_bytes) in the task result.
|
||||
|
||||
## Request Body
|
||||
- **tenant_id**: UUID of the tenant
|
||||
- **path**: Absolute path to the file to inspect
|
||||
- **max_bytes**: Optional max bytes to read (default 4096, max 1MB)
|
||||
|
||||
## Response
|
||||
Returns the created Task with type="FILE_INSPECT" and status="pending".
|
||||
"""
|
||||
# Validate tenant exists
|
||||
tenant = await get_tenant_by_id(db, request.tenant_id)
|
||||
if tenant is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Tenant {request.tenant_id} not found",
|
||||
)
|
||||
|
||||
# Validate agent exists
|
||||
agent = await get_agent_by_id(db, agent_id)
|
||||
if agent is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Agent {agent_id} not found",
|
||||
)
|
||||
|
||||
# Build payload
|
||||
payload: dict = {"path": request.path}
|
||||
if request.max_bytes is not None:
|
||||
payload["max_bytes"] = request.max_bytes
|
||||
|
||||
# Create the task
|
||||
task = Task(
|
||||
tenant_id=request.tenant_id,
|
||||
agent_id=agent_id,
|
||||
type="FILE_INSPECT",
|
||||
payload=payload,
|
||||
status=TaskStatus.PENDING.value,
|
||||
)
|
||||
|
||||
db.add(task)
|
||||
await db.commit()
|
||||
await db.refresh(task)
|
||||
|
||||
return task
|
||||
21
letsbe-orchestrator/app/routes/health.py
Normal file
21
letsbe-orchestrator/app/routes/health.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""Health check endpoints."""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.config import settings
|
||||
from app.schemas.common import HealthResponse
|
||||
|
||||
router = APIRouter(tags=["Health"])
|
||||
|
||||
|
||||
@router.get("/health", response_model=HealthResponse)
|
||||
async def health_check() -> HealthResponse:
|
||||
"""
|
||||
Health check endpoint.
|
||||
|
||||
Returns the current status and version of the API.
|
||||
"""
|
||||
return HealthResponse(
|
||||
status="ok",
|
||||
version=settings.APP_VERSION,
|
||||
)
|
||||
35
letsbe-orchestrator/app/routes/meta.py
Normal file
35
letsbe-orchestrator/app/routes/meta.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""Meta/instance endpoints for diagnostics and identification."""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.config import settings
|
||||
from app.schemas.common import InstanceMetaResponse
|
||||
from app.services.local_bootstrap import LocalBootstrapService
|
||||
|
||||
router = APIRouter(prefix="/meta", tags=["Meta"])
|
||||
|
||||
|
||||
@router.get("/instance", response_model=InstanceMetaResponse)
|
||||
async def get_instance_meta() -> InstanceMetaResponse:
|
||||
"""
|
||||
Get instance metadata.
|
||||
|
||||
This endpoint is stable and works even before tenant bootstrap completes.
|
||||
Use it for diagnostics, health checks, and instance identification.
|
||||
|
||||
Returns:
|
||||
- instance_id: Unique instance identifier (from Hub activation)
|
||||
- local_mode: Whether running in single-tenant local mode
|
||||
- version: Application version
|
||||
- tenant_id: Local tenant ID (null if not bootstrapped or in multi-tenant mode)
|
||||
- bootstrap_status: Detailed bootstrap status for debugging
|
||||
"""
|
||||
tenant_id = LocalBootstrapService.get_local_tenant_id()
|
||||
|
||||
return InstanceMetaResponse(
|
||||
instance_id=settings.INSTANCE_ID,
|
||||
local_mode=settings.LOCAL_MODE,
|
||||
version=settings.APP_VERSION,
|
||||
tenant_id=str(tenant_id) if tenant_id else None,
|
||||
bootstrap_status=LocalBootstrapService.get_bootstrap_status(),
|
||||
)
|
||||
1530
letsbe-orchestrator/app/routes/playbooks.py
Normal file
1530
letsbe-orchestrator/app/routes/playbooks.py
Normal file
File diff suppressed because it is too large
Load Diff
214
letsbe-orchestrator/app/routes/registration_tokens.py
Normal file
214
letsbe-orchestrator/app/routes/registration_tokens.py
Normal file
@@ -0,0 +1,214 @@
|
||||
"""Registration token management endpoints."""
|
||||
|
||||
import hashlib
|
||||
import uuid
|
||||
from datetime import timedelta
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db import AsyncSessionDep
|
||||
from app.dependencies.admin_auth import AdminAuthDep
|
||||
from app.models.base import utc_now
|
||||
from app.models.registration_token import RegistrationToken
|
||||
from app.models.tenant import Tenant
|
||||
from app.schemas.registration_token import (
|
||||
RegistrationTokenCreate,
|
||||
RegistrationTokenCreatedResponse,
|
||||
RegistrationTokenList,
|
||||
RegistrationTokenResponse,
|
||||
)
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/tenants/{tenant_id}/registration-tokens",
|
||||
tags=["Registration Tokens"],
|
||||
dependencies=[AdminAuthDep],
|
||||
)
|
||||
|
||||
|
||||
# --- Helper functions ---
|
||||
|
||||
|
||||
async def get_tenant_by_id(db: AsyncSessionDep, tenant_id: uuid.UUID) -> Tenant | None:
|
||||
"""Retrieve a tenant by ID."""
|
||||
result = await db.execute(select(Tenant).where(Tenant.id == tenant_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_token_by_id(
|
||||
db: AsyncSessionDep, tenant_id: uuid.UUID, token_id: uuid.UUID
|
||||
) -> RegistrationToken | None:
|
||||
"""Retrieve a registration token by ID, scoped to tenant."""
|
||||
result = await db.execute(
|
||||
select(RegistrationToken).where(
|
||||
RegistrationToken.id == token_id,
|
||||
RegistrationToken.tenant_id == tenant_id,
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
# --- Route handlers ---
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=RegistrationTokenCreatedResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Create a registration token",
|
||||
description="""
|
||||
Create a new registration token for a tenant.
|
||||
|
||||
The token can be used by agents to register with the orchestrator.
|
||||
The plaintext token is only returned once - store it securely.
|
||||
|
||||
**Authentication:** Requires X-Admin-Api-Key header.
|
||||
""",
|
||||
)
|
||||
async def create_registration_token(
|
||||
tenant_id: uuid.UUID,
|
||||
request: RegistrationTokenCreate,
|
||||
db: AsyncSessionDep,
|
||||
) -> RegistrationTokenCreatedResponse:
|
||||
"""Create a new registration token for a tenant."""
|
||||
# Verify tenant exists
|
||||
tenant = await get_tenant_by_id(db, tenant_id)
|
||||
if tenant is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Tenant {tenant_id} not found",
|
||||
)
|
||||
|
||||
# Generate token (UUID format for uniqueness)
|
||||
plaintext_token = str(uuid.uuid4())
|
||||
token_hash = hashlib.sha256(plaintext_token.encode()).hexdigest()
|
||||
|
||||
# Calculate expiration if specified
|
||||
expires_at = None
|
||||
if request.expires_in_hours is not None:
|
||||
expires_at = utc_now() + timedelta(hours=request.expires_in_hours)
|
||||
|
||||
# Create token record
|
||||
token_record = RegistrationToken(
|
||||
tenant_id=tenant_id,
|
||||
token_hash=token_hash,
|
||||
description=request.description,
|
||||
max_uses=request.max_uses,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
|
||||
db.add(token_record)
|
||||
await db.commit()
|
||||
await db.refresh(token_record)
|
||||
|
||||
# Return response with plaintext token (only time it's shown)
|
||||
return RegistrationTokenCreatedResponse(
|
||||
id=token_record.id,
|
||||
tenant_id=token_record.tenant_id,
|
||||
description=token_record.description,
|
||||
max_uses=token_record.max_uses,
|
||||
use_count=token_record.use_count,
|
||||
expires_at=token_record.expires_at,
|
||||
revoked=token_record.revoked,
|
||||
created_at=token_record.created_at,
|
||||
created_by=token_record.created_by,
|
||||
token=plaintext_token,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=RegistrationTokenList,
|
||||
summary="List registration tokens",
|
||||
description="""
|
||||
List all registration tokens for a tenant.
|
||||
|
||||
Note: The plaintext token values are not returned.
|
||||
|
||||
**Authentication:** Requires X-Admin-Api-Key header.
|
||||
""",
|
||||
)
|
||||
async def list_registration_tokens(
|
||||
tenant_id: uuid.UUID,
|
||||
db: AsyncSessionDep,
|
||||
) -> RegistrationTokenList:
|
||||
"""List all registration tokens for a tenant."""
|
||||
# Verify tenant exists
|
||||
tenant = await get_tenant_by_id(db, tenant_id)
|
||||
if tenant is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Tenant {tenant_id} not found",
|
||||
)
|
||||
|
||||
# Get all tokens for tenant
|
||||
result = await db.execute(
|
||||
select(RegistrationToken)
|
||||
.where(RegistrationToken.tenant_id == tenant_id)
|
||||
.order_by(RegistrationToken.created_at.desc())
|
||||
)
|
||||
tokens = result.scalars().all()
|
||||
|
||||
return RegistrationTokenList(
|
||||
tokens=[RegistrationTokenResponse.model_validate(t) for t in tokens],
|
||||
total=len(tokens),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{token_id}",
|
||||
response_model=RegistrationTokenResponse,
|
||||
summary="Get registration token details",
|
||||
description="""
|
||||
Get details of a specific registration token.
|
||||
|
||||
Note: The plaintext token value is not returned.
|
||||
|
||||
**Authentication:** Requires X-Admin-Api-Key header.
|
||||
""",
|
||||
)
|
||||
async def get_registration_token(
|
||||
tenant_id: uuid.UUID,
|
||||
token_id: uuid.UUID,
|
||||
db: AsyncSessionDep,
|
||||
) -> RegistrationTokenResponse:
|
||||
"""Get details of a specific registration token."""
|
||||
token = await get_token_by_id(db, tenant_id, token_id)
|
||||
if token is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Registration token {token_id} not found",
|
||||
)
|
||||
|
||||
return RegistrationTokenResponse.model_validate(token)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{token_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
summary="Revoke registration token",
|
||||
description="""
|
||||
Revoke a registration token.
|
||||
|
||||
Revoked tokens cannot be used for new agent registrations.
|
||||
Agents that have already registered with this token will continue to work.
|
||||
|
||||
**Authentication:** Requires X-Admin-Api-Key header.
|
||||
""",
|
||||
)
|
||||
async def revoke_registration_token(
|
||||
tenant_id: uuid.UUID,
|
||||
token_id: uuid.UUID,
|
||||
db: AsyncSessionDep,
|
||||
) -> None:
|
||||
"""Revoke a registration token."""
|
||||
token = await get_token_by_id(db, tenant_id, token_id)
|
||||
if token is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Registration token {token_id} not found",
|
||||
)
|
||||
|
||||
# Mark as revoked
|
||||
token.revoked = True
|
||||
await db.commit()
|
||||
283
letsbe-orchestrator/app/routes/tasks.py
Normal file
283
letsbe-orchestrator/app/routes/tasks.py
Normal file
@@ -0,0 +1,283 @@
|
||||
"""Task management endpoints."""
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, status
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db import AsyncSessionDep
|
||||
from app.dependencies.auth import CurrentAgentDep
|
||||
from app.models.agent import Agent
|
||||
from app.models.task import Task, TaskStatus
|
||||
from app.schemas.task import TaskCreate, TaskResponse, TaskUpdate
|
||||
|
||||
router = APIRouter(prefix="/tasks", tags=["Tasks"])
|
||||
|
||||
|
||||
# --- Helper functions (embryonic service layer) ---
|
||||
|
||||
|
||||
async def create_task(db: AsyncSessionDep, task_in: TaskCreate) -> Task:
|
||||
"""Create a new task in the database."""
|
||||
task = Task(
|
||||
tenant_id=task_in.tenant_id,
|
||||
type=task_in.type,
|
||||
payload=task_in.payload,
|
||||
status=TaskStatus.PENDING.value,
|
||||
)
|
||||
db.add(task)
|
||||
await db.commit()
|
||||
await db.refresh(task)
|
||||
return task
|
||||
|
||||
|
||||
async def get_tasks(
|
||||
db: AsyncSessionDep,
|
||||
tenant_id: uuid.UUID | None = None,
|
||||
task_status: TaskStatus | None = None,
|
||||
) -> list[Task]:
|
||||
"""Retrieve tasks with optional filtering."""
|
||||
query = select(Task).order_by(Task.created_at.desc())
|
||||
|
||||
if tenant_id is not None:
|
||||
query = query.where(Task.tenant_id == tenant_id)
|
||||
|
||||
if task_status is not None:
|
||||
query = query.where(Task.status == task_status.value)
|
||||
|
||||
result = await db.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_task_by_id(db: AsyncSessionDep, task_id: uuid.UUID) -> Task | None:
|
||||
"""Retrieve a task by ID."""
|
||||
result = await db.execute(select(Task).where(Task.id == task_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_task(
|
||||
db: AsyncSessionDep,
|
||||
task: Task,
|
||||
task_update: TaskUpdate,
|
||||
) -> Task:
|
||||
"""Update a task's status and/or result."""
|
||||
if task_update.status is not None:
|
||||
task.status = task_update.status.value
|
||||
if task_update.result is not None:
|
||||
task.result = task_update.result
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(task)
|
||||
return task
|
||||
|
||||
|
||||
# --- Route handlers (thin controllers) ---
|
||||
|
||||
|
||||
@router.post("", response_model=TaskResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_task_endpoint(
|
||||
task_in: TaskCreate,
|
||||
db: AsyncSessionDep,
|
||||
) -> Task:
|
||||
"""
|
||||
Create a new task for agent execution.
|
||||
|
||||
## Parameters
|
||||
- **tenant_id**: UUID of the tenant this task belongs to
|
||||
- **type**: Task type string (see supported types below)
|
||||
- **payload**: JSON payload with task-specific parameters
|
||||
|
||||
## Supported Task Types
|
||||
|
||||
| Type | Description | Payload |
|
||||
|------|-------------|---------|
|
||||
| FILE_WRITE | Write content to a file | `{"path": str, "content": str}` |
|
||||
| ENV_UPDATE | Update .env key/value pairs | `{"path": str, "updates": {str: str}}` |
|
||||
| DOCKER_RELOAD | Reload Docker Compose stack | `{"compose_dir": str}` |
|
||||
| COMPOSITE | Execute sequence of sub-tasks | `{"sequence": [{task, payload}, ...]}` |
|
||||
|
||||
## Agent Behavior
|
||||
1. Agent polls `GET /tasks/next` to claim pending tasks
|
||||
2. Agent executes the task based on type and payload
|
||||
3. Agent updates task status via `PATCH /tasks/{id}`
|
||||
|
||||
## Example Payloads
|
||||
|
||||
**FILE_WRITE:**
|
||||
```json
|
||||
{"path": "/opt/app/config.json", "content": "{\"key\": \"value\"}"}
|
||||
```
|
||||
|
||||
**ENV_UPDATE:**
|
||||
```json
|
||||
{"path": "/opt/app/.env", "updates": {"DB_HOST": "localhost", "DB_PORT": "5432"}}
|
||||
```
|
||||
|
||||
**DOCKER_RELOAD:**
|
||||
```json
|
||||
{"compose_dir": "/opt/stacks/keycloak"}
|
||||
```
|
||||
|
||||
**COMPOSITE:**
|
||||
```json
|
||||
{
|
||||
"sequence": [
|
||||
{"task": "FILE_WRITE", "payload": {"path": "/opt/app/config.json", "content": "{}"}},
|
||||
{"task": "DOCKER_RELOAD", "payload": {"compose_dir": "/opt/stacks/app"}}
|
||||
]
|
||||
}
|
||||
```
|
||||
"""
|
||||
return await create_task(db, task_in)
|
||||
|
||||
|
||||
@router.get("", response_model=list[TaskResponse])
|
||||
async def list_tasks_endpoint(
|
||||
db: AsyncSessionDep,
|
||||
tenant_id: uuid.UUID | None = Query(None, description="Filter by tenant ID"),
|
||||
status: TaskStatus | None = Query(None, description="Filter by task status"),
|
||||
) -> list[Task]:
|
||||
"""
|
||||
List all tasks with optional filtering.
|
||||
|
||||
## Query Parameters
|
||||
- **tenant_id**: Optional filter by tenant UUID
|
||||
- **status**: Optional filter by task status (pending, running, completed, failed)
|
||||
|
||||
## Task Types
|
||||
Tasks may have the following types:
|
||||
- **FILE_WRITE**: Write content to a file
|
||||
- **ENV_UPDATE**: Update .env key/value pairs
|
||||
- **DOCKER_RELOAD**: Reload Docker Compose stack
|
||||
- **COMPOSITE**: Execute sequence of sub-tasks
|
||||
- Legacy types: provision_server, configure_keycloak, etc.
|
||||
|
||||
## Response
|
||||
Returns tasks ordered by created_at descending (newest first).
|
||||
Each task includes: id, tenant_id, agent_id, type, payload, status, result, timestamps.
|
||||
"""
|
||||
return await get_tasks(db, tenant_id=tenant_id, task_status=status)
|
||||
|
||||
|
||||
# --- Agent task acquisition ---
|
||||
# NOTE: /next must be defined BEFORE /{task_id} to avoid path matching issues
|
||||
|
||||
|
||||
async def get_next_pending_task(db: AsyncSessionDep, agent: Agent) -> Task | None:
|
||||
"""Get the oldest pending task for the agent's tenant.
|
||||
|
||||
If the agent has a tenant_id, only returns tasks for that tenant.
|
||||
If the agent has no tenant_id (shared agent), returns any pending task.
|
||||
"""
|
||||
query = select(Task).where(Task.status == TaskStatus.PENDING.value)
|
||||
|
||||
# Filter by agent's tenant if agent is tenant-specific
|
||||
if agent.tenant_id is not None:
|
||||
query = query.where(Task.tenant_id == agent.tenant_id)
|
||||
|
||||
query = query.order_by(Task.created_at.asc()).limit(1)
|
||||
result = await db.execute(query)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
@router.get("/next", response_model=TaskResponse | None)
|
||||
async def get_next_task_endpoint(
|
||||
db: AsyncSessionDep,
|
||||
current_agent: CurrentAgentDep,
|
||||
) -> Task | None:
|
||||
"""
|
||||
Get the next pending task for an agent.
|
||||
|
||||
**Authentication:**
|
||||
- New: X-Agent-Id and X-Agent-Secret headers
|
||||
- Legacy: Authorization: Bearer <token> header
|
||||
|
||||
Atomically claims the oldest pending task by:
|
||||
- Setting status to 'running'
|
||||
- Assigning agent_id to the requesting agent
|
||||
|
||||
Tasks are filtered by the agent's tenant_id:
|
||||
- If agent has a tenant_id, only returns tasks for that tenant
|
||||
- If agent has no tenant_id (shared agent), can claim any task
|
||||
|
||||
Returns null (200) if no pending tasks are available.
|
||||
"""
|
||||
# Get next pending task for this agent's tenant
|
||||
task = await get_next_pending_task(db, current_agent)
|
||||
|
||||
if task is None:
|
||||
return None
|
||||
|
||||
# Claim the task
|
||||
task.status = TaskStatus.RUNNING.value
|
||||
task.agent_id = current_agent.id
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(task)
|
||||
|
||||
return task
|
||||
|
||||
|
||||
@router.get("/{task_id}", response_model=TaskResponse)
|
||||
async def get_task_endpoint(
|
||||
task_id: uuid.UUID,
|
||||
db: AsyncSessionDep,
|
||||
) -> Task:
|
||||
"""
|
||||
Get a task by ID.
|
||||
|
||||
Returns the task with the specified UUID.
|
||||
"""
|
||||
task = await get_task_by_id(db, task_id)
|
||||
if task is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Task {task_id} not found",
|
||||
)
|
||||
return task
|
||||
|
||||
|
||||
@router.patch("/{task_id}", response_model=TaskResponse)
|
||||
async def update_task_endpoint(
|
||||
task_id: uuid.UUID,
|
||||
task_update: TaskUpdate,
|
||||
db: AsyncSessionDep,
|
||||
current_agent: CurrentAgentDep,
|
||||
) -> Task:
|
||||
"""
|
||||
Update a task's status and/or result.
|
||||
|
||||
**Authentication:**
|
||||
- New: X-Agent-Id and X-Agent-Secret headers
|
||||
- Legacy: Authorization: Bearer <token> header
|
||||
|
||||
**Authorization:**
|
||||
- Task must belong to the agent's tenant
|
||||
- Task must be assigned to the requesting agent
|
||||
|
||||
Only status and result fields can be updated.
|
||||
- **status**: New task status
|
||||
- **result**: JSON result payload
|
||||
"""
|
||||
task = await get_task_by_id(db, task_id)
|
||||
if task is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Task {task_id} not found",
|
||||
)
|
||||
|
||||
# Verify tenant ownership (if agent has a tenant_id)
|
||||
if current_agent.tenant_id is not None and task.tenant_id != current_agent.tenant_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Task does not belong to this tenant",
|
||||
)
|
||||
|
||||
# Verify task is assigned to this agent
|
||||
if task.agent_id != current_agent.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Task is not assigned to this agent",
|
||||
)
|
||||
|
||||
return await update_task(db, task, task_update)
|
||||
185
letsbe-orchestrator/app/routes/tenants.py
Normal file
185
letsbe-orchestrator/app/routes/tenants.py
Normal file
@@ -0,0 +1,185 @@
|
||||
"""Tenant management endpoints."""
|
||||
|
||||
import hashlib
|
||||
import secrets
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db import AsyncSessionDep
|
||||
from app.dependencies import AdminAuthDep
|
||||
from app.models.tenant import Tenant
|
||||
from app.schemas.tenant import TenantCreate, TenantResponse
|
||||
|
||||
|
||||
class SetDashboardTokenRequest(BaseModel):
|
||||
"""Request body for setting dashboard token."""
|
||||
|
||||
token: str | None = Field(
|
||||
None,
|
||||
min_length=32,
|
||||
max_length=128,
|
||||
description="Dashboard token (32-128 chars). If None, generates a new token.",
|
||||
)
|
||||
|
||||
|
||||
class SetDashboardTokenResponse(BaseModel):
|
||||
"""Response after setting dashboard token."""
|
||||
|
||||
token: str = Field(..., description="The dashboard token (only shown once)")
|
||||
message: str = Field(default="Dashboard token configured successfully")
|
||||
|
||||
router = APIRouter(prefix="/tenants", tags=["Tenants"])
|
||||
|
||||
|
||||
# --- Helper functions (embryonic service layer) ---
|
||||
|
||||
|
||||
async def create_tenant(db: AsyncSessionDep, tenant_in: TenantCreate) -> Tenant:
|
||||
"""Create a new tenant in the database."""
|
||||
tenant = Tenant(
|
||||
name=tenant_in.name,
|
||||
domain=tenant_in.domain,
|
||||
)
|
||||
db.add(tenant)
|
||||
await db.commit()
|
||||
await db.refresh(tenant)
|
||||
return tenant
|
||||
|
||||
|
||||
async def get_tenants(db: AsyncSessionDep) -> list[Tenant]:
|
||||
"""Retrieve all tenants from the database."""
|
||||
result = await db.execute(select(Tenant).order_by(Tenant.created_at.desc()))
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_tenant_by_id(db: AsyncSessionDep, tenant_id: uuid.UUID) -> Tenant | None:
|
||||
"""Retrieve a tenant by ID."""
|
||||
result = await db.execute(select(Tenant).where(Tenant.id == tenant_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
# --- Route handlers (thin controllers) ---
|
||||
|
||||
|
||||
@router.post("", response_model=TenantResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_tenant_endpoint(
|
||||
tenant_in: TenantCreate,
|
||||
db: AsyncSessionDep,
|
||||
) -> Tenant:
|
||||
"""
|
||||
Create a new tenant.
|
||||
|
||||
- **name**: Unique tenant name (required)
|
||||
- **domain**: Optional domain for the tenant
|
||||
"""
|
||||
return await create_tenant(db, tenant_in)
|
||||
|
||||
|
||||
@router.get("", response_model=list[TenantResponse])
|
||||
async def list_tenants_endpoint(db: AsyncSessionDep) -> list[Tenant]:
|
||||
"""
|
||||
List all tenants.
|
||||
|
||||
Returns a list of all registered tenants.
|
||||
"""
|
||||
return await get_tenants(db)
|
||||
|
||||
|
||||
@router.get("/{tenant_id}", response_model=TenantResponse)
|
||||
async def get_tenant_endpoint(
|
||||
tenant_id: uuid.UUID,
|
||||
db: AsyncSessionDep,
|
||||
) -> Tenant:
|
||||
"""
|
||||
Get a tenant by ID.
|
||||
|
||||
Returns the tenant with the specified UUID.
|
||||
"""
|
||||
tenant = await get_tenant_by_id(db, tenant_id)
|
||||
if tenant is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Tenant {tenant_id} not found",
|
||||
)
|
||||
return tenant
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{tenant_id}/dashboard-token",
|
||||
response_model=SetDashboardTokenResponse,
|
||||
dependencies=[AdminAuthDep],
|
||||
)
|
||||
async def set_dashboard_token_endpoint(
|
||||
tenant_id: uuid.UUID,
|
||||
db: AsyncSessionDep,
|
||||
request: SetDashboardTokenRequest | None = None,
|
||||
) -> SetDashboardTokenResponse:
|
||||
"""
|
||||
Set or regenerate dashboard token for a tenant.
|
||||
|
||||
**Admin-only endpoint** - requires X-Admin-Api-Key header.
|
||||
|
||||
This token is used by the tenant's dashboard (Hub Dashboard or Control Panel)
|
||||
to authenticate requests to the Orchestrator.
|
||||
|
||||
- If `token` is provided, it will be used (must be 32-128 characters)
|
||||
- If `token` is None or not provided, a secure 48-character token is generated
|
||||
|
||||
**IMPORTANT**: The plaintext token is only returned once. Store it securely.
|
||||
"""
|
||||
# Get tenant
|
||||
tenant = await get_tenant_by_id(db, tenant_id)
|
||||
if tenant is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Tenant {tenant_id} not found",
|
||||
)
|
||||
|
||||
# Generate or use provided token
|
||||
if request and request.token:
|
||||
token = request.token
|
||||
else:
|
||||
# Generate secure random token (48 chars = 192 bits of entropy)
|
||||
token = secrets.token_hex(24)
|
||||
|
||||
# Store SHA-256 hash of token
|
||||
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
||||
tenant.dashboard_token_hash = token_hash
|
||||
|
||||
await db.commit()
|
||||
|
||||
return SetDashboardTokenResponse(
|
||||
token=token,
|
||||
message="Dashboard token configured successfully. Store this token securely - it will not be shown again.",
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{tenant_id}/dashboard-token",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[AdminAuthDep],
|
||||
)
|
||||
async def revoke_dashboard_token_endpoint(
|
||||
tenant_id: uuid.UUID,
|
||||
db: AsyncSessionDep,
|
||||
) -> None:
|
||||
"""
|
||||
Revoke/remove dashboard token for a tenant.
|
||||
|
||||
**Admin-only endpoint** - requires X-Admin-Api-Key header.
|
||||
|
||||
After revocation, the tenant's dashboard will no longer be able to
|
||||
authenticate with the Orchestrator until a new token is set.
|
||||
"""
|
||||
tenant = await get_tenant_by_id(db, tenant_id)
|
||||
if tenant is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Tenant {tenant_id} not found",
|
||||
)
|
||||
|
||||
tenant.dashboard_token_hash = None
|
||||
await db.commit()
|
||||
60
letsbe-orchestrator/app/schemas/__init__.py
Normal file
60
letsbe-orchestrator/app/schemas/__init__.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""Pydantic schemas for API request/response validation."""
|
||||
|
||||
from app.schemas.common import HealthResponse, InstanceMetaResponse
|
||||
from app.schemas.tenant import TenantCreate, TenantResponse
|
||||
from app.schemas.task import (
|
||||
TaskCreate,
|
||||
TaskResponse,
|
||||
TaskUpdate,
|
||||
)
|
||||
from app.schemas.agent import (
|
||||
AgentRegisterRequest,
|
||||
AgentRegisterResponse,
|
||||
AgentHeartbeatResponse,
|
||||
AgentResponse,
|
||||
LocalAgentRegisterRequest,
|
||||
LocalAgentRegisterResponse,
|
||||
)
|
||||
from app.schemas.tasks_extended import (
|
||||
FileWritePayload,
|
||||
EnvUpdatePayload,
|
||||
DockerReloadPayload,
|
||||
CompositeSubTask,
|
||||
CompositePayload,
|
||||
)
|
||||
from app.schemas.env import (
|
||||
EnvInspectRequest,
|
||||
EnvUpdateRequest,
|
||||
)
|
||||
from app.schemas.file import FileInspectRequest
|
||||
|
||||
__all__ = [
|
||||
# Common
|
||||
"HealthResponse",
|
||||
"InstanceMetaResponse",
|
||||
# Tenant
|
||||
"TenantCreate",
|
||||
"TenantResponse",
|
||||
# Task
|
||||
"TaskCreate",
|
||||
"TaskResponse",
|
||||
"TaskUpdate",
|
||||
# Task Payloads (for documentation/reference)
|
||||
"FileWritePayload",
|
||||
"EnvUpdatePayload",
|
||||
"DockerReloadPayload",
|
||||
"CompositeSubTask",
|
||||
"CompositePayload",
|
||||
# Agent
|
||||
"AgentRegisterRequest",
|
||||
"AgentRegisterResponse",
|
||||
"AgentHeartbeatResponse",
|
||||
"AgentResponse",
|
||||
"LocalAgentRegisterRequest",
|
||||
"LocalAgentRegisterResponse",
|
||||
# Env Management
|
||||
"EnvInspectRequest",
|
||||
"EnvUpdateRequest",
|
||||
# File Management
|
||||
"FileInspectRequest",
|
||||
]
|
||||
111
letsbe-orchestrator/app/schemas/agent.py
Normal file
111
letsbe-orchestrator/app/schemas/agent.py
Normal file
@@ -0,0 +1,111 @@
|
||||
"""Agent schemas for API validation."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class AgentRegisterRequest(BaseModel):
|
||||
"""Schema for agent registration request (new secure flow)."""
|
||||
|
||||
hostname: str = Field(..., min_length=1, max_length=255)
|
||||
version: str = Field(..., min_length=1, max_length=50)
|
||||
metadata: dict[str, Any] | None = None
|
||||
registration_token: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
description="Registration token issued by the orchestrator",
|
||||
)
|
||||
|
||||
|
||||
class AgentRegisterRequestLegacy(BaseModel):
|
||||
"""Schema for legacy agent registration request (deprecated).
|
||||
|
||||
This schema is kept for backward compatibility during migration.
|
||||
New agents should use AgentRegisterRequest with registration_token.
|
||||
"""
|
||||
|
||||
hostname: str = Field(..., min_length=1, max_length=255)
|
||||
version: str = Field(..., min_length=1, max_length=50)
|
||||
metadata: dict[str, Any] | None = None
|
||||
tenant_id: uuid.UUID | None = Field(
|
||||
default=None,
|
||||
description="Tenant UUID to associate the agent with (DEPRECATED)",
|
||||
)
|
||||
|
||||
|
||||
class AgentRegisterResponse(BaseModel):
|
||||
"""Schema for agent registration response."""
|
||||
|
||||
agent_id: uuid.UUID
|
||||
agent_secret: str = Field(
|
||||
...,
|
||||
description="Agent secret for authentication. Store securely - shown only once.",
|
||||
)
|
||||
tenant_id: uuid.UUID = Field(
|
||||
...,
|
||||
description="Tenant this agent is associated with",
|
||||
)
|
||||
|
||||
|
||||
class LocalAgentRegisterRequest(BaseModel):
|
||||
"""Schema for LOCAL_MODE agent registration request.
|
||||
|
||||
Unlike AgentRegisterRequest, this does NOT include registration_token
|
||||
because LOCAL_MODE uses X-Local-Agent-Key header authentication.
|
||||
"""
|
||||
|
||||
hostname: str = Field(..., min_length=1, max_length=255)
|
||||
version: str = Field(..., min_length=1, max_length=50)
|
||||
metadata: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class LocalAgentRegisterResponse(BaseModel):
|
||||
"""Schema for LOCAL_MODE agent registration response (idempotent).
|
||||
|
||||
This endpoint is idempotent:
|
||||
- First registration: returns agent_id, agent_secret, already_registered=False
|
||||
- Subsequent calls: returns agent_id, NO secret, already_registered=True
|
||||
- With rotate=True: deletes existing, returns new credentials
|
||||
"""
|
||||
|
||||
agent_id: uuid.UUID
|
||||
tenant_id: uuid.UUID
|
||||
agent_secret: str | None = Field(
|
||||
default=None,
|
||||
description="Agent secret. Only returned on first registration or rotation.",
|
||||
)
|
||||
already_registered: bool = Field(
|
||||
default=False,
|
||||
description="True if returning existing agent (no new secret).",
|
||||
)
|
||||
|
||||
|
||||
class AgentRegisterResponseLegacy(BaseModel):
|
||||
"""Schema for legacy agent registration response (deprecated)."""
|
||||
|
||||
agent_id: uuid.UUID
|
||||
token: str
|
||||
|
||||
|
||||
class AgentHeartbeatResponse(BaseModel):
|
||||
"""Schema for agent heartbeat response."""
|
||||
|
||||
status: str = "ok"
|
||||
|
||||
|
||||
class AgentResponse(BaseModel):
|
||||
"""Schema for agent response."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
tenant_id: uuid.UUID | None
|
||||
name: str
|
||||
version: str
|
||||
status: str
|
||||
last_heartbeat: datetime | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
39
letsbe-orchestrator/app/schemas/common.py
Normal file
39
letsbe-orchestrator/app/schemas/common.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""Common schemas used across the API."""
|
||||
|
||||
from typing import Generic, Optional, TypeVar
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
"""Health check response schema."""
|
||||
|
||||
status: str
|
||||
version: str
|
||||
|
||||
|
||||
class InstanceMetaResponse(BaseModel):
|
||||
"""
|
||||
Instance metadata response.
|
||||
|
||||
This endpoint is stable even before tenant bootstrap completes.
|
||||
Used for diagnostics and instance identification.
|
||||
"""
|
||||
|
||||
instance_id: Optional[str] = None
|
||||
local_mode: bool
|
||||
version: str
|
||||
tenant_id: Optional[str] = None
|
||||
bootstrap_status: dict
|
||||
|
||||
|
||||
class PaginatedResponse(BaseModel, Generic[T]):
|
||||
"""Generic paginated response wrapper."""
|
||||
|
||||
items: list[T]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
total_pages: int
|
||||
32
letsbe-orchestrator/app/schemas/env.py
Normal file
32
letsbe-orchestrator/app/schemas/env.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""Pydantic schemas for env management endpoints."""
|
||||
|
||||
import uuid
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class EnvInspectRequest(BaseModel):
|
||||
"""Request body for env inspect endpoint."""
|
||||
|
||||
tenant_id: uuid.UUID = Field(..., description="UUID of the tenant")
|
||||
path: str = Field(
|
||||
..., min_length=1, description="Path to .env file (e.g., /opt/letsbe/env/chatwoot.env)"
|
||||
)
|
||||
keys: list[str] | None = Field(
|
||||
None, description="Optional list of specific keys to inspect"
|
||||
)
|
||||
|
||||
|
||||
class EnvUpdateRequest(BaseModel):
|
||||
"""Request body for env update endpoint."""
|
||||
|
||||
tenant_id: uuid.UUID = Field(..., description="UUID of the tenant")
|
||||
path: str = Field(
|
||||
..., min_length=1, description="Path to .env file (e.g., /opt/letsbe/env/chatwoot.env)"
|
||||
)
|
||||
updates: dict[str, str] | None = Field(
|
||||
None, description="Key-value pairs to set or update"
|
||||
)
|
||||
remove_keys: list[str] | None = Field(
|
||||
None, description="Keys to remove from the env file"
|
||||
)
|
||||
37
letsbe-orchestrator/app/schemas/event.py
Normal file
37
letsbe-orchestrator/app/schemas/event.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""Event schemas for API validation."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class EventCreate(BaseModel):
|
||||
"""Schema for creating a new event."""
|
||||
|
||||
tenant_id: uuid.UUID
|
||||
task_id: uuid.UUID | None = None
|
||||
event_type: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
max_length=100,
|
||||
description="Event type identifier (e.g. agent.registered, task.completed)",
|
||||
)
|
||||
payload: dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description="Event-specific payload data",
|
||||
)
|
||||
|
||||
|
||||
class EventResponse(BaseModel):
|
||||
"""Schema for event response."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
tenant_id: uuid.UUID
|
||||
task_id: uuid.UUID | None
|
||||
event_type: str
|
||||
payload: dict[str, Any]
|
||||
created_at: datetime
|
||||
20
letsbe-orchestrator/app/schemas/file.py
Normal file
20
letsbe-orchestrator/app/schemas/file.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""Pydantic schemas for file management endpoints."""
|
||||
|
||||
import uuid
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class FileInspectRequest(BaseModel):
|
||||
"""Request body for FILE_INSPECT tasks."""
|
||||
|
||||
tenant_id: uuid.UUID = Field(..., description="UUID of the tenant")
|
||||
path: str = Field(
|
||||
..., min_length=1, description="Absolute path to the file to inspect"
|
||||
)
|
||||
max_bytes: int | None = Field(
|
||||
4096,
|
||||
ge=1,
|
||||
le=1048576,
|
||||
description="Max bytes to read from file (default 4096, max 1MB)",
|
||||
)
|
||||
63
letsbe-orchestrator/app/schemas/registration_token.py
Normal file
63
letsbe-orchestrator/app/schemas/registration_token.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""Registration token schemas for API validation."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class RegistrationTokenCreate(BaseModel):
|
||||
"""Schema for creating a new registration token."""
|
||||
|
||||
description: str | None = Field(
|
||||
default=None,
|
||||
max_length=255,
|
||||
description="Human-readable description for this token",
|
||||
)
|
||||
max_uses: int = Field(
|
||||
default=1,
|
||||
ge=0,
|
||||
description="Maximum number of times this token can be used (0 = unlimited)",
|
||||
)
|
||||
expires_in_hours: int | None = Field(
|
||||
default=None,
|
||||
ge=1,
|
||||
le=8760, # Max 1 year
|
||||
description="Number of hours until this token expires (optional)",
|
||||
)
|
||||
|
||||
|
||||
class RegistrationTokenResponse(BaseModel):
|
||||
"""Schema for registration token response (without plaintext token)."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
tenant_id: uuid.UUID
|
||||
description: str | None
|
||||
max_uses: int
|
||||
use_count: int
|
||||
expires_at: datetime | None
|
||||
revoked: bool
|
||||
created_at: datetime
|
||||
created_by: str | None
|
||||
|
||||
|
||||
class RegistrationTokenCreatedResponse(RegistrationTokenResponse):
|
||||
"""Schema for registration token creation response.
|
||||
|
||||
This is the only time the plaintext token is returned to the client.
|
||||
It must be securely stored as it cannot be retrieved again.
|
||||
"""
|
||||
|
||||
token: str = Field(
|
||||
...,
|
||||
description="The plaintext registration token. Store this securely - it cannot be retrieved again.",
|
||||
)
|
||||
|
||||
|
||||
class RegistrationTokenList(BaseModel):
|
||||
"""Schema for listing registration tokens."""
|
||||
|
||||
tokens: list[RegistrationTokenResponse]
|
||||
total: int
|
||||
70
letsbe-orchestrator/app/schemas/task.py
Normal file
70
letsbe-orchestrator/app/schemas/task.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""Task schemas for API validation."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.models.task import TaskStatus
|
||||
|
||||
|
||||
class TaskCreate(BaseModel):
|
||||
"""
|
||||
Schema for creating a new task.
|
||||
|
||||
Supported task types and their expected payloads:
|
||||
|
||||
**FILE_WRITE** - Write content to a file
|
||||
payload: {"path": "/absolute/path", "content": "file content"}
|
||||
|
||||
**ENV_UPDATE** - Update key/value pairs in a .env file
|
||||
payload: {"path": "/path/to/.env", "updates": {"KEY": "value"}}
|
||||
|
||||
**DOCKER_RELOAD** - Reload a Docker Compose stack
|
||||
payload: {"compose_dir": "/path/to/compose/dir"}
|
||||
|
||||
**COMPOSITE** - Execute a sequence of sub-tasks
|
||||
payload: {"sequence": [{"task": "FILE_WRITE", "payload": {...}}, ...]}
|
||||
|
||||
Legacy types (still supported):
|
||||
- provision_server, configure_keycloak, configure_minio, etc.
|
||||
|
||||
Note: Payload validation is performed agent-side. The orchestrator
|
||||
accepts any dict payload to allow flexibility and forward compatibility.
|
||||
"""
|
||||
|
||||
tenant_id: uuid.UUID
|
||||
type: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
max_length=100,
|
||||
description="Task type (FILE_WRITE, ENV_UPDATE, DOCKER_RELOAD, COMPOSITE, etc.)",
|
||||
)
|
||||
payload: dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description="Task-specific payload (see docstring for formats)",
|
||||
)
|
||||
|
||||
|
||||
class TaskUpdate(BaseModel):
|
||||
"""Schema for updating a task (status and result only)."""
|
||||
|
||||
status: TaskStatus | None = None
|
||||
result: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class TaskResponse(BaseModel):
|
||||
"""Schema for task response."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
tenant_id: uuid.UUID
|
||||
agent_id: uuid.UUID | None
|
||||
type: str
|
||||
payload: dict[str, Any]
|
||||
status: str
|
||||
result: dict[str, Any] | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
73
letsbe-orchestrator/app/schemas/tasks_extended.py
Normal file
73
letsbe-orchestrator/app/schemas/tasks_extended.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""Extended task payload schemas for SysAdmin Agent automation.
|
||||
|
||||
These schemas define the expected payload structure for each task type.
|
||||
Validation is performed agent-side; the orchestrator accepts any dict payload.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class FileWritePayload(BaseModel):
|
||||
"""
|
||||
Payload for FILE_WRITE task type.
|
||||
|
||||
Instructs the agent to write content to a file at the specified path.
|
||||
"""
|
||||
|
||||
path: str = Field(..., description="Absolute path to the target file")
|
||||
content: str = Field(..., description="Content to write to the file")
|
||||
|
||||
|
||||
class EnvUpdatePayload(BaseModel):
|
||||
"""
|
||||
Payload for ENV_UPDATE task type.
|
||||
|
||||
Instructs the agent to update key/value pairs in an .env file.
|
||||
Existing keys are updated; new keys are appended.
|
||||
"""
|
||||
|
||||
path: str = Field(..., description="Absolute path to the .env file")
|
||||
updates: dict[str, str] = Field(
|
||||
..., description="Key-value pairs to update or add"
|
||||
)
|
||||
|
||||
|
||||
class DockerReloadPayload(BaseModel):
|
||||
"""
|
||||
Payload for DOCKER_RELOAD task type.
|
||||
|
||||
Instructs the agent to reload a Docker Compose stack.
|
||||
Equivalent to: docker compose down && docker compose up -d
|
||||
"""
|
||||
|
||||
compose_dir: str = Field(
|
||||
..., description="Directory containing docker-compose.yml"
|
||||
)
|
||||
|
||||
|
||||
class CompositeSubTask(BaseModel):
|
||||
"""
|
||||
A single sub-task within a COMPOSITE task.
|
||||
|
||||
Represents one step in a multi-step automation sequence.
|
||||
"""
|
||||
|
||||
task: str = Field(..., description="Task type (e.g., FILE_WRITE, ENV_UPDATE)")
|
||||
payload: dict[str, Any] = Field(
|
||||
default_factory=dict, description="Payload for this sub-task"
|
||||
)
|
||||
|
||||
|
||||
class CompositePayload(BaseModel):
|
||||
"""
|
||||
Payload for COMPOSITE task type.
|
||||
|
||||
Instructs the agent to execute a sequence of sub-tasks in order.
|
||||
If any sub-task fails, the sequence stops and the composite task fails.
|
||||
"""
|
||||
|
||||
sequence: list[CompositeSubTask] = Field(
|
||||
..., description="Ordered list of sub-tasks to execute"
|
||||
)
|
||||
45
letsbe-orchestrator/app/schemas/tenant.py
Normal file
45
letsbe-orchestrator/app/schemas/tenant.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""Tenant schemas for API validation."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.tenant import Tenant
|
||||
|
||||
|
||||
class TenantCreate(BaseModel):
|
||||
"""Schema for creating a new tenant."""
|
||||
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
domain: str | None = Field(None, max_length=255)
|
||||
|
||||
|
||||
class TenantResponse(BaseModel):
|
||||
"""Schema for tenant response."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
domain: str | None
|
||||
has_dashboard_token: bool = Field(
|
||||
default=False,
|
||||
description="Whether a dashboard token has been configured",
|
||||
)
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
@classmethod
|
||||
def from_orm_with_token_check(cls, tenant: "Tenant") -> "TenantResponse":
|
||||
"""Create response with dashboard token check."""
|
||||
return cls(
|
||||
id=tenant.id,
|
||||
name=tenant.name,
|
||||
domain=tenant.domain,
|
||||
has_dashboard_token=tenant.dashboard_token_hash is not None,
|
||||
created_at=tenant.created_at,
|
||||
updated_at=tenant.updated_at,
|
||||
)
|
||||
5
letsbe-orchestrator/app/services/__init__.py
Normal file
5
letsbe-orchestrator/app/services/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Service layer for the Orchestrator."""
|
||||
|
||||
from app.services.local_bootstrap import LocalBootstrapService
|
||||
|
||||
__all__ = ["LocalBootstrapService"]
|
||||
270
letsbe-orchestrator/app/services/hub_telemetry.py
Normal file
270
letsbe-orchestrator/app/services/hub_telemetry.py
Normal file
@@ -0,0 +1,270 @@
|
||||
"""Hub Telemetry Service - sends aggregated metrics to Hub.
|
||||
|
||||
This background service periodically collects metrics from the local database
|
||||
and sends them to the central Hub for license compliance and usage analytics.
|
||||
|
||||
Key design choices:
|
||||
- Since-last-send windowing (avoids double-counting)
|
||||
- SQL aggregates (never loads task objects into Python)
|
||||
- Reusable httpx.AsyncClient (single connection pool)
|
||||
- Jitter ±15% (prevents thundering herd)
|
||||
- Exponential backoff on errors (1s → 2s → 4s → ... → 60s max)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import random
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.config import get_settings
|
||||
from app.db import async_session_maker
|
||||
from app.models.agent import Agent, AgentStatus
|
||||
from app.models.server import Server
|
||||
from app.models.task import Task
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class HubTelemetryService:
|
||||
"""Background service that sends telemetry to Hub."""
|
||||
|
||||
_task: Optional[asyncio.Task] = None
|
||||
_shutdown_event: Optional[asyncio.Event] = None
|
||||
_start_time: Optional[datetime] = None
|
||||
_last_sent_at: Optional[datetime] = None
|
||||
_client: Optional[httpx.AsyncClient] = None
|
||||
_consecutive_failures: int = 0
|
||||
|
||||
@classmethod
|
||||
async def start(cls) -> None:
|
||||
"""Start the telemetry background task. Never blocks startup."""
|
||||
if not settings.HUB_TELEMETRY_ENABLED:
|
||||
logger.info("hub_telemetry_disabled")
|
||||
return
|
||||
|
||||
if not settings.HUB_URL:
|
||||
logger.warning("hub_telemetry_missing_hub_url")
|
||||
return
|
||||
|
||||
if not settings.HUB_API_KEY:
|
||||
logger.warning("hub_telemetry_missing_hub_api_key")
|
||||
return
|
||||
|
||||
if not settings.INSTANCE_ID:
|
||||
logger.warning("hub_telemetry_missing_instance_id")
|
||||
return
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
cls._start_time = now
|
||||
# Initialize window to (now - interval) so first send isn't empty
|
||||
cls._last_sent_at = now - timedelta(
|
||||
seconds=settings.HUB_TELEMETRY_INTERVAL_SECONDS
|
||||
)
|
||||
cls._shutdown_event = asyncio.Event()
|
||||
cls._consecutive_failures = 0
|
||||
cls._client = httpx.AsyncClient(timeout=30.0)
|
||||
cls._task = asyncio.create_task(cls._telemetry_loop())
|
||||
|
||||
logger.info(
|
||||
"hub_telemetry_started",
|
||||
extra={
|
||||
"interval_seconds": settings.HUB_TELEMETRY_INTERVAL_SECONDS,
|
||||
"hub_url": settings.HUB_URL,
|
||||
"instance_id": settings.INSTANCE_ID,
|
||||
},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def stop(cls) -> None:
|
||||
"""Stop the telemetry background task."""
|
||||
if cls._shutdown_event:
|
||||
cls._shutdown_event.set()
|
||||
|
||||
if cls._task:
|
||||
try:
|
||||
await asyncio.wait_for(cls._task, timeout=5.0)
|
||||
except asyncio.TimeoutError:
|
||||
cls._task.cancel()
|
||||
try:
|
||||
await cls._task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
if cls._client:
|
||||
await cls._client.aclose()
|
||||
cls._client = None
|
||||
|
||||
logger.info("hub_telemetry_stopped")
|
||||
|
||||
@classmethod
|
||||
async def _telemetry_loop(cls) -> None:
|
||||
"""Main telemetry loop with jitter and backoff."""
|
||||
base_interval = settings.HUB_TELEMETRY_INTERVAL_SECONDS
|
||||
|
||||
while not cls._shutdown_event.is_set():
|
||||
try:
|
||||
await cls._send_telemetry()
|
||||
cls._consecutive_failures = 0 # Reset on success
|
||||
except Exception as e:
|
||||
cls._consecutive_failures += 1
|
||||
logger.warning(
|
||||
"hub_telemetry_send_failed",
|
||||
extra={
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
"consecutive_failures": cls._consecutive_failures,
|
||||
},
|
||||
)
|
||||
|
||||
# Calculate interval: base ± 15% jitter, with backoff on failures
|
||||
jitter = random.uniform(-0.15, 0.15) * base_interval
|
||||
backoff = (
|
||||
min(2**cls._consecutive_failures, 60)
|
||||
if cls._consecutive_failures
|
||||
else 0
|
||||
)
|
||||
interval = base_interval + jitter + backoff
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
cls._shutdown_event.wait(), timeout=interval
|
||||
)
|
||||
break # Shutdown requested
|
||||
except asyncio.TimeoutError:
|
||||
pass # Normal timeout, continue loop
|
||||
|
||||
@classmethod
|
||||
async def _send_telemetry(cls) -> None:
|
||||
"""Collect and send telemetry to Hub."""
|
||||
window_start = cls._last_sent_at
|
||||
window_end = datetime.now(timezone.utc)
|
||||
|
||||
payload = await cls._collect_metrics(window_start, window_end)
|
||||
|
||||
response = await cls._client.post(
|
||||
f"{settings.HUB_URL}/api/v1/instances/{settings.INSTANCE_ID}/telemetry",
|
||||
json=payload,
|
||||
headers={"X-Hub-Api-Key": settings.HUB_API_KEY},
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
# Only update window on success
|
||||
cls._last_sent_at = window_end
|
||||
|
||||
logger.debug(
|
||||
"hub_telemetry_sent",
|
||||
extra={
|
||||
"window_seconds": (window_end - window_start).total_seconds(),
|
||||
"status_code": response.status_code,
|
||||
},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def _collect_metrics(
|
||||
cls, window_start: datetime, window_end: datetime
|
||||
) -> dict[str, Any]:
|
||||
"""Collect metrics using SQL aggregates (never load objects)."""
|
||||
async with async_session_maker() as db:
|
||||
# Agent counts by status (all agents, not windowed)
|
||||
agent_result = await db.execute(
|
||||
select(Agent.status, func.count(Agent.id).label("count")).group_by(
|
||||
Agent.status
|
||||
)
|
||||
)
|
||||
agent_rows = agent_result.all()
|
||||
|
||||
# Task counts by status and type (windowed by updated_at)
|
||||
# Duration approximated as (updated_at - created_at) for completed/failed tasks
|
||||
task_result = await db.execute(
|
||||
select(
|
||||
Task.status,
|
||||
Task.type,
|
||||
func.count(Task.id).label("count"),
|
||||
func.avg(
|
||||
func.extract("epoch", Task.updated_at - Task.created_at) * 1000
|
||||
).label("avg_duration_ms"),
|
||||
)
|
||||
.where(Task.updated_at.between(window_start, window_end))
|
||||
.group_by(Task.status, Task.type)
|
||||
)
|
||||
task_rows = task_result.all()
|
||||
|
||||
# Server count (simple count, not windowed)
|
||||
server_count = await db.scalar(select(func.count(Server.id)))
|
||||
|
||||
return {
|
||||
"instance_id": str(settings.INSTANCE_ID),
|
||||
"window_start": window_start.isoformat(),
|
||||
"window_end": window_end.isoformat(),
|
||||
"uptime_seconds": int((window_end - cls._start_time).total_seconds()),
|
||||
"metrics": {
|
||||
"agents": cls._format_agent_counts(agent_rows),
|
||||
"tasks": cls._format_task_counts(task_rows),
|
||||
"servers": {"total_count": server_count or 0},
|
||||
},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _format_agent_counts(cls, rows: list) -> dict[str, int]:
|
||||
"""Format agent count rows into response structure."""
|
||||
counts = {
|
||||
"online_count": 0,
|
||||
"offline_count": 0,
|
||||
"total_count": 0,
|
||||
}
|
||||
|
||||
for row in rows:
|
||||
status, count = row.status, row.count
|
||||
counts["total_count"] += count
|
||||
|
||||
if status == AgentStatus.ONLINE:
|
||||
counts["online_count"] = count
|
||||
elif status == AgentStatus.OFFLINE:
|
||||
counts["offline_count"] = count
|
||||
# INVALID agents are counted in total but not separately
|
||||
|
||||
return counts
|
||||
|
||||
@classmethod
|
||||
def _format_task_counts(cls, rows: list) -> dict[str, Any]:
|
||||
"""Format task count rows into response structure."""
|
||||
by_status: dict[str, int] = {}
|
||||
by_type: dict[str, dict[str, Any]] = {}
|
||||
|
||||
for row in rows:
|
||||
status_str = row.status.value if hasattr(row.status, "value") else str(row.status)
|
||||
type_str = row.type.value if hasattr(row.type, "value") else str(row.type)
|
||||
count = row.count
|
||||
avg_duration_ms = row.avg_duration_ms
|
||||
|
||||
# Aggregate by status
|
||||
by_status[status_str] = by_status.get(status_str, 0) + count
|
||||
|
||||
# Aggregate by type
|
||||
if type_str not in by_type:
|
||||
by_type[type_str] = {"count": 0, "avg_duration_ms": 0}
|
||||
|
||||
# Weighted average for duration when merging
|
||||
existing = by_type[type_str]
|
||||
total_count = existing["count"] + count
|
||||
if total_count > 0 and avg_duration_ms is not None:
|
||||
existing_weighted = existing["avg_duration_ms"] * existing["count"]
|
||||
new_weighted = avg_duration_ms * count
|
||||
by_type[type_str]["avg_duration_ms"] = (
|
||||
existing_weighted + new_weighted
|
||||
) / total_count
|
||||
by_type[type_str]["count"] = total_count
|
||||
|
||||
# Round durations for cleaner output
|
||||
for type_data in by_type.values():
|
||||
type_data["avg_duration_ms"] = round(type_data["avg_duration_ms"], 2)
|
||||
|
||||
return {
|
||||
"by_status": by_status,
|
||||
"by_type": by_type,
|
||||
}
|
||||
167
letsbe-orchestrator/app/services/local_bootstrap.py
Normal file
167
letsbe-orchestrator/app/services/local_bootstrap.py
Normal file
@@ -0,0 +1,167 @@
|
||||
"""
|
||||
Local bootstrap service for single-tenant mode.
|
||||
|
||||
Handles automatic tenant creation when LOCAL_MODE is enabled.
|
||||
Designed to be migration-safe: gracefully handles cases where
|
||||
database tables don't exist yet.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy.exc import OperationalError, ProgrammingError
|
||||
|
||||
from app.config import settings
|
||||
from app.db import async_session_maker
|
||||
from app.models import Tenant
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LocalBootstrapService:
|
||||
"""
|
||||
Service for bootstrapping local single-tenant mode.
|
||||
|
||||
When LOCAL_MODE=true:
|
||||
- Waits for database migrations to complete
|
||||
- Creates or retrieves the local tenant
|
||||
- Makes tenant_id available for the meta endpoint
|
||||
|
||||
When LOCAL_MODE=false:
|
||||
- Does nothing (multi-tenant mode unchanged)
|
||||
"""
|
||||
|
||||
# Class-level state for meta endpoint access
|
||||
_local_tenant_id: Optional[UUID] = None
|
||||
_bootstrap_attempted: bool = False
|
||||
_bootstrap_error: Optional[str] = None
|
||||
|
||||
# Bootstrap configuration
|
||||
MAX_RETRIES = 30 # Max attempts waiting for migrations
|
||||
RETRY_DELAY_SECONDS = 2 # Delay between retries
|
||||
LOCAL_TENANT_NAME = "local"
|
||||
|
||||
@classmethod
|
||||
def get_local_tenant_id(cls) -> Optional[UUID]:
|
||||
"""Get the local tenant ID if bootstrap succeeded."""
|
||||
return cls._local_tenant_id
|
||||
|
||||
@classmethod
|
||||
def get_bootstrap_status(cls) -> dict:
|
||||
"""Get bootstrap status for diagnostics."""
|
||||
return {
|
||||
"attempted": cls._bootstrap_attempted,
|
||||
"success": cls._local_tenant_id is not None,
|
||||
"tenant_id": str(cls._local_tenant_id) if cls._local_tenant_id else None,
|
||||
"error": cls._bootstrap_error,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
async def run(cls) -> None:
|
||||
"""
|
||||
Run the bootstrap process.
|
||||
|
||||
Only executes if LOCAL_MODE is enabled.
|
||||
Safe to call multiple times (idempotent).
|
||||
"""
|
||||
if not settings.LOCAL_MODE:
|
||||
logger.debug("LOCAL_MODE is disabled, skipping bootstrap")
|
||||
return
|
||||
|
||||
if cls._bootstrap_attempted:
|
||||
logger.debug("Bootstrap already attempted, skipping")
|
||||
return
|
||||
|
||||
cls._bootstrap_attempted = True
|
||||
logger.info("LOCAL_MODE enabled, starting local tenant bootstrap")
|
||||
|
||||
# Validate required settings
|
||||
if not settings.INSTANCE_ID:
|
||||
cls._bootstrap_error = "INSTANCE_ID is required when LOCAL_MODE is enabled"
|
||||
logger.error(cls._bootstrap_error)
|
||||
return
|
||||
|
||||
try:
|
||||
await cls._bootstrap_with_retry()
|
||||
except Exception as e:
|
||||
cls._bootstrap_error = str(e)
|
||||
logger.exception("Bootstrap failed with unexpected error")
|
||||
|
||||
@classmethod
|
||||
async def _bootstrap_with_retry(cls) -> None:
|
||||
"""
|
||||
Attempt bootstrap with retry logic for migration safety.
|
||||
|
||||
Waits for the tenants table to exist before proceeding.
|
||||
"""
|
||||
for attempt in range(1, cls.MAX_RETRIES + 1):
|
||||
try:
|
||||
await cls._ensure_local_tenant()
|
||||
logger.info(f"Local tenant bootstrap succeeded (tenant_id={cls._local_tenant_id})")
|
||||
return
|
||||
except (OperationalError, ProgrammingError) as e:
|
||||
# These errors typically indicate migrations haven't run yet
|
||||
error_msg = str(e).lower()
|
||||
if "does not exist" in error_msg or "no such table" in error_msg:
|
||||
if attempt < cls.MAX_RETRIES:
|
||||
logger.warning(
|
||||
f"Database table not ready (attempt {attempt}/{cls.MAX_RETRIES}), "
|
||||
f"retrying in {cls.RETRY_DELAY_SECONDS}s..."
|
||||
)
|
||||
await asyncio.sleep(cls.RETRY_DELAY_SECONDS)
|
||||
continue
|
||||
else:
|
||||
cls._bootstrap_error = f"Migrations did not complete after {cls.MAX_RETRIES} attempts"
|
||||
logger.error(cls._bootstrap_error)
|
||||
return
|
||||
else:
|
||||
# Unexpected database error
|
||||
raise
|
||||
|
||||
@classmethod
|
||||
async def _ensure_local_tenant(cls) -> None:
|
||||
"""
|
||||
Ensure the local tenant exists.
|
||||
|
||||
Creates it if missing, retrieves it if already exists.
|
||||
"""
|
||||
async with async_session_maker() as session:
|
||||
# First, verify we can query the tenants table
|
||||
# This will fail fast if migrations haven't run
|
||||
await session.execute(text("SELECT 1 FROM tenants LIMIT 1"))
|
||||
|
||||
# Check if local tenant exists
|
||||
result = await session.execute(
|
||||
select(Tenant).where(Tenant.name == cls.LOCAL_TENANT_NAME)
|
||||
)
|
||||
tenant = result.scalar_one_or_none()
|
||||
|
||||
if tenant:
|
||||
logger.info(f"Local tenant already exists (id={tenant.id})")
|
||||
cls._local_tenant_id = tenant.id
|
||||
return
|
||||
|
||||
# Create local tenant
|
||||
tenant = Tenant(
|
||||
name=cls.LOCAL_TENANT_NAME,
|
||||
domain=settings.LOCAL_TENANT_DOMAIN,
|
||||
)
|
||||
session.add(tenant)
|
||||
await session.commit()
|
||||
await session.refresh(tenant)
|
||||
|
||||
cls._local_tenant_id = tenant.id
|
||||
logger.info(f"Created local tenant (id={tenant.id}, domain={settings.LOCAL_TENANT_DOMAIN})")
|
||||
|
||||
@classmethod
|
||||
async def _check_table_exists(cls, table_name: str) -> bool:
|
||||
"""Check if a database table exists."""
|
||||
async with async_session_maker() as session:
|
||||
try:
|
||||
await session.execute(text(f"SELECT 1 FROM {table_name} LIMIT 1"))
|
||||
return True
|
||||
except (OperationalError, ProgrammingError):
|
||||
return False
|
||||
25
letsbe-orchestrator/deploy.sh
Normal file
25
letsbe-orchestrator/deploy.sh
Normal file
@@ -0,0 +1,25 @@
|
||||
#!/bin/bash
|
||||
# Deploy script for LetsBe Orchestrator
|
||||
# This handles registry authentication and deployment
|
||||
|
||||
set -e
|
||||
|
||||
REGISTRY="code.letsbe.solutions"
|
||||
REGISTRY_USER="${REGISTRY_USER:-matt}"
|
||||
REGISTRY_TOKEN="${REGISTRY_TOKEN:-db51180aa20509cddb2e7d3b34f3b321e382069c}"
|
||||
|
||||
echo "Logging into registry..."
|
||||
echo "${REGISTRY_TOKEN}" | docker login "${REGISTRY}" -u "${REGISTRY_USER}" --password-stdin
|
||||
|
||||
echo "Pulling latest images..."
|
||||
docker compose pull
|
||||
|
||||
echo "Starting services..."
|
||||
docker compose up -d
|
||||
|
||||
echo "Running migrations..."
|
||||
sleep 5 # Wait for DB to be ready
|
||||
docker compose exec -T api alembic upgrade head
|
||||
|
||||
echo "Deployment complete!"
|
||||
docker compose ps
|
||||
40
letsbe-orchestrator/docker-compose-dev.yml
Normal file
40
letsbe-orchestrator/docker-compose-dev.yml
Normal file
@@ -0,0 +1,40 @@
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
container_name: orchestrator-db
|
||||
environment:
|
||||
POSTGRES_USER: orchestrator
|
||||
POSTGRES_PASSWORD: orchestrator
|
||||
POSTGRES_DB: orchestrator
|
||||
ports:
|
||||
- "5433:5432" # Host port 5433 to avoid conflict with existing Postgres
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U orchestrator -d orchestrator"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
api:
|
||||
build: .
|
||||
container_name: orchestrator-api
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:8100:8000"
|
||||
environment:
|
||||
DATABASE_URL: postgresql+asyncpg://orchestrator:orchestrator@db:5432/orchestrator
|
||||
DEBUG: "true"
|
||||
APP_NAME: "LetsBe Orchestrator"
|
||||
ADMIN_API_KEY: "dev-admin-key-12345" # Dev only - generate secure key for prod
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ./app:/app/app
|
||||
- ./alembic:/app/alembic
|
||||
- ./tests:/app/tests
|
||||
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
42
letsbe-orchestrator/docker-compose.local.yml
Normal file
42
letsbe-orchestrator/docker-compose.local.yml
Normal file
@@ -0,0 +1,42 @@
|
||||
# docker-compose.local.yml
|
||||
#
|
||||
# Orchestrator configuration for LOCAL_MODE (single-tenant deployment)
|
||||
#
|
||||
# Usage:
|
||||
# docker compose -f docker-compose.yml -f docker-compose.local.yml up
|
||||
#
|
||||
# Or set COMPOSE_FILE environment variable:
|
||||
# export COMPOSE_FILE=docker-compose.yml:docker-compose.local.yml
|
||||
# docker compose up
|
||||
#
|
||||
# Required environment variables:
|
||||
# INSTANCE_ID - Unique instance identifier (from Hub activation)
|
||||
# ADMIN_API_KEY - Admin API key for protected endpoints
|
||||
# LOCAL_AGENT_KEY - Key for local agent registration (Phase 2)
|
||||
#
|
||||
# Optional environment variables:
|
||||
# HUB_URL - Hub API URL (for telemetry)
|
||||
# HUB_API_KEY - Hub API key (for telemetry auth)
|
||||
# LOCAL_TENANT_DOMAIN - Domain for auto-created tenant (default: local.letsbe.cloud)
|
||||
|
||||
services:
|
||||
api:
|
||||
environment:
|
||||
# Enable LOCAL_MODE for single-tenant operation
|
||||
LOCAL_MODE: "true"
|
||||
|
||||
# Instance identification (from Hub activation)
|
||||
# Required: Must be set via environment variable
|
||||
INSTANCE_ID: "${INSTANCE_ID}"
|
||||
|
||||
# Local tenant configuration
|
||||
LOCAL_TENANT_DOMAIN: "${LOCAL_TENANT_DOMAIN:-local.letsbe.cloud}"
|
||||
|
||||
# Local agent registration key (Phase 2)
|
||||
# Separate from ADMIN_API_KEY - can ONLY register the local agent
|
||||
LOCAL_AGENT_KEY: "${LOCAL_AGENT_KEY}"
|
||||
|
||||
# Hub telemetry (optional)
|
||||
HUB_URL: "${HUB_URL:-}"
|
||||
HUB_API_KEY: "${HUB_API_KEY:-}"
|
||||
HUB_TELEMETRY_ENABLED: "${HUB_TELEMETRY_ENABLED:-false}"
|
||||
22
letsbe-orchestrator/docker-compose.override.yml
Normal file
22
letsbe-orchestrator/docker-compose.override.yml
Normal file
@@ -0,0 +1,22 @@
|
||||
# Development overrides - automatically merged with docker-compose.yml
|
||||
# For production, rename this file or use: docker compose -f docker-compose.yml up
|
||||
|
||||
services:
|
||||
api:
|
||||
build: .
|
||||
image: orchestrator-dev
|
||||
volumes:
|
||||
- ./app:/app/app
|
||||
- ./alembic:/app/alembic
|
||||
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
environment:
|
||||
DEBUG: "true"
|
||||
# LOCAL_MODE settings (set via env file or command line)
|
||||
LOCAL_MODE: "${LOCAL_MODE:-false}"
|
||||
INSTANCE_ID: "${INSTANCE_ID:-}"
|
||||
LOCAL_AGENT_KEY: "${LOCAL_AGENT_KEY:-}"
|
||||
# Hub telemetry settings
|
||||
HUB_TELEMETRY_ENABLED: "${HUB_TELEMETRY_ENABLED:-false}"
|
||||
HUB_URL: "${HUB_URL:-}"
|
||||
HUB_API_KEY: "${HUB_API_KEY:-}"
|
||||
HUB_TELEMETRY_INTERVAL_SECONDS: "${HUB_TELEMETRY_INTERVAL_SECONDS:-60}"
|
||||
42
letsbe-orchestrator/docker-compose.yml
Normal file
42
letsbe-orchestrator/docker-compose.yml
Normal file
@@ -0,0 +1,42 @@
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
container_name: orchestrator-db
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-orchestrator}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-orchestrator}
|
||||
ports:
|
||||
- "5434:5432" # Port 5434 to avoid conflict with Hub Postgres (5432) and other services
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U orchestrator -d orchestrator"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
api:
|
||||
image: code.letsbe.solutions/letsbe/orchestrator:latest
|
||||
container_name: orchestrator-api
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:8100:8000"
|
||||
environment:
|
||||
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-orchestrator}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-orchestrator}
|
||||
DEBUG: "false"
|
||||
APP_NAME: "LetsBe Orchestrator"
|
||||
ADMIN_API_KEY: "${ADMIN_API_KEY}"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
# Use Python (curl not available in slim images)
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 10s
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
21
letsbe-orchestrator/nginx.conf
Normal file
21
letsbe-orchestrator/nginx.conf
Normal file
@@ -0,0 +1,21 @@
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
|
||||
server_name orchestrator.yourdomain.com;
|
||||
|
||||
# Allow Certbot challenges
|
||||
location ^~ /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot; # Use your existing certbot webroot
|
||||
allow all;
|
||||
}
|
||||
|
||||
# Everything else goes to the orchestrator backend (HTTP only for now)
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8100;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
26
letsbe-orchestrator/requirements.txt
Normal file
26
letsbe-orchestrator/requirements.txt
Normal file
@@ -0,0 +1,26 @@
|
||||
# Web Framework
|
||||
fastapi>=0.109.0
|
||||
uvicorn[standard]>=0.27.0
|
||||
|
||||
# Database
|
||||
sqlalchemy[asyncio]>=2.0.25
|
||||
asyncpg>=0.29.0
|
||||
alembic>=1.13.0
|
||||
|
||||
# Serialization & Validation
|
||||
pydantic>=2.5.0
|
||||
pydantic-settings>=2.1.0
|
||||
|
||||
# Utilities
|
||||
python-dotenv>=1.0.0
|
||||
|
||||
# Rate Limiting
|
||||
slowapi>=0.1.9
|
||||
|
||||
# HTTP Client
|
||||
httpx>=0.26.0
|
||||
|
||||
# Testing
|
||||
pytest>=8.0.0
|
||||
pytest-asyncio>=0.23.0
|
||||
aiosqlite>=0.19.0
|
||||
1
letsbe-orchestrator/tests/__init__.py
Normal file
1
letsbe-orchestrator/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Test suite for letsbe-orchestrator."""
|
||||
168
letsbe-orchestrator/tests/conftest.py
Normal file
168
letsbe-orchestrator/tests/conftest.py
Normal file
@@ -0,0 +1,168 @@
|
||||
"""Pytest configuration and fixtures for letsbe-orchestrator tests."""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import uuid
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from app.models.base import Base, utc_now
|
||||
from app.models.tenant import Tenant
|
||||
from app.models.agent import Agent
|
||||
from app.models.registration_token import RegistrationToken
|
||||
|
||||
# Use in-memory SQLite for testing
|
||||
TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def event_loop():
|
||||
"""Create an instance of the default event loop for the test session."""
|
||||
loop = asyncio.get_event_loop_policy().new_event_loop()
|
||||
yield loop
|
||||
loop.close()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
async def async_engine():
|
||||
"""Create a test async engine."""
|
||||
engine = create_async_engine(TEST_DATABASE_URL, echo=False)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
yield engine
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
async def db(async_engine) -> AsyncGenerator[AsyncSession, None]:
|
||||
"""Create a test database session."""
|
||||
session_factory = async_sessionmaker(
|
||||
async_engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
autocommit=False,
|
||||
autoflush=False,
|
||||
)
|
||||
async with session_factory() as session:
|
||||
yield session
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
async def test_tenant(db: AsyncSession) -> Tenant:
|
||||
"""Create a test tenant."""
|
||||
tenant = Tenant(
|
||||
id=uuid.uuid4(),
|
||||
name="Test Tenant",
|
||||
)
|
||||
db.add(tenant)
|
||||
await db.commit()
|
||||
await db.refresh(tenant)
|
||||
return tenant
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
async def test_agent(db: AsyncSession) -> Agent:
|
||||
"""Create a test agent."""
|
||||
agent = Agent(
|
||||
id=uuid.uuid4(),
|
||||
name="test-agent-host",
|
||||
version="1.0.0",
|
||||
status="online",
|
||||
token="test-token-12345",
|
||||
)
|
||||
db.add(agent)
|
||||
await db.commit()
|
||||
await db.refresh(agent)
|
||||
return agent
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
async def test_agent_for_tenant(db: AsyncSession, test_tenant: Tenant) -> Agent:
|
||||
"""Create a test agent linked to test_tenant with online status."""
|
||||
agent = Agent(
|
||||
id=uuid.uuid4(),
|
||||
tenant_id=test_tenant.id,
|
||||
name="test-agent-for-tenant",
|
||||
version="1.0.0",
|
||||
status="online",
|
||||
token="test-token-tenant",
|
||||
)
|
||||
db.add(agent)
|
||||
await db.commit()
|
||||
await db.refresh(agent)
|
||||
return agent
|
||||
|
||||
|
||||
# --- New fixtures for secure auth testing ---
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
async def test_registration_token(
|
||||
db: AsyncSession, test_tenant: Tenant
|
||||
) -> tuple[RegistrationToken, str]:
|
||||
"""Create a test registration token and return (token_record, plaintext_token)."""
|
||||
plaintext_token = str(uuid.uuid4())
|
||||
token_hash = hashlib.sha256(plaintext_token.encode()).hexdigest()
|
||||
|
||||
reg_token = RegistrationToken(
|
||||
id=uuid.uuid4(),
|
||||
tenant_id=test_tenant.id,
|
||||
token_hash=token_hash,
|
||||
description="Test registration token",
|
||||
max_uses=10,
|
||||
use_count=0,
|
||||
)
|
||||
db.add(reg_token)
|
||||
await db.commit()
|
||||
await db.refresh(reg_token)
|
||||
return reg_token, plaintext_token
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
async def test_agent_with_secret(
|
||||
db: AsyncSession, test_tenant: Tenant
|
||||
) -> tuple[Agent, str]:
|
||||
"""Create a test agent with secret_hash and return (agent, plaintext_secret)."""
|
||||
plaintext_secret = "test-secret-" + uuid.uuid4().hex[:16]
|
||||
secret_hash = hashlib.sha256(plaintext_secret.encode()).hexdigest()
|
||||
|
||||
agent = Agent(
|
||||
id=uuid.uuid4(),
|
||||
tenant_id=test_tenant.id,
|
||||
name="test-agent-secure",
|
||||
version="1.0.0",
|
||||
status="online",
|
||||
token="", # Empty for new auth scheme
|
||||
secret_hash=secret_hash,
|
||||
last_heartbeat=utc_now(),
|
||||
)
|
||||
db.add(agent)
|
||||
await db.commit()
|
||||
await db.refresh(agent)
|
||||
return agent, plaintext_secret
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
async def test_agent_legacy(db: AsyncSession, test_tenant: Tenant) -> Agent:
|
||||
"""Create a test agent with legacy token auth (both token and secret_hash set)."""
|
||||
legacy_token = "legacy-token-" + uuid.uuid4().hex[:16]
|
||||
secret_hash = hashlib.sha256(legacy_token.encode()).hexdigest()
|
||||
|
||||
agent = Agent(
|
||||
id=uuid.uuid4(),
|
||||
tenant_id=test_tenant.id,
|
||||
name="test-agent-legacy",
|
||||
version="1.0.0",
|
||||
status="online",
|
||||
token=legacy_token, # Legacy field
|
||||
secret_hash=secret_hash, # Also set for new auth
|
||||
)
|
||||
db.add(agent)
|
||||
await db.commit()
|
||||
await db.refresh(agent)
|
||||
return agent
|
||||
1
letsbe-orchestrator/tests/routes/__init__.py
Normal file
1
letsbe-orchestrator/tests/routes/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Tests for route modules."""
|
||||
361
letsbe-orchestrator/tests/routes/test_agent_auth.py
Normal file
361
letsbe-orchestrator/tests/routes/test_agent_auth.py
Normal file
@@ -0,0 +1,361 @@
|
||||
"""Tests for agent authentication endpoints and dependencies."""
|
||||
|
||||
import hashlib
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.dependencies.auth import get_current_agent, get_current_agent_compat
|
||||
from app.models.agent import Agent, AgentStatus
|
||||
from app.models.registration_token import RegistrationToken
|
||||
from app.models.tenant import Tenant
|
||||
from app.routes.agents import (
|
||||
_register_agent_legacy,
|
||||
_register_agent_secure,
|
||||
agent_heartbeat,
|
||||
)
|
||||
from app.schemas.agent import AgentRegisterRequest, AgentRegisterRequestLegacy
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestGetCurrentAgent:
|
||||
"""Tests for the get_current_agent dependency (new auth scheme)."""
|
||||
|
||||
async def test_valid_credentials(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
test_tenant: Tenant,
|
||||
test_agent_with_secret: tuple[Agent, str],
|
||||
):
|
||||
"""Successfully authenticate with valid X-Agent-Id and X-Agent-Secret."""
|
||||
agent, secret = test_agent_with_secret
|
||||
|
||||
result = await get_current_agent(
|
||||
db=db, x_agent_id=str(agent.id), x_agent_secret=secret
|
||||
)
|
||||
|
||||
assert result.id == agent.id
|
||||
assert result.tenant_id == test_tenant.id
|
||||
|
||||
async def test_invalid_agent_id(self, db: AsyncSession):
|
||||
"""Returns 401 for non-existent agent ID."""
|
||||
fake_agent_id = str(uuid.uuid4())
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_current_agent(
|
||||
db=db, x_agent_id=fake_agent_id, x_agent_secret="any-secret"
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
assert "Invalid agent credentials" in str(exc_info.value.detail)
|
||||
|
||||
async def test_invalid_secret(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
test_agent_with_secret: tuple[Agent, str],
|
||||
):
|
||||
"""Returns 401 for wrong secret."""
|
||||
agent, _ = test_agent_with_secret
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_current_agent(
|
||||
db=db, x_agent_id=str(agent.id), x_agent_secret="wrong-secret"
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
assert "Invalid agent credentials" in str(exc_info.value.detail)
|
||||
|
||||
async def test_malformed_agent_id(self, db: AsyncSession):
|
||||
"""Returns 401 for malformed UUID."""
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_current_agent(
|
||||
db=db, x_agent_id="not-a-uuid", x_agent_secret="any-secret"
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
assert "Invalid Agent ID format" in str(exc_info.value.detail)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestGetCurrentAgentCompat:
|
||||
"""Tests for the backward-compatible auth dependency."""
|
||||
|
||||
async def test_new_scheme_preferred(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
test_agent_with_secret: tuple[Agent, str],
|
||||
):
|
||||
"""New X-Agent-* headers take precedence over Bearer."""
|
||||
agent, secret = test_agent_with_secret
|
||||
|
||||
result = await get_current_agent_compat(
|
||||
db=db,
|
||||
x_agent_id=str(agent.id),
|
||||
x_agent_secret=secret,
|
||||
authorization="Bearer wrong-token", # Should be ignored
|
||||
)
|
||||
|
||||
assert result.id == agent.id
|
||||
|
||||
async def test_legacy_bearer_fallback(
|
||||
self, db: AsyncSession, test_agent_legacy: Agent
|
||||
):
|
||||
"""Falls back to Bearer token when X-Agent-* not provided."""
|
||||
result = await get_current_agent_compat(
|
||||
db=db,
|
||||
x_agent_id=None,
|
||||
x_agent_secret=None,
|
||||
authorization=f"Bearer {test_agent_legacy.token}",
|
||||
agent_id=test_agent_legacy.id, # Legacy auth requires agent_id
|
||||
)
|
||||
|
||||
assert result.id == test_agent_legacy.id
|
||||
|
||||
async def test_no_credentials_provided(self, db: AsyncSession):
|
||||
"""Returns 401 when no auth credentials provided."""
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_current_agent_compat(
|
||||
db=db, x_agent_id=None, x_agent_secret=None, authorization=None
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
assert "Missing authentication credentials" in str(exc_info.value.detail)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestSecureAgentRegistration:
|
||||
"""Tests for the new secure registration flow."""
|
||||
|
||||
async def test_registration_with_valid_token(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
test_tenant: Tenant,
|
||||
test_registration_token: tuple[RegistrationToken, str],
|
||||
):
|
||||
"""Successfully register agent with valid registration token."""
|
||||
_, plaintext_token = test_registration_token
|
||||
|
||||
request = AgentRegisterRequest(
|
||||
hostname="new-agent-host",
|
||||
version="2.0.0",
|
||||
registration_token=plaintext_token,
|
||||
)
|
||||
|
||||
response = await _register_agent_secure(request, db)
|
||||
|
||||
assert response.agent_id is not None
|
||||
assert response.agent_secret is not None
|
||||
assert response.tenant_id == test_tenant.id
|
||||
|
||||
# Verify agent was created
|
||||
result = await db.execute(
|
||||
select(Agent).where(Agent.id == response.agent_id)
|
||||
)
|
||||
agent = result.scalar_one()
|
||||
assert agent.name == "new-agent-host"
|
||||
assert agent.tenant_id == test_tenant.id
|
||||
|
||||
async def test_registration_increments_use_count(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
test_registration_token: tuple[RegistrationToken, str],
|
||||
):
|
||||
"""Registration increments the token's use_count."""
|
||||
token_record, plaintext_token = test_registration_token
|
||||
initial_count = token_record.use_count
|
||||
|
||||
request = AgentRegisterRequest(
|
||||
hostname="test-host",
|
||||
version="1.0.0",
|
||||
registration_token=plaintext_token,
|
||||
)
|
||||
|
||||
await _register_agent_secure(request, db)
|
||||
|
||||
await db.refresh(token_record)
|
||||
assert token_record.use_count == initial_count + 1
|
||||
|
||||
async def test_registration_stores_secret_hash(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
test_registration_token: tuple[RegistrationToken, str],
|
||||
):
|
||||
"""Agent secret is stored as hash, not plaintext."""
|
||||
_, plaintext_token = test_registration_token
|
||||
|
||||
request = AgentRegisterRequest(
|
||||
hostname="test-host",
|
||||
version="1.0.0",
|
||||
registration_token=plaintext_token,
|
||||
)
|
||||
|
||||
response = await _register_agent_secure(request, db)
|
||||
|
||||
result = await db.execute(
|
||||
select(Agent).where(Agent.id == response.agent_id)
|
||||
)
|
||||
agent = result.scalar_one()
|
||||
|
||||
expected_hash = hashlib.sha256(response.agent_secret.encode()).hexdigest()
|
||||
assert agent.secret_hash == expected_hash
|
||||
|
||||
async def test_registration_with_invalid_token(self, db: AsyncSession):
|
||||
"""Returns 401 for invalid registration token."""
|
||||
request = AgentRegisterRequest(
|
||||
hostname="test-host",
|
||||
version="1.0.0",
|
||||
registration_token="invalid-token",
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await _register_agent_secure(request, db)
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
assert "Invalid registration token" in str(exc_info.value.detail)
|
||||
|
||||
async def test_registration_with_revoked_token(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
test_registration_token: tuple[RegistrationToken, str],
|
||||
):
|
||||
"""Returns 401 for revoked registration token."""
|
||||
token_record, plaintext_token = test_registration_token
|
||||
token_record.revoked = True
|
||||
await db.commit()
|
||||
|
||||
request = AgentRegisterRequest(
|
||||
hostname="test-host",
|
||||
version="1.0.0",
|
||||
registration_token=plaintext_token,
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await _register_agent_secure(request, db)
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
assert "revoked" in str(exc_info.value.detail).lower()
|
||||
|
||||
async def test_registration_with_exhausted_token(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
test_registration_token: tuple[RegistrationToken, str],
|
||||
):
|
||||
"""Returns 401 for exhausted registration token."""
|
||||
token_record, plaintext_token = test_registration_token
|
||||
token_record.max_uses = 1
|
||||
token_record.use_count = 1
|
||||
await db.commit()
|
||||
|
||||
request = AgentRegisterRequest(
|
||||
hostname="test-host",
|
||||
version="1.0.0",
|
||||
registration_token=plaintext_token,
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await _register_agent_secure(request, db)
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
assert "exhausted" in str(exc_info.value.detail).lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestLegacyAgentRegistration:
|
||||
"""Tests for the legacy registration flow."""
|
||||
|
||||
async def test_legacy_registration_success(
|
||||
self, db: AsyncSession, test_tenant: Tenant
|
||||
):
|
||||
"""Successfully register agent using legacy flow."""
|
||||
request = AgentRegisterRequestLegacy(
|
||||
hostname="legacy-host",
|
||||
version="1.0.0",
|
||||
tenant_id=test_tenant.id,
|
||||
)
|
||||
|
||||
response = await _register_agent_legacy(request, db)
|
||||
|
||||
assert response.agent_id is not None
|
||||
assert response.token is not None
|
||||
|
||||
# Verify agent was created with both token and secret_hash
|
||||
result = await db.execute(
|
||||
select(Agent).where(Agent.id == response.agent_id)
|
||||
)
|
||||
agent = result.scalar_one()
|
||||
assert agent.token == response.token
|
||||
assert agent.secret_hash == hashlib.sha256(response.token.encode()).hexdigest()
|
||||
|
||||
async def test_legacy_registration_tenant_not_found(self, db: AsyncSession):
|
||||
"""Returns 404 for non-existent tenant in legacy flow."""
|
||||
fake_tenant_id = uuid.uuid4()
|
||||
request = AgentRegisterRequestLegacy(
|
||||
hostname="legacy-host",
|
||||
version="1.0.0",
|
||||
tenant_id=fake_tenant_id,
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await _register_agent_legacy(request, db)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
async def test_legacy_registration_without_tenant(self, db: AsyncSession):
|
||||
"""Legacy registration without tenant_id creates shared agent."""
|
||||
request = AgentRegisterRequestLegacy(
|
||||
hostname="shared-host",
|
||||
version="1.0.0",
|
||||
tenant_id=None,
|
||||
)
|
||||
|
||||
response = await _register_agent_legacy(request, db)
|
||||
|
||||
result = await db.execute(
|
||||
select(Agent).where(Agent.id == response.agent_id)
|
||||
)
|
||||
agent = result.scalar_one()
|
||||
assert agent.tenant_id is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestAgentHeartbeat:
|
||||
"""Tests for the agent heartbeat endpoint."""
|
||||
|
||||
async def test_heartbeat_success(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
test_agent_with_secret: tuple[Agent, str],
|
||||
):
|
||||
"""Successfully send heartbeat updates timestamp and status."""
|
||||
agent, _ = test_agent_with_secret
|
||||
old_heartbeat = agent.last_heartbeat
|
||||
|
||||
response = await agent_heartbeat(
|
||||
agent_id=agent.id, db=db, current_agent=agent
|
||||
)
|
||||
|
||||
assert response.status == "ok"
|
||||
|
||||
await db.refresh(agent)
|
||||
assert agent.status == AgentStatus.ONLINE.value
|
||||
assert agent.last_heartbeat >= old_heartbeat
|
||||
|
||||
async def test_heartbeat_agent_id_mismatch(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
test_agent_with_secret: tuple[Agent, str],
|
||||
):
|
||||
"""Returns 403 when path agent_id doesn't match authenticated agent."""
|
||||
agent, _ = test_agent_with_secret
|
||||
wrong_agent_id = uuid.uuid4()
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await agent_heartbeat(
|
||||
agent_id=wrong_agent_id, db=db, current_agent=agent
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
assert "Agent ID mismatch" in str(exc_info.value.detail)
|
||||
213
letsbe-orchestrator/tests/routes/test_env_routes.py
Normal file
213
letsbe-orchestrator/tests/routes/test_env_routes.py
Normal file
@@ -0,0 +1,213 @@
|
||||
"""Tests for env management routes."""
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.agent import Agent
|
||||
from app.models.task import Task
|
||||
from app.models.tenant import Tenant
|
||||
from app.routes.env import inspect_env, update_env
|
||||
from app.schemas.env import EnvInspectRequest, EnvUpdateRequest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestInspectEnv:
|
||||
"""Tests for the inspect_env endpoint."""
|
||||
|
||||
async def test_happy_path(
|
||||
self, db: AsyncSession, test_tenant: Tenant, test_agent: Agent
|
||||
):
|
||||
"""Valid request creates an ENV_INSPECT task."""
|
||||
request = EnvInspectRequest(
|
||||
tenant_id=test_tenant.id,
|
||||
path="/opt/letsbe/env/chatwoot.env",
|
||||
keys=["FRONTEND_URL", "BACKEND_URL"],
|
||||
)
|
||||
|
||||
task = await inspect_env(agent_id=test_agent.id, request=request, db=db)
|
||||
|
||||
assert task.id is not None
|
||||
assert task.tenant_id == test_tenant.id
|
||||
assert task.agent_id == test_agent.id
|
||||
assert task.type == "ENV_INSPECT"
|
||||
assert task.status == "pending"
|
||||
assert task.payload["path"] == "/opt/letsbe/env/chatwoot.env"
|
||||
assert task.payload["keys"] == ["FRONTEND_URL", "BACKEND_URL"]
|
||||
|
||||
async def test_without_keys(
|
||||
self, db: AsyncSession, test_tenant: Tenant, test_agent: Agent
|
||||
):
|
||||
"""Request without keys omits keys from payload."""
|
||||
request = EnvInspectRequest(
|
||||
tenant_id=test_tenant.id,
|
||||
path="/opt/letsbe/env/chatwoot.env",
|
||||
keys=None,
|
||||
)
|
||||
|
||||
task = await inspect_env(agent_id=test_agent.id, request=request, db=db)
|
||||
|
||||
assert task.type == "ENV_INSPECT"
|
||||
assert task.payload["path"] == "/opt/letsbe/env/chatwoot.env"
|
||||
assert "keys" not in task.payload
|
||||
|
||||
async def test_tenant_not_found(self, db: AsyncSession, test_agent: Agent):
|
||||
"""Returns 404 when tenant doesn't exist."""
|
||||
fake_tenant_id = uuid.uuid4()
|
||||
request = EnvInspectRequest(
|
||||
tenant_id=fake_tenant_id,
|
||||
path="/opt/letsbe/env/chatwoot.env",
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await inspect_env(agent_id=test_agent.id, request=request, db=db)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
assert f"Tenant {fake_tenant_id} not found" in str(exc_info.value.detail)
|
||||
|
||||
async def test_agent_not_found(self, db: AsyncSession, test_tenant: Tenant):
|
||||
"""Returns 404 when agent doesn't exist."""
|
||||
fake_agent_id = uuid.uuid4()
|
||||
request = EnvInspectRequest(
|
||||
tenant_id=test_tenant.id,
|
||||
path="/opt/letsbe/env/chatwoot.env",
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await inspect_env(agent_id=fake_agent_id, request=request, db=db)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
assert f"Agent {fake_agent_id} not found" in str(exc_info.value.detail)
|
||||
|
||||
async def test_task_persisted_to_database(
|
||||
self, db: AsyncSession, test_tenant: Tenant, test_agent: Agent
|
||||
):
|
||||
"""Verify the task is actually persisted and can be retrieved."""
|
||||
request = EnvInspectRequest(
|
||||
tenant_id=test_tenant.id,
|
||||
path="/opt/letsbe/env/chatwoot.env",
|
||||
)
|
||||
|
||||
task = await inspect_env(agent_id=test_agent.id, request=request, db=db)
|
||||
|
||||
# Query the task back from the database
|
||||
result = await db.execute(select(Task).where(Task.id == task.id))
|
||||
retrieved_task = result.scalar_one_or_none()
|
||||
|
||||
assert retrieved_task is not None
|
||||
assert retrieved_task.type == "ENV_INSPECT"
|
||||
assert retrieved_task.tenant_id == test_tenant.id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestUpdateEnv:
|
||||
"""Tests for the update_env endpoint."""
|
||||
|
||||
async def test_happy_path(
|
||||
self, db: AsyncSession, test_tenant: Tenant, test_agent: Agent
|
||||
):
|
||||
"""Valid request creates an ENV_UPDATE task."""
|
||||
request = EnvUpdateRequest(
|
||||
tenant_id=test_tenant.id,
|
||||
path="/opt/letsbe/env/chatwoot.env",
|
||||
updates={"FRONTEND_URL": "https://new.domain.com"},
|
||||
remove_keys=["OLD_KEY"],
|
||||
)
|
||||
|
||||
task = await update_env(agent_id=test_agent.id, request=request, db=db)
|
||||
|
||||
assert task.id is not None
|
||||
assert task.tenant_id == test_tenant.id
|
||||
assert task.agent_id == test_agent.id
|
||||
assert task.type == "ENV_UPDATE"
|
||||
assert task.status == "pending"
|
||||
assert task.payload["path"] == "/opt/letsbe/env/chatwoot.env"
|
||||
assert task.payload["updates"] == {"FRONTEND_URL": "https://new.domain.com"}
|
||||
assert task.payload["remove_keys"] == ["OLD_KEY"]
|
||||
|
||||
async def test_updates_only(
|
||||
self, db: AsyncSession, test_tenant: Tenant, test_agent: Agent
|
||||
):
|
||||
"""Request with only updates omits remove_keys from payload."""
|
||||
request = EnvUpdateRequest(
|
||||
tenant_id=test_tenant.id,
|
||||
path="/opt/letsbe/env/chatwoot.env",
|
||||
updates={"KEY": "VALUE"},
|
||||
remove_keys=None,
|
||||
)
|
||||
|
||||
task = await update_env(agent_id=test_agent.id, request=request, db=db)
|
||||
|
||||
assert task.type == "ENV_UPDATE"
|
||||
assert task.payload["updates"] == {"KEY": "VALUE"}
|
||||
assert "remove_keys" not in task.payload
|
||||
|
||||
async def test_remove_keys_only(
|
||||
self, db: AsyncSession, test_tenant: Tenant, test_agent: Agent
|
||||
):
|
||||
"""Request with only remove_keys omits updates from payload."""
|
||||
request = EnvUpdateRequest(
|
||||
tenant_id=test_tenant.id,
|
||||
path="/opt/letsbe/env/chatwoot.env",
|
||||
updates=None,
|
||||
remove_keys=["OLD_KEY"],
|
||||
)
|
||||
|
||||
task = await update_env(agent_id=test_agent.id, request=request, db=db)
|
||||
|
||||
assert task.type == "ENV_UPDATE"
|
||||
assert "updates" not in task.payload
|
||||
assert task.payload["remove_keys"] == ["OLD_KEY"]
|
||||
|
||||
async def test_tenant_not_found(self, db: AsyncSession, test_agent: Agent):
|
||||
"""Returns 404 when tenant doesn't exist."""
|
||||
fake_tenant_id = uuid.uuid4()
|
||||
request = EnvUpdateRequest(
|
||||
tenant_id=fake_tenant_id,
|
||||
path="/opt/letsbe/env/chatwoot.env",
|
||||
updates={"KEY": "VALUE"},
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await update_env(agent_id=test_agent.id, request=request, db=db)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
assert f"Tenant {fake_tenant_id} not found" in str(exc_info.value.detail)
|
||||
|
||||
async def test_agent_not_found(self, db: AsyncSession, test_tenant: Tenant):
|
||||
"""Returns 404 when agent doesn't exist."""
|
||||
fake_agent_id = uuid.uuid4()
|
||||
request = EnvUpdateRequest(
|
||||
tenant_id=test_tenant.id,
|
||||
path="/opt/letsbe/env/chatwoot.env",
|
||||
updates={"KEY": "VALUE"},
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await update_env(agent_id=fake_agent_id, request=request, db=db)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
assert f"Agent {fake_agent_id} not found" in str(exc_info.value.detail)
|
||||
|
||||
async def test_task_persisted_to_database(
|
||||
self, db: AsyncSession, test_tenant: Tenant, test_agent: Agent
|
||||
):
|
||||
"""Verify the task is actually persisted and can be retrieved."""
|
||||
request = EnvUpdateRequest(
|
||||
tenant_id=test_tenant.id,
|
||||
path="/opt/letsbe/env/chatwoot.env",
|
||||
updates={"KEY": "VALUE"},
|
||||
)
|
||||
|
||||
task = await update_env(agent_id=test_agent.id, request=request, db=db)
|
||||
|
||||
# Query the task back from the database
|
||||
result = await db.execute(select(Task).where(Task.id == task.id))
|
||||
retrieved_task = result.scalar_one_or_none()
|
||||
|
||||
assert retrieved_task is not None
|
||||
assert retrieved_task.type == "ENV_UPDATE"
|
||||
assert retrieved_task.tenant_id == test_tenant.id
|
||||
116
letsbe-orchestrator/tests/routes/test_files_routes.py
Normal file
116
letsbe-orchestrator/tests/routes/test_files_routes.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""Tests for file management routes."""
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.agent import Agent
|
||||
from app.models.task import Task
|
||||
from app.models.tenant import Tenant
|
||||
from app.routes.files import inspect_file
|
||||
from app.schemas.file import FileInspectRequest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestInspectFile:
|
||||
"""Tests for the inspect_file endpoint."""
|
||||
|
||||
async def test_happy_path(
|
||||
self, db: AsyncSession, test_tenant: Tenant, test_agent: Agent
|
||||
):
|
||||
"""Valid request creates a FILE_INSPECT task."""
|
||||
request = FileInspectRequest(
|
||||
tenant_id=test_tenant.id,
|
||||
path="/opt/letsbe/data/config.json",
|
||||
max_bytes=4096,
|
||||
)
|
||||
|
||||
task = await inspect_file(agent_id=test_agent.id, request=request, db=db)
|
||||
|
||||
assert task.id is not None
|
||||
assert task.tenant_id == test_tenant.id
|
||||
assert task.agent_id == test_agent.id
|
||||
assert task.type == "FILE_INSPECT"
|
||||
assert task.status == "pending"
|
||||
assert task.payload["path"] == "/opt/letsbe/data/config.json"
|
||||
assert task.payload["max_bytes"] == 4096
|
||||
|
||||
async def test_with_default_max_bytes(
|
||||
self, db: AsyncSession, test_tenant: Tenant, test_agent: Agent
|
||||
):
|
||||
"""Request uses default max_bytes of 4096."""
|
||||
request = FileInspectRequest(
|
||||
tenant_id=test_tenant.id,
|
||||
path="/opt/letsbe/data/config.json",
|
||||
)
|
||||
|
||||
task = await inspect_file(agent_id=test_agent.id, request=request, db=db)
|
||||
|
||||
assert task.type == "FILE_INSPECT"
|
||||
assert task.payload["path"] == "/opt/letsbe/data/config.json"
|
||||
assert task.payload["max_bytes"] == 4096
|
||||
|
||||
async def test_with_custom_max_bytes(
|
||||
self, db: AsyncSession, test_tenant: Tenant, test_agent: Agent
|
||||
):
|
||||
"""Request with custom max_bytes is respected."""
|
||||
request = FileInspectRequest(
|
||||
tenant_id=test_tenant.id,
|
||||
path="/opt/letsbe/data/large_file.txt",
|
||||
max_bytes=1048576, # 1MB
|
||||
)
|
||||
|
||||
task = await inspect_file(agent_id=test_agent.id, request=request, db=db)
|
||||
|
||||
assert task.type == "FILE_INSPECT"
|
||||
assert task.payload["max_bytes"] == 1048576
|
||||
|
||||
async def test_tenant_not_found(self, db: AsyncSession, test_agent: Agent):
|
||||
"""Returns 404 when tenant doesn't exist."""
|
||||
fake_tenant_id = uuid.uuid4()
|
||||
request = FileInspectRequest(
|
||||
tenant_id=fake_tenant_id,
|
||||
path="/opt/letsbe/data/config.json",
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await inspect_file(agent_id=test_agent.id, request=request, db=db)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
assert f"Tenant {fake_tenant_id} not found" in str(exc_info.value.detail)
|
||||
|
||||
async def test_agent_not_found(self, db: AsyncSession, test_tenant: Tenant):
|
||||
"""Returns 404 when agent doesn't exist."""
|
||||
fake_agent_id = uuid.uuid4()
|
||||
request = FileInspectRequest(
|
||||
tenant_id=test_tenant.id,
|
||||
path="/opt/letsbe/data/config.json",
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await inspect_file(agent_id=fake_agent_id, request=request, db=db)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
assert f"Agent {fake_agent_id} not found" in str(exc_info.value.detail)
|
||||
|
||||
async def test_task_persisted_to_database(
|
||||
self, db: AsyncSession, test_tenant: Tenant, test_agent: Agent
|
||||
):
|
||||
"""Verify the task is actually persisted and can be retrieved."""
|
||||
request = FileInspectRequest(
|
||||
tenant_id=test_tenant.id,
|
||||
path="/opt/letsbe/data/config.json",
|
||||
)
|
||||
|
||||
task = await inspect_file(agent_id=test_agent.id, request=request, db=db)
|
||||
|
||||
# Query the task back from the database
|
||||
result = await db.execute(select(Task).where(Task.id == task.id))
|
||||
retrieved_task = result.scalar_one_or_none()
|
||||
|
||||
assert retrieved_task is not None
|
||||
assert retrieved_task.type == "FILE_INSPECT"
|
||||
assert retrieved_task.tenant_id == test_tenant.id
|
||||
492
letsbe-orchestrator/tests/routes/test_nextcloud_routes.py
Normal file
492
letsbe-orchestrator/tests/routes/test_nextcloud_routes.py
Normal file
@@ -0,0 +1,492 @@
|
||||
"""Tests for Nextcloud playbook routes."""
|
||||
|
||||
import uuid
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from fastapi import HTTPException
|
||||
from pydantic import ValidationError
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.agent import Agent
|
||||
from app.models.task import Task
|
||||
from app.models.tenant import Tenant
|
||||
from app.playbooks.nextcloud import NEXTCLOUD_STACK_DIR
|
||||
from app.routes.playbooks import (
|
||||
NextcloudInitialSetupRequest,
|
||||
NextcloudSetDomainRequest,
|
||||
nextcloud_initial_setup,
|
||||
nextcloud_set_domain,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestNextcloudSetDomainEndpoint:
|
||||
"""Tests for the nextcloud_set_domain endpoint."""
|
||||
|
||||
async def test_happy_path_creates_task(
|
||||
self, db: AsyncSession, test_tenant: Tenant, test_agent_for_tenant: Agent
|
||||
):
|
||||
"""POST /tenants/{id}/nextcloud/set-domain returns 201 with COMPOSITE task."""
|
||||
request = NextcloudSetDomainRequest(
|
||||
public_url="https://cloud.example.com",
|
||||
pull=False,
|
||||
)
|
||||
|
||||
task = await nextcloud_set_domain(
|
||||
tenant_id=test_tenant.id, request=request, db=db
|
||||
)
|
||||
|
||||
assert task.id is not None
|
||||
assert task.tenant_id == test_tenant.id
|
||||
assert task.agent_id == test_agent_for_tenant.id
|
||||
assert task.type == "COMPOSITE"
|
||||
assert task.status == "pending"
|
||||
|
||||
async def test_task_has_both_steps(
|
||||
self, db: AsyncSession, test_tenant: Tenant, test_agent_for_tenant: Agent
|
||||
):
|
||||
"""Verify response task payload contains both steps in correct order."""
|
||||
request = NextcloudSetDomainRequest(
|
||||
public_url="https://cloud.example.com",
|
||||
pull=True,
|
||||
)
|
||||
|
||||
task = await nextcloud_set_domain(
|
||||
tenant_id=test_tenant.id, request=request, db=db
|
||||
)
|
||||
|
||||
assert "steps" in task.payload
|
||||
assert len(task.payload["steps"]) == 2
|
||||
|
||||
# First step: NEXTCLOUD_SET_DOMAIN
|
||||
step0 = task.payload["steps"][0]
|
||||
assert step0["type"] == "NEXTCLOUD_SET_DOMAIN"
|
||||
assert step0["payload"]["public_url"] == "https://cloud.example.com"
|
||||
|
||||
# Second step: DOCKER_RELOAD
|
||||
step1 = task.payload["steps"][1]
|
||||
assert step1["type"] == "DOCKER_RELOAD"
|
||||
assert step1["payload"]["compose_dir"] == NEXTCLOUD_STACK_DIR
|
||||
assert step1["payload"]["pull"] is True
|
||||
|
||||
async def test_pull_flag_default_false(
|
||||
self, db: AsyncSession, test_tenant: Tenant, test_agent_for_tenant: Agent
|
||||
):
|
||||
"""Verify pull defaults to False when not specified."""
|
||||
request = NextcloudSetDomainRequest(
|
||||
public_url="https://cloud.example.com",
|
||||
)
|
||||
|
||||
task = await nextcloud_set_domain(
|
||||
tenant_id=test_tenant.id, request=request, db=db
|
||||
)
|
||||
|
||||
step = task.payload["steps"][1]
|
||||
assert step["payload"]["pull"] is False
|
||||
|
||||
async def test_tenant_not_found_returns_404(self, db: AsyncSession):
|
||||
"""Non-existent tenant_id returns 404."""
|
||||
fake_tenant_id = uuid.uuid4()
|
||||
request = NextcloudSetDomainRequest(
|
||||
public_url="https://cloud.example.com",
|
||||
pull=False,
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await nextcloud_set_domain(
|
||||
tenant_id=fake_tenant_id, request=request, db=db
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
assert f"Tenant {fake_tenant_id} not found" in str(exc_info.value.detail)
|
||||
|
||||
async def test_no_online_agent_returns_404(
|
||||
self, db: AsyncSession, test_tenant: Tenant
|
||||
):
|
||||
"""Tenant with no online agent returns 404."""
|
||||
request = NextcloudSetDomainRequest(
|
||||
public_url="https://cloud.example.com",
|
||||
pull=False,
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await nextcloud_set_domain(
|
||||
tenant_id=test_tenant.id, request=request, db=db
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
assert "No online agent found" in str(exc_info.value.detail)
|
||||
|
||||
async def test_offline_agent_not_resolved(
|
||||
self, db: AsyncSession, test_tenant: Tenant
|
||||
):
|
||||
"""Offline agent should not be auto-resolved."""
|
||||
# Create an offline agent for the tenant
|
||||
offline_agent = Agent(
|
||||
id=uuid.uuid4(),
|
||||
tenant_id=test_tenant.id,
|
||||
name="offline-agent",
|
||||
version="1.0.0",
|
||||
status="offline",
|
||||
token="offline-token",
|
||||
)
|
||||
db.add(offline_agent)
|
||||
await db.commit()
|
||||
|
||||
request = NextcloudSetDomainRequest(
|
||||
public_url="https://cloud.example.com",
|
||||
pull=False,
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await nextcloud_set_domain(
|
||||
tenant_id=test_tenant.id, request=request, db=db
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
assert "No online agent found" in str(exc_info.value.detail)
|
||||
|
||||
async def test_task_persisted_to_database(
|
||||
self, db: AsyncSession, test_tenant: Tenant, test_agent_for_tenant: Agent
|
||||
):
|
||||
"""Verify the task is actually persisted and can be retrieved."""
|
||||
request = NextcloudSetDomainRequest(
|
||||
public_url="https://cloud.example.com",
|
||||
pull=False,
|
||||
)
|
||||
|
||||
task = await nextcloud_set_domain(
|
||||
tenant_id=test_tenant.id, request=request, db=db
|
||||
)
|
||||
|
||||
# Query the task back from the database
|
||||
result = await db.execute(select(Task).where(Task.id == task.id))
|
||||
retrieved_task = result.scalar_one_or_none()
|
||||
|
||||
assert retrieved_task is not None
|
||||
assert retrieved_task.type == "COMPOSITE"
|
||||
assert retrieved_task.tenant_id == test_tenant.id
|
||||
assert retrieved_task.agent_id == test_agent_for_tenant.id
|
||||
|
||||
async def test_auto_resolves_first_online_agent(
|
||||
self, db: AsyncSession, test_tenant: Tenant
|
||||
):
|
||||
"""Verify that the first online agent is auto-resolved."""
|
||||
# Create two agents for the tenant - one offline, one online
|
||||
offline_agent = Agent(
|
||||
id=uuid.uuid4(),
|
||||
tenant_id=test_tenant.id,
|
||||
name="offline-agent",
|
||||
version="1.0.0",
|
||||
status="offline",
|
||||
token="offline-token",
|
||||
)
|
||||
online_agent = Agent(
|
||||
id=uuid.uuid4(),
|
||||
tenant_id=test_tenant.id,
|
||||
name="online-agent",
|
||||
version="1.0.0",
|
||||
status="online",
|
||||
token="online-token",
|
||||
)
|
||||
db.add(offline_agent)
|
||||
db.add(online_agent)
|
||||
await db.commit()
|
||||
|
||||
request = NextcloudSetDomainRequest(
|
||||
public_url="https://cloud.example.com",
|
||||
pull=False,
|
||||
)
|
||||
|
||||
task = await nextcloud_set_domain(
|
||||
tenant_id=test_tenant.id, request=request, db=db
|
||||
)
|
||||
|
||||
# Should have resolved to the online agent
|
||||
assert task.agent_id == online_agent.id
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Fixtures for Initial Setup Tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
async def test_tenant_with_domain(db: AsyncSession) -> Tenant:
|
||||
"""Create a test tenant with domain configured."""
|
||||
tenant = Tenant(
|
||||
id=uuid.uuid4(),
|
||||
name="Test Tenant With Domain",
|
||||
domain="example.com",
|
||||
)
|
||||
db.add(tenant)
|
||||
await db.commit()
|
||||
await db.refresh(tenant)
|
||||
return tenant
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
async def test_agent_for_tenant_with_domain(
|
||||
db: AsyncSession, test_tenant_with_domain: Tenant
|
||||
) -> Agent:
|
||||
"""Create a test agent linked to test_tenant_with_domain with online status."""
|
||||
agent = Agent(
|
||||
id=uuid.uuid4(),
|
||||
tenant_id=test_tenant_with_domain.id,
|
||||
name="test-agent-for-tenant-domain",
|
||||
version="1.0.0",
|
||||
status="online",
|
||||
token="test-token-domain",
|
||||
)
|
||||
db.add(agent)
|
||||
await db.commit()
|
||||
await db.refresh(agent)
|
||||
return agent
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tests for Nextcloud Initial Setup Endpoint
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestNextcloudInitialSetupEndpoint:
|
||||
"""Tests for the nextcloud_initial_setup endpoint."""
|
||||
|
||||
@patch("app.routes.playbooks.check_nextcloud_availability")
|
||||
async def test_happy_path_creates_task(
|
||||
self,
|
||||
mock_health_check: AsyncMock,
|
||||
db: AsyncSession,
|
||||
test_tenant_with_domain: Tenant,
|
||||
test_agent_for_tenant_with_domain: Agent,
|
||||
):
|
||||
"""POST /tenants/{id}/nextcloud/setup returns 201 with PLAYWRIGHT task."""
|
||||
mock_health_check.return_value = True
|
||||
|
||||
request = NextcloudInitialSetupRequest(
|
||||
admin_username="admin",
|
||||
admin_password="securepassword123",
|
||||
)
|
||||
|
||||
task = await nextcloud_initial_setup(
|
||||
tenant_id=test_tenant_with_domain.id, request=request, db=db
|
||||
)
|
||||
|
||||
assert task.id is not None
|
||||
assert task.tenant_id == test_tenant_with_domain.id
|
||||
assert task.agent_id == test_agent_for_tenant_with_domain.id
|
||||
assert task.type == "PLAYWRIGHT"
|
||||
assert task.status == "pending"
|
||||
|
||||
@patch("app.routes.playbooks.check_nextcloud_availability")
|
||||
async def test_task_has_correct_payload(
|
||||
self,
|
||||
mock_health_check: AsyncMock,
|
||||
db: AsyncSession,
|
||||
test_tenant_with_domain: Tenant,
|
||||
test_agent_for_tenant_with_domain: Agent,
|
||||
):
|
||||
"""Verify response task payload contains scenario and inputs."""
|
||||
mock_health_check.return_value = True
|
||||
|
||||
request = NextcloudInitialSetupRequest(
|
||||
admin_username="myadmin",
|
||||
admin_password="mypassword123",
|
||||
)
|
||||
|
||||
task = await nextcloud_initial_setup(
|
||||
tenant_id=test_tenant_with_domain.id, request=request, db=db
|
||||
)
|
||||
|
||||
assert task.payload["scenario"] == "nextcloud_initial_setup"
|
||||
inputs = task.payload["inputs"]
|
||||
assert inputs["base_url"] == "https://cloud.example.com"
|
||||
assert inputs["admin_username"] == "myadmin"
|
||||
assert inputs["admin_password"] == "mypassword123"
|
||||
# allowed_domains should be in options, not inputs
|
||||
assert task.payload["options"]["allowed_domains"] == ["cloud.example.com"]
|
||||
|
||||
async def test_tenant_not_found_returns_404(self, db: AsyncSession):
|
||||
"""Non-existent tenant_id returns 404."""
|
||||
fake_tenant_id = uuid.uuid4()
|
||||
request = NextcloudInitialSetupRequest(
|
||||
admin_username="admin",
|
||||
admin_password="password123",
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await nextcloud_initial_setup(
|
||||
tenant_id=fake_tenant_id, request=request, db=db
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
assert f"Tenant {fake_tenant_id} not found" in str(exc_info.value.detail)
|
||||
|
||||
async def test_tenant_without_domain_returns_400(
|
||||
self, db: AsyncSession, test_tenant: Tenant
|
||||
):
|
||||
"""Tenant without domain configured returns 400."""
|
||||
request = NextcloudInitialSetupRequest(
|
||||
admin_username="admin",
|
||||
admin_password="password123",
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await nextcloud_initial_setup(
|
||||
tenant_id=test_tenant.id, request=request, db=db
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "no domain configured" in str(exc_info.value.detail)
|
||||
|
||||
async def test_no_online_agent_returns_404(
|
||||
self, db: AsyncSession, test_tenant_with_domain: Tenant
|
||||
):
|
||||
"""Tenant with no online agent returns 404."""
|
||||
request = NextcloudInitialSetupRequest(
|
||||
admin_username="admin",
|
||||
admin_password="password123",
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await nextcloud_initial_setup(
|
||||
tenant_id=test_tenant_with_domain.id, request=request, db=db
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
assert "No online agent found" in str(exc_info.value.detail)
|
||||
|
||||
@patch("app.routes.playbooks.check_nextcloud_availability")
|
||||
async def test_nextcloud_unavailable_returns_409(
|
||||
self,
|
||||
mock_health_check: AsyncMock,
|
||||
db: AsyncSession,
|
||||
test_tenant_with_domain: Tenant,
|
||||
test_agent_for_tenant_with_domain: Agent,
|
||||
):
|
||||
"""Health check failure returns 409."""
|
||||
mock_health_check.return_value = False
|
||||
|
||||
request = NextcloudInitialSetupRequest(
|
||||
admin_username="admin",
|
||||
admin_password="password123",
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await nextcloud_initial_setup(
|
||||
tenant_id=test_tenant_with_domain.id, request=request, db=db
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 409
|
||||
assert "Nextcloud not available" in str(exc_info.value.detail)
|
||||
|
||||
@patch("app.routes.playbooks.check_nextcloud_availability")
|
||||
async def test_health_check_called_with_correct_url(
|
||||
self,
|
||||
mock_health_check: AsyncMock,
|
||||
db: AsyncSession,
|
||||
test_tenant_with_domain: Tenant,
|
||||
test_agent_for_tenant_with_domain: Agent,
|
||||
):
|
||||
"""Verify health check is called with correct URL."""
|
||||
mock_health_check.return_value = True
|
||||
|
||||
request = NextcloudInitialSetupRequest(
|
||||
admin_username="admin",
|
||||
admin_password="password123",
|
||||
)
|
||||
|
||||
await nextcloud_initial_setup(
|
||||
tenant_id=test_tenant_with_domain.id, request=request, db=db
|
||||
)
|
||||
|
||||
mock_health_check.assert_called_once_with("https://cloud.example.com")
|
||||
|
||||
def test_username_too_short_raises_validation_error(self):
|
||||
"""Username less than 3 characters raises ValidationError."""
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
NextcloudInitialSetupRequest(
|
||||
admin_username="ab", # Too short
|
||||
admin_password="password123",
|
||||
)
|
||||
|
||||
assert "admin_username" in str(exc_info.value)
|
||||
|
||||
def test_username_invalid_chars_raises_validation_error(self):
|
||||
"""Username with invalid characters raises ValidationError."""
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
NextcloudInitialSetupRequest(
|
||||
admin_username="admin@user", # Invalid char @
|
||||
admin_password="password123",
|
||||
)
|
||||
|
||||
assert "admin_username" in str(exc_info.value)
|
||||
|
||||
def test_username_starting_with_number_raises_validation_error(self):
|
||||
"""Username starting with number raises ValidationError."""
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
NextcloudInitialSetupRequest(
|
||||
admin_username="123admin", # Starts with number
|
||||
admin_password="password123",
|
||||
)
|
||||
|
||||
assert "admin_username" in str(exc_info.value)
|
||||
|
||||
def test_password_too_short_raises_validation_error(self):
|
||||
"""Password less than 8 characters raises ValidationError."""
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
NextcloudInitialSetupRequest(
|
||||
admin_username="admin",
|
||||
admin_password="short", # Too short
|
||||
)
|
||||
|
||||
assert "admin_password" in str(exc_info.value)
|
||||
|
||||
def test_valid_username_with_underscore(self):
|
||||
"""Username with underscore is valid."""
|
||||
request = NextcloudInitialSetupRequest(
|
||||
admin_username="admin_user",
|
||||
admin_password="password123",
|
||||
)
|
||||
assert request.admin_username == "admin_user"
|
||||
|
||||
def test_valid_username_with_numbers(self):
|
||||
"""Username with numbers (not at start) is valid."""
|
||||
request = NextcloudInitialSetupRequest(
|
||||
admin_username="admin123",
|
||||
admin_password="password123",
|
||||
)
|
||||
assert request.admin_username == "admin123"
|
||||
|
||||
@patch("app.routes.playbooks.check_nextcloud_availability")
|
||||
async def test_task_persisted_to_database(
|
||||
self,
|
||||
mock_health_check: AsyncMock,
|
||||
db: AsyncSession,
|
||||
test_tenant_with_domain: Tenant,
|
||||
test_agent_for_tenant_with_domain: Agent,
|
||||
):
|
||||
"""Verify the task is actually persisted and can be retrieved."""
|
||||
mock_health_check.return_value = True
|
||||
|
||||
request = NextcloudInitialSetupRequest(
|
||||
admin_username="admin",
|
||||
admin_password="password123",
|
||||
)
|
||||
|
||||
task = await nextcloud_initial_setup(
|
||||
tenant_id=test_tenant_with_domain.id, request=request, db=db
|
||||
)
|
||||
|
||||
# Query the task back from the database
|
||||
result = await db.execute(select(Task).where(Task.id == task.id))
|
||||
retrieved_task = result.scalar_one_or_none()
|
||||
|
||||
assert retrieved_task is not None
|
||||
assert retrieved_task.type == "PLAYWRIGHT"
|
||||
assert retrieved_task.tenant_id == test_tenant_with_domain.id
|
||||
assert retrieved_task.agent_id == test_agent_for_tenant_with_domain.id
|
||||
300
letsbe-orchestrator/tests/routes/test_registration_tokens.py
Normal file
300
letsbe-orchestrator/tests/routes/test_registration_tokens.py
Normal file
@@ -0,0 +1,300 @@
|
||||
"""Tests for registration token endpoints."""
|
||||
|
||||
import hashlib
|
||||
import uuid
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.base import utc_now
|
||||
from app.models.registration_token import RegistrationToken
|
||||
from app.models.tenant import Tenant
|
||||
from app.routes.registration_tokens import (
|
||||
create_registration_token,
|
||||
get_registration_token,
|
||||
list_registration_tokens,
|
||||
revoke_registration_token,
|
||||
)
|
||||
from app.schemas.registration_token import RegistrationTokenCreate
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestCreateRegistrationToken:
|
||||
"""Tests for the create_registration_token endpoint."""
|
||||
|
||||
async def test_create_token_success(self, db: AsyncSession, test_tenant: Tenant):
|
||||
"""Successfully create a registration token."""
|
||||
request = RegistrationTokenCreate(
|
||||
description="Test token",
|
||||
max_uses=5,
|
||||
expires_in_hours=24,
|
||||
)
|
||||
|
||||
response = await create_registration_token(
|
||||
tenant_id=test_tenant.id, request=request, db=db
|
||||
)
|
||||
|
||||
assert response.id is not None
|
||||
assert response.tenant_id == test_tenant.id
|
||||
assert response.description == "Test token"
|
||||
assert response.max_uses == 5
|
||||
assert response.use_count == 0
|
||||
assert response.revoked is False
|
||||
assert response.token is not None # Plaintext token returned once
|
||||
assert response.expires_at is not None
|
||||
|
||||
async def test_create_token_stores_hash(self, db: AsyncSession, test_tenant: Tenant):
|
||||
"""Token is stored as hash, not plaintext."""
|
||||
request = RegistrationTokenCreate(description="Hash test")
|
||||
|
||||
response = await create_registration_token(
|
||||
tenant_id=test_tenant.id, request=request, db=db
|
||||
)
|
||||
|
||||
# Retrieve from database
|
||||
result = await db.execute(
|
||||
select(RegistrationToken).where(RegistrationToken.id == response.id)
|
||||
)
|
||||
token_record = result.scalar_one()
|
||||
|
||||
# Verify hash is stored, not plaintext
|
||||
expected_hash = hashlib.sha256(response.token.encode()).hexdigest()
|
||||
assert token_record.token_hash == expected_hash
|
||||
assert token_record.token_hash != response.token
|
||||
|
||||
async def test_create_token_default_values(
|
||||
self, db: AsyncSession, test_tenant: Tenant
|
||||
):
|
||||
"""Default values are applied correctly."""
|
||||
request = RegistrationTokenCreate()
|
||||
|
||||
response = await create_registration_token(
|
||||
tenant_id=test_tenant.id, request=request, db=db
|
||||
)
|
||||
|
||||
assert response.max_uses == 1 # Default
|
||||
assert response.description is None
|
||||
assert response.expires_at is None
|
||||
|
||||
async def test_create_token_tenant_not_found(self, db: AsyncSession):
|
||||
"""Returns 404 when tenant doesn't exist."""
|
||||
fake_tenant_id = uuid.uuid4()
|
||||
request = RegistrationTokenCreate()
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await create_registration_token(
|
||||
tenant_id=fake_tenant_id, request=request, db=db
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
assert f"Tenant {fake_tenant_id} not found" in str(exc_info.value.detail)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestListRegistrationTokens:
|
||||
"""Tests for the list_registration_tokens endpoint."""
|
||||
|
||||
async def test_list_tokens_empty(self, db: AsyncSession, test_tenant: Tenant):
|
||||
"""Returns empty list when no tokens exist."""
|
||||
response = await list_registration_tokens(tenant_id=test_tenant.id, db=db)
|
||||
|
||||
assert response.tokens == []
|
||||
assert response.total == 0
|
||||
|
||||
async def test_list_tokens_multiple(self, db: AsyncSession, test_tenant: Tenant):
|
||||
"""Returns all tokens for tenant."""
|
||||
# Create multiple tokens
|
||||
for i in range(3):
|
||||
token = RegistrationToken(
|
||||
tenant_id=test_tenant.id,
|
||||
token_hash=f"hash-{i}",
|
||||
description=f"Token {i}",
|
||||
)
|
||||
db.add(token)
|
||||
await db.commit()
|
||||
|
||||
response = await list_registration_tokens(tenant_id=test_tenant.id, db=db)
|
||||
|
||||
assert len(response.tokens) == 3
|
||||
assert response.total == 3
|
||||
|
||||
async def test_list_tokens_tenant_isolation(
|
||||
self, db: AsyncSession, test_tenant: Tenant
|
||||
):
|
||||
"""Only returns tokens for the specified tenant."""
|
||||
# Create another tenant
|
||||
other_tenant = Tenant(id=uuid.uuid4(), name="Other Tenant")
|
||||
db.add(other_tenant)
|
||||
|
||||
# Create token for test_tenant
|
||||
token1 = RegistrationToken(
|
||||
tenant_id=test_tenant.id, token_hash="hash-1", description="Tenant 1"
|
||||
)
|
||||
# Create token for other_tenant
|
||||
token2 = RegistrationToken(
|
||||
tenant_id=other_tenant.id, token_hash="hash-2", description="Tenant 2"
|
||||
)
|
||||
db.add_all([token1, token2])
|
||||
await db.commit()
|
||||
|
||||
response = await list_registration_tokens(tenant_id=test_tenant.id, db=db)
|
||||
|
||||
assert len(response.tokens) == 1
|
||||
assert response.tokens[0].description == "Tenant 1"
|
||||
|
||||
async def test_list_tokens_tenant_not_found(self, db: AsyncSession):
|
||||
"""Returns 404 when tenant doesn't exist."""
|
||||
fake_tenant_id = uuid.uuid4()
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await list_registration_tokens(tenant_id=fake_tenant_id, db=db)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestGetRegistrationToken:
|
||||
"""Tests for the get_registration_token endpoint."""
|
||||
|
||||
async def test_get_token_success(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
test_tenant: Tenant,
|
||||
test_registration_token: tuple[RegistrationToken, str],
|
||||
):
|
||||
"""Successfully retrieve a token."""
|
||||
token_record, _ = test_registration_token
|
||||
|
||||
response = await get_registration_token(
|
||||
tenant_id=test_tenant.id, token_id=token_record.id, db=db
|
||||
)
|
||||
|
||||
assert response.id == token_record.id
|
||||
assert response.description == token_record.description
|
||||
|
||||
async def test_get_token_not_found(self, db: AsyncSession, test_tenant: Tenant):
|
||||
"""Returns 404 when token doesn't exist."""
|
||||
fake_token_id = uuid.uuid4()
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_registration_token(
|
||||
tenant_id=test_tenant.id, token_id=fake_token_id, db=db
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
async def test_get_token_wrong_tenant(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
test_tenant: Tenant,
|
||||
test_registration_token: tuple[RegistrationToken, str],
|
||||
):
|
||||
"""Returns 404 when token belongs to different tenant."""
|
||||
token_record, _ = test_registration_token
|
||||
other_tenant = Tenant(id=uuid.uuid4(), name="Other Tenant")
|
||||
db.add(other_tenant)
|
||||
await db.commit()
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_registration_token(
|
||||
tenant_id=other_tenant.id, token_id=token_record.id, db=db
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestRevokeRegistrationToken:
|
||||
"""Tests for the revoke_registration_token endpoint."""
|
||||
|
||||
async def test_revoke_token_success(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
test_tenant: Tenant,
|
||||
test_registration_token: tuple[RegistrationToken, str],
|
||||
):
|
||||
"""Successfully revoke a token."""
|
||||
token_record, _ = test_registration_token
|
||||
assert token_record.revoked is False
|
||||
|
||||
await revoke_registration_token(
|
||||
tenant_id=test_tenant.id, token_id=token_record.id, db=db
|
||||
)
|
||||
|
||||
# Refresh to see updated value
|
||||
await db.refresh(token_record)
|
||||
assert token_record.revoked is True
|
||||
|
||||
async def test_revoke_token_not_found(self, db: AsyncSession, test_tenant: Tenant):
|
||||
"""Returns 404 when token doesn't exist."""
|
||||
fake_token_id = uuid.uuid4()
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await revoke_registration_token(
|
||||
tenant_id=test_tenant.id, token_id=fake_token_id, db=db
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestRegistrationTokenIsValid:
|
||||
"""Tests for the RegistrationToken.is_valid() method."""
|
||||
|
||||
async def test_valid_token(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
test_tenant: Tenant,
|
||||
test_registration_token: tuple[RegistrationToken, str],
|
||||
):
|
||||
"""Token is valid when not revoked, not expired, and not exhausted."""
|
||||
token_record, _ = test_registration_token
|
||||
assert token_record.is_valid() is True
|
||||
|
||||
async def test_revoked_token_invalid(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
test_tenant: Tenant,
|
||||
test_registration_token: tuple[RegistrationToken, str],
|
||||
):
|
||||
"""Revoked token is invalid."""
|
||||
token_record, _ = test_registration_token
|
||||
token_record.revoked = True
|
||||
await db.commit()
|
||||
|
||||
assert token_record.is_valid() is False
|
||||
|
||||
async def test_expired_token_invalid(self, db: AsyncSession, test_tenant: Tenant):
|
||||
"""Expired token is invalid."""
|
||||
token = RegistrationToken(
|
||||
tenant_id=test_tenant.id,
|
||||
token_hash="test-hash",
|
||||
expires_at=utc_now() - timedelta(hours=1),
|
||||
)
|
||||
db.add(token)
|
||||
await db.commit()
|
||||
|
||||
assert token.is_valid() is False
|
||||
|
||||
async def test_exhausted_token_invalid(self, db: AsyncSession, test_tenant: Tenant):
|
||||
"""Token that reached max_uses is invalid."""
|
||||
token = RegistrationToken(
|
||||
tenant_id=test_tenant.id, token_hash="test-hash", max_uses=3, use_count=3
|
||||
)
|
||||
db.add(token)
|
||||
await db.commit()
|
||||
|
||||
assert token.is_valid() is False
|
||||
|
||||
async def test_unlimited_uses_token(self, db: AsyncSession, test_tenant: Tenant):
|
||||
"""Token with max_uses=0 has unlimited uses."""
|
||||
token = RegistrationToken(
|
||||
tenant_id=test_tenant.id, token_hash="test-hash", max_uses=0, use_count=1000
|
||||
)
|
||||
db.add(token)
|
||||
await db.commit()
|
||||
|
||||
assert token.is_valid() is True
|
||||
396
letsbe-orchestrator/tests/routes/test_tasks_auth.py
Normal file
396
letsbe-orchestrator/tests/routes/test_tasks_auth.py
Normal file
@@ -0,0 +1,396 @@
|
||||
"""Tests for task endpoints with authentication."""
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.agent import Agent
|
||||
from app.models.task import Task, TaskStatus
|
||||
from app.models.tenant import Tenant
|
||||
from app.routes.tasks import (
|
||||
get_next_pending_task,
|
||||
get_next_task_endpoint,
|
||||
update_task_endpoint,
|
||||
)
|
||||
from app.schemas.task import TaskUpdate
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestGetNextPendingTask:
|
||||
"""Tests for the get_next_pending_task helper function."""
|
||||
|
||||
async def test_returns_pending_task(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
test_tenant: Tenant,
|
||||
test_agent_with_secret: tuple[Agent, str],
|
||||
):
|
||||
"""Returns oldest pending task for agent's tenant."""
|
||||
agent, _ = test_agent_with_secret
|
||||
|
||||
# Create tasks
|
||||
task1 = Task(
|
||||
tenant_id=test_tenant.id,
|
||||
type="TEST",
|
||||
payload={"order": 1},
|
||||
status=TaskStatus.PENDING.value,
|
||||
)
|
||||
task2 = Task(
|
||||
tenant_id=test_tenant.id,
|
||||
type="TEST",
|
||||
payload={"order": 2},
|
||||
status=TaskStatus.PENDING.value,
|
||||
)
|
||||
db.add_all([task1, task2])
|
||||
await db.commit()
|
||||
|
||||
result = await get_next_pending_task(db, agent)
|
||||
|
||||
# Should return oldest (task1)
|
||||
assert result is not None
|
||||
assert result.payload["order"] == 1
|
||||
|
||||
async def test_filters_by_tenant(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
test_tenant: Tenant,
|
||||
test_agent_with_secret: tuple[Agent, str],
|
||||
):
|
||||
"""Only returns tasks for agent's tenant."""
|
||||
agent, _ = test_agent_with_secret
|
||||
|
||||
# Create tenant for another tenant
|
||||
other_tenant = Tenant(id=uuid.uuid4(), name="Other Tenant")
|
||||
db.add(other_tenant)
|
||||
|
||||
# Create task for other tenant
|
||||
other_task = Task(
|
||||
tenant_id=other_tenant.id,
|
||||
type="TEST",
|
||||
payload={},
|
||||
status=TaskStatus.PENDING.value,
|
||||
)
|
||||
db.add(other_task)
|
||||
await db.commit()
|
||||
|
||||
result = await get_next_pending_task(db, agent)
|
||||
|
||||
# Should not see other tenant's task
|
||||
assert result is None
|
||||
|
||||
async def test_returns_none_when_empty(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
test_agent_with_secret: tuple[Agent, str],
|
||||
):
|
||||
"""Returns None when no pending tasks exist."""
|
||||
agent, _ = test_agent_with_secret
|
||||
|
||||
result = await get_next_pending_task(db, agent)
|
||||
|
||||
assert result is None
|
||||
|
||||
async def test_skips_non_pending_tasks(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
test_tenant: Tenant,
|
||||
test_agent_with_secret: tuple[Agent, str],
|
||||
):
|
||||
"""Only returns tasks with PENDING status."""
|
||||
agent, _ = test_agent_with_secret
|
||||
|
||||
# Create running task
|
||||
running_task = Task(
|
||||
tenant_id=test_tenant.id,
|
||||
type="TEST",
|
||||
payload={},
|
||||
status=TaskStatus.RUNNING.value,
|
||||
)
|
||||
db.add(running_task)
|
||||
await db.commit()
|
||||
|
||||
result = await get_next_pending_task(db, agent)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestGetNextTaskEndpoint:
|
||||
"""Tests for the GET /tasks/next endpoint."""
|
||||
|
||||
async def test_claims_task(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
test_tenant: Tenant,
|
||||
test_agent_with_secret: tuple[Agent, str],
|
||||
):
|
||||
"""Successfully claims a pending task."""
|
||||
agent, _ = test_agent_with_secret
|
||||
|
||||
task = Task(
|
||||
tenant_id=test_tenant.id,
|
||||
type="TEST",
|
||||
payload={},
|
||||
status=TaskStatus.PENDING.value,
|
||||
)
|
||||
db.add(task)
|
||||
await db.commit()
|
||||
|
||||
result = await get_next_task_endpoint(db=db, current_agent=agent)
|
||||
|
||||
assert result is not None
|
||||
assert result.status == TaskStatus.RUNNING.value
|
||||
assert result.agent_id == agent.id
|
||||
|
||||
async def test_returns_none_when_empty(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
test_agent_with_secret: tuple[Agent, str],
|
||||
):
|
||||
"""Returns None when no tasks available."""
|
||||
agent, _ = test_agent_with_secret
|
||||
|
||||
result = await get_next_task_endpoint(db=db, current_agent=agent)
|
||||
|
||||
assert result is None
|
||||
|
||||
async def test_tenant_isolation(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
test_tenant: Tenant,
|
||||
test_agent_with_secret: tuple[Agent, str],
|
||||
):
|
||||
"""Agent can only claim tasks from its tenant."""
|
||||
agent, _ = test_agent_with_secret
|
||||
|
||||
# Create task for different tenant
|
||||
other_tenant = Tenant(id=uuid.uuid4(), name="Other")
|
||||
db.add(other_tenant)
|
||||
|
||||
other_task = Task(
|
||||
tenant_id=other_tenant.id,
|
||||
type="TEST",
|
||||
payload={},
|
||||
status=TaskStatus.PENDING.value,
|
||||
)
|
||||
db.add(other_task)
|
||||
await db.commit()
|
||||
|
||||
result = await get_next_task_endpoint(db=db, current_agent=agent)
|
||||
|
||||
# Should not see other tenant's task
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestUpdateTaskEndpoint:
|
||||
"""Tests for the PATCH /tasks/{task_id} endpoint."""
|
||||
|
||||
async def test_update_task_success(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
test_tenant: Tenant,
|
||||
test_agent_with_secret: tuple[Agent, str],
|
||||
):
|
||||
"""Successfully update an assigned task."""
|
||||
agent, _ = test_agent_with_secret
|
||||
|
||||
task = Task(
|
||||
tenant_id=test_tenant.id,
|
||||
type="TEST",
|
||||
payload={},
|
||||
status=TaskStatus.RUNNING.value,
|
||||
agent_id=agent.id, # Assigned to this agent
|
||||
)
|
||||
db.add(task)
|
||||
await db.commit()
|
||||
|
||||
update = TaskUpdate(status=TaskStatus.COMPLETED, result={"success": True})
|
||||
|
||||
result = await update_task_endpoint(
|
||||
task_id=task.id, task_update=update, db=db, current_agent=agent
|
||||
)
|
||||
|
||||
assert result.status == TaskStatus.COMPLETED.value
|
||||
assert result.result == {"success": True}
|
||||
|
||||
async def test_update_task_not_found(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
test_agent_with_secret: tuple[Agent, str],
|
||||
):
|
||||
"""Returns 404 for non-existent task."""
|
||||
agent, _ = test_agent_with_secret
|
||||
fake_task_id = uuid.uuid4()
|
||||
|
||||
update = TaskUpdate(status=TaskStatus.COMPLETED)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await update_task_endpoint(
|
||||
task_id=fake_task_id, task_update=update, db=db, current_agent=agent
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
async def test_update_task_wrong_tenant(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
test_tenant: Tenant,
|
||||
test_agent_with_secret: tuple[Agent, str],
|
||||
):
|
||||
"""Returns 403 when task belongs to different tenant."""
|
||||
agent, _ = test_agent_with_secret
|
||||
|
||||
# Create task for different tenant
|
||||
other_tenant = Tenant(id=uuid.uuid4(), name="Other")
|
||||
db.add(other_tenant)
|
||||
|
||||
other_task = Task(
|
||||
tenant_id=other_tenant.id,
|
||||
type="TEST",
|
||||
payload={},
|
||||
status=TaskStatus.RUNNING.value,
|
||||
agent_id=agent.id, # Even if assigned to agent
|
||||
)
|
||||
db.add(other_task)
|
||||
await db.commit()
|
||||
|
||||
update = TaskUpdate(status=TaskStatus.COMPLETED)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await update_task_endpoint(
|
||||
task_id=other_task.id, task_update=update, db=db, current_agent=agent
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
assert "does not belong to this tenant" in str(exc_info.value.detail)
|
||||
|
||||
async def test_update_task_not_assigned(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
test_tenant: Tenant,
|
||||
test_agent_with_secret: tuple[Agent, str],
|
||||
):
|
||||
"""Returns 403 when task is not assigned to requesting agent."""
|
||||
agent, _ = test_agent_with_secret
|
||||
|
||||
# Create task assigned to different agent
|
||||
other_agent_id = uuid.uuid4()
|
||||
task = Task(
|
||||
tenant_id=test_tenant.id,
|
||||
type="TEST",
|
||||
payload={},
|
||||
status=TaskStatus.RUNNING.value,
|
||||
agent_id=other_agent_id, # Different agent
|
||||
)
|
||||
db.add(task)
|
||||
await db.commit()
|
||||
|
||||
update = TaskUpdate(status=TaskStatus.COMPLETED)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await update_task_endpoint(
|
||||
task_id=task.id, task_update=update, db=db, current_agent=agent
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
assert "not assigned to this agent" in str(exc_info.value.detail)
|
||||
|
||||
async def test_update_task_unassigned_forbidden(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
test_tenant: Tenant,
|
||||
test_agent_with_secret: tuple[Agent, str],
|
||||
):
|
||||
"""Returns 403 when task has no agent_id assigned."""
|
||||
agent, _ = test_agent_with_secret
|
||||
|
||||
task = Task(
|
||||
tenant_id=test_tenant.id,
|
||||
type="TEST",
|
||||
payload={},
|
||||
status=TaskStatus.PENDING.value,
|
||||
agent_id=None, # Not assigned
|
||||
)
|
||||
db.add(task)
|
||||
await db.commit()
|
||||
|
||||
update = TaskUpdate(status=TaskStatus.COMPLETED)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await update_task_endpoint(
|
||||
task_id=task.id, task_update=update, db=db, current_agent=agent
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestSharedAgentBehavior:
|
||||
"""Tests for agents without tenant_id (shared agents)."""
|
||||
|
||||
async def test_shared_agent_can_claim_any_task(
|
||||
self, db: AsyncSession, test_tenant: Tenant
|
||||
):
|
||||
"""Shared agent (no tenant_id) can claim tasks from any tenant."""
|
||||
# Create shared agent (no tenant_id)
|
||||
shared_agent = Agent(
|
||||
id=uuid.uuid4(),
|
||||
name="shared-agent",
|
||||
version="1.0.0",
|
||||
status="online",
|
||||
token="shared-token",
|
||||
secret_hash="dummy-hash",
|
||||
tenant_id=None,
|
||||
)
|
||||
db.add(shared_agent)
|
||||
|
||||
task = Task(
|
||||
tenant_id=test_tenant.id,
|
||||
type="TEST",
|
||||
payload={},
|
||||
status=TaskStatus.PENDING.value,
|
||||
)
|
||||
db.add(task)
|
||||
await db.commit()
|
||||
|
||||
result = await get_next_task_endpoint(db=db, current_agent=shared_agent)
|
||||
|
||||
assert result is not None
|
||||
assert result.agent_id == shared_agent.id
|
||||
|
||||
async def test_shared_agent_can_update_any_tenant_task(
|
||||
self, db: AsyncSession, test_tenant: Tenant
|
||||
):
|
||||
"""Shared agent can update tasks from any tenant if assigned."""
|
||||
shared_agent = Agent(
|
||||
id=uuid.uuid4(),
|
||||
name="shared-agent",
|
||||
version="1.0.0",
|
||||
status="online",
|
||||
token="shared-token",
|
||||
secret_hash="dummy-hash",
|
||||
tenant_id=None,
|
||||
)
|
||||
db.add(shared_agent)
|
||||
|
||||
task = Task(
|
||||
tenant_id=test_tenant.id,
|
||||
type="TEST",
|
||||
payload={},
|
||||
status=TaskStatus.RUNNING.value,
|
||||
agent_id=shared_agent.id,
|
||||
)
|
||||
db.add(task)
|
||||
await db.commit()
|
||||
|
||||
update = TaskUpdate(status=TaskStatus.COMPLETED)
|
||||
|
||||
result = await update_task_endpoint(
|
||||
task_id=task.id, task_update=update, db=db, current_agent=shared_agent
|
||||
)
|
||||
|
||||
assert result.status == TaskStatus.COMPLETED.value
|
||||
192
letsbe-orchestrator/tests/test_events.py
Normal file
192
letsbe-orchestrator/tests/test_events.py
Normal file
@@ -0,0 +1,192 @@
|
||||
"""Tests for the events route module."""
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.event import Event
|
||||
from app.models.tenant import Tenant
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestEventModel:
|
||||
"""Tests for Event model creation and retrieval."""
|
||||
|
||||
async def test_create_event(self, db: AsyncSession, test_tenant: Tenant):
|
||||
"""Verify that an event can be created with required fields."""
|
||||
event = Event(
|
||||
tenant_id=test_tenant.id,
|
||||
event_type="agent.registered",
|
||||
payload={"agent_name": "test-agent", "version": "1.0.0"},
|
||||
)
|
||||
db.add(event)
|
||||
await db.commit()
|
||||
await db.refresh(event)
|
||||
|
||||
assert event.id is not None
|
||||
assert event.tenant_id == test_tenant.id
|
||||
assert event.event_type == "agent.registered"
|
||||
assert event.payload["agent_name"] == "test-agent"
|
||||
assert event.created_at is not None
|
||||
|
||||
async def test_create_event_with_task_id(self, db: AsyncSession, test_tenant: Tenant):
|
||||
"""Verify that an event can be created with an optional task_id."""
|
||||
task_id = uuid.uuid4()
|
||||
|
||||
event = Event(
|
||||
tenant_id=test_tenant.id,
|
||||
task_id=task_id,
|
||||
event_type="task.completed",
|
||||
payload={"result": "success"},
|
||||
)
|
||||
db.add(event)
|
||||
await db.commit()
|
||||
await db.refresh(event)
|
||||
|
||||
assert event.task_id == task_id
|
||||
|
||||
async def test_create_event_without_task_id(self, db: AsyncSession, test_tenant: Tenant):
|
||||
"""Verify that task_id defaults to None."""
|
||||
event = Event(
|
||||
tenant_id=test_tenant.id,
|
||||
event_type="system.startup",
|
||||
payload={},
|
||||
)
|
||||
db.add(event)
|
||||
await db.commit()
|
||||
await db.refresh(event)
|
||||
|
||||
assert event.task_id is None
|
||||
|
||||
async def test_create_event_with_empty_payload(self, db: AsyncSession, test_tenant: Tenant):
|
||||
"""Verify that events can have empty payloads."""
|
||||
event = Event(
|
||||
tenant_id=test_tenant.id,
|
||||
event_type="heartbeat",
|
||||
payload={},
|
||||
)
|
||||
db.add(event)
|
||||
await db.commit()
|
||||
await db.refresh(event)
|
||||
|
||||
assert event.payload == {}
|
||||
|
||||
async def test_create_event_with_complex_payload(self, db: AsyncSession, test_tenant: Tenant):
|
||||
"""Verify that events can have complex nested payloads."""
|
||||
payload = {
|
||||
"metrics": {
|
||||
"cpu_usage": 45.2,
|
||||
"memory_mb": 1024,
|
||||
"disk_io": {"read": 100, "write": 200},
|
||||
},
|
||||
"tags": ["production", "high-priority"],
|
||||
"count": 42,
|
||||
}
|
||||
|
||||
event = Event(
|
||||
tenant_id=test_tenant.id,
|
||||
event_type="metrics.collected",
|
||||
payload=payload,
|
||||
)
|
||||
db.add(event)
|
||||
await db.commit()
|
||||
await db.refresh(event)
|
||||
|
||||
assert event.payload["metrics"]["cpu_usage"] == 45.2
|
||||
assert event.payload["tags"] == ["production", "high-priority"]
|
||||
|
||||
async def test_retrieve_event_by_id(self, db: AsyncSession, test_tenant: Tenant):
|
||||
"""Verify that an event can be retrieved by its ID."""
|
||||
event = Event(
|
||||
tenant_id=test_tenant.id,
|
||||
event_type="test.retrieve",
|
||||
payload={"key": "value"},
|
||||
)
|
||||
db.add(event)
|
||||
await db.commit()
|
||||
await db.refresh(event)
|
||||
|
||||
result = await db.execute(select(Event).where(Event.id == event.id))
|
||||
retrieved = result.scalar_one_or_none()
|
||||
|
||||
assert retrieved is not None
|
||||
assert retrieved.event_type == "test.retrieve"
|
||||
assert retrieved.payload["key"] == "value"
|
||||
|
||||
async def test_filter_events_by_type(self, db: AsyncSession, test_tenant: Tenant):
|
||||
"""Verify that events can be filtered by event_type."""
|
||||
# Create events of different types
|
||||
for event_type in ["agent.registered", "task.completed", "agent.registered"]:
|
||||
event = Event(
|
||||
tenant_id=test_tenant.id,
|
||||
event_type=event_type,
|
||||
payload={},
|
||||
)
|
||||
db.add(event)
|
||||
|
||||
await db.commit()
|
||||
|
||||
result = await db.execute(
|
||||
select(Event).where(Event.event_type == "agent.registered")
|
||||
)
|
||||
events = list(result.scalars().all())
|
||||
|
||||
assert len(events) == 2
|
||||
|
||||
async def test_filter_events_by_tenant(self, db: AsyncSession, test_tenant: Tenant):
|
||||
"""Verify that events can be filtered by tenant_id."""
|
||||
# Create event for test tenant
|
||||
event = Event(
|
||||
tenant_id=test_tenant.id,
|
||||
event_type="test.tenant_filter",
|
||||
payload={},
|
||||
)
|
||||
db.add(event)
|
||||
await db.commit()
|
||||
|
||||
result = await db.execute(
|
||||
select(Event).where(Event.tenant_id == test_tenant.id)
|
||||
)
|
||||
events = list(result.scalars().all())
|
||||
|
||||
assert len(events) >= 1
|
||||
assert all(e.tenant_id == test_tenant.id for e in events)
|
||||
|
||||
async def test_events_ordered_by_created_at(self, db: AsyncSession, test_tenant: Tenant):
|
||||
"""Verify that events can be ordered by created_at."""
|
||||
for i in range(3):
|
||||
event = Event(
|
||||
tenant_id=test_tenant.id,
|
||||
event_type=f"test.order_{i}",
|
||||
payload={"index": i},
|
||||
)
|
||||
db.add(event)
|
||||
|
||||
await db.commit()
|
||||
|
||||
result = await db.execute(
|
||||
select(Event)
|
||||
.where(Event.tenant_id == test_tenant.id)
|
||||
.order_by(Event.created_at.desc())
|
||||
)
|
||||
events = list(result.scalars().all())
|
||||
|
||||
assert len(events) >= 3
|
||||
|
||||
async def test_event_repr(self, db: AsyncSession, test_tenant: Tenant):
|
||||
"""Verify the __repr__ method of Event."""
|
||||
event = Event(
|
||||
tenant_id=test_tenant.id,
|
||||
event_type="test.repr",
|
||||
payload={},
|
||||
)
|
||||
db.add(event)
|
||||
await db.commit()
|
||||
await db.refresh(event)
|
||||
|
||||
repr_str = repr(event)
|
||||
assert "Event" in repr_str
|
||||
assert "test.repr" in repr_str
|
||||
254
letsbe-orchestrator/tests/test_hub_telemetry.py
Normal file
254
letsbe-orchestrator/tests/test_hub_telemetry.py
Normal file
@@ -0,0 +1,254 @@
|
||||
"""Tests for the Hub Telemetry service."""
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.hub_telemetry import HubTelemetryService
|
||||
|
||||
|
||||
class TestHubTelemetryServiceStart:
|
||||
"""Tests for the start/stop lifecycle of HubTelemetryService."""
|
||||
|
||||
def _reset_service(self):
|
||||
"""Reset class state between tests."""
|
||||
HubTelemetryService._task = None
|
||||
HubTelemetryService._shutdown_event = None
|
||||
HubTelemetryService._start_time = None
|
||||
HubTelemetryService._last_sent_at = None
|
||||
HubTelemetryService._client = None
|
||||
HubTelemetryService._consecutive_failures = 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_skips_when_telemetry_disabled(self):
|
||||
"""Verify start() does nothing when HUB_TELEMETRY_ENABLED is False."""
|
||||
self._reset_service()
|
||||
|
||||
with patch("app.services.hub_telemetry.settings") as mock_settings:
|
||||
mock_settings.HUB_TELEMETRY_ENABLED = False
|
||||
|
||||
await HubTelemetryService.start()
|
||||
|
||||
assert HubTelemetryService._task is None
|
||||
assert HubTelemetryService._client is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_skips_when_hub_url_missing(self):
|
||||
"""Verify start() does nothing when HUB_URL is not set."""
|
||||
self._reset_service()
|
||||
|
||||
with patch("app.services.hub_telemetry.settings") as mock_settings:
|
||||
mock_settings.HUB_TELEMETRY_ENABLED = True
|
||||
mock_settings.HUB_URL = None
|
||||
|
||||
await HubTelemetryService.start()
|
||||
|
||||
assert HubTelemetryService._task is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_skips_when_hub_api_key_missing(self):
|
||||
"""Verify start() does nothing when HUB_API_KEY is not set."""
|
||||
self._reset_service()
|
||||
|
||||
with patch("app.services.hub_telemetry.settings") as mock_settings:
|
||||
mock_settings.HUB_TELEMETRY_ENABLED = True
|
||||
mock_settings.HUB_URL = "https://hub.example.com"
|
||||
mock_settings.HUB_API_KEY = None
|
||||
|
||||
await HubTelemetryService.start()
|
||||
|
||||
assert HubTelemetryService._task is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_skips_when_instance_id_missing(self):
|
||||
"""Verify start() does nothing when INSTANCE_ID is not set."""
|
||||
self._reset_service()
|
||||
|
||||
with patch("app.services.hub_telemetry.settings") as mock_settings:
|
||||
mock_settings.HUB_TELEMETRY_ENABLED = True
|
||||
mock_settings.HUB_URL = "https://hub.example.com"
|
||||
mock_settings.HUB_API_KEY = "test-key"
|
||||
mock_settings.INSTANCE_ID = None
|
||||
|
||||
await HubTelemetryService.start()
|
||||
|
||||
assert HubTelemetryService._task is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_without_start(self):
|
||||
"""Verify stop() handles gracefully when service was never started."""
|
||||
self._reset_service()
|
||||
|
||||
# Should not raise
|
||||
await HubTelemetryService.stop()
|
||||
|
||||
|
||||
class TestHubTelemetryFormatters:
|
||||
"""Tests for the metric formatting class methods."""
|
||||
|
||||
def test_format_agent_counts_empty(self):
|
||||
"""Verify _format_agent_counts handles empty rows."""
|
||||
result = HubTelemetryService._format_agent_counts([])
|
||||
|
||||
assert result == {
|
||||
"online_count": 0,
|
||||
"offline_count": 0,
|
||||
"total_count": 0,
|
||||
}
|
||||
|
||||
def test_format_agent_counts_with_online_agents(self):
|
||||
"""Verify _format_agent_counts counts online agents correctly."""
|
||||
# Create mock rows that mimic SQLAlchemy result rows
|
||||
from enum import Enum
|
||||
|
||||
class MockAgentStatus(str, Enum):
|
||||
ONLINE = "online"
|
||||
OFFLINE = "offline"
|
||||
|
||||
# Patch AgentStatus for comparison
|
||||
with patch("app.services.hub_telemetry.AgentStatus") as mock_status:
|
||||
mock_status.ONLINE = MockAgentStatus.ONLINE
|
||||
mock_status.OFFLINE = MockAgentStatus.OFFLINE
|
||||
|
||||
online_row = MagicMock()
|
||||
online_row.status = MockAgentStatus.ONLINE
|
||||
online_row.count = 3
|
||||
|
||||
offline_row = MagicMock()
|
||||
offline_row.status = MockAgentStatus.OFFLINE
|
||||
offline_row.count = 1
|
||||
|
||||
result = HubTelemetryService._format_agent_counts([online_row, offline_row])
|
||||
|
||||
assert result["online_count"] == 3
|
||||
assert result["offline_count"] == 1
|
||||
assert result["total_count"] == 4
|
||||
|
||||
def test_format_task_counts_empty(self):
|
||||
"""Verify _format_task_counts handles empty rows."""
|
||||
result = HubTelemetryService._format_task_counts([])
|
||||
|
||||
assert result == {
|
||||
"by_status": {},
|
||||
"by_type": {},
|
||||
}
|
||||
|
||||
def test_format_task_counts_with_data(self):
|
||||
"""Verify _format_task_counts aggregates correctly."""
|
||||
row1 = MagicMock()
|
||||
row1.status = "completed"
|
||||
row1.type = "SHELL"
|
||||
row1.count = 5
|
||||
row1.avg_duration_ms = 1500.0
|
||||
|
||||
row2 = MagicMock()
|
||||
row2.status = "failed"
|
||||
row2.type = "SHELL"
|
||||
row2.count = 2
|
||||
row2.avg_duration_ms = 3000.0
|
||||
|
||||
row3 = MagicMock()
|
||||
row3.status = "completed"
|
||||
row3.type = "DOCKER_RELOAD"
|
||||
row3.count = 3
|
||||
row3.avg_duration_ms = 5000.0
|
||||
|
||||
result = HubTelemetryService._format_task_counts([row1, row2, row3])
|
||||
|
||||
# Check by_status aggregation
|
||||
assert result["by_status"]["completed"] == 8 # 5 + 3
|
||||
assert result["by_status"]["failed"] == 2
|
||||
|
||||
# Check by_type aggregation
|
||||
assert result["by_type"]["SHELL"]["count"] == 7 # 5 + 2
|
||||
assert result["by_type"]["DOCKER_RELOAD"]["count"] == 3
|
||||
|
||||
def test_format_task_counts_handles_none_duration(self):
|
||||
"""Verify _format_task_counts handles None avg_duration_ms."""
|
||||
row = MagicMock()
|
||||
row.status = "pending"
|
||||
row.type = "ECHO"
|
||||
row.count = 1
|
||||
row.avg_duration_ms = None
|
||||
|
||||
result = HubTelemetryService._format_task_counts([row])
|
||||
|
||||
assert result["by_type"]["ECHO"]["count"] == 1
|
||||
assert result["by_type"]["ECHO"]["avg_duration_ms"] == 0
|
||||
|
||||
def test_format_task_counts_rounds_durations(self):
|
||||
"""Verify _format_task_counts rounds avg_duration_ms to 2 decimals."""
|
||||
row = MagicMock()
|
||||
row.status = "completed"
|
||||
row.type = "SHELL"
|
||||
row.count = 1
|
||||
row.avg_duration_ms = 1234.56789
|
||||
|
||||
result = HubTelemetryService._format_task_counts([row])
|
||||
|
||||
assert result["by_type"]["SHELL"]["avg_duration_ms"] == 1234.57
|
||||
|
||||
def test_format_task_counts_weighted_average(self):
|
||||
"""Verify _format_task_counts computes weighted average across same type."""
|
||||
# Two rows for same type: SHELL completed (count=2, avg=1000) and SHELL failed (count=3, avg=2000)
|
||||
row1 = MagicMock()
|
||||
row1.status = "completed"
|
||||
row1.type = "SHELL"
|
||||
row1.count = 2
|
||||
row1.avg_duration_ms = 1000.0
|
||||
|
||||
row2 = MagicMock()
|
||||
row2.status = "failed"
|
||||
row2.type = "SHELL"
|
||||
row2.count = 3
|
||||
row2.avg_duration_ms = 2000.0
|
||||
|
||||
result = HubTelemetryService._format_task_counts([row1, row2])
|
||||
|
||||
# Weighted avg: (2*1000 + 3*2000) / (2+3) = 8000/5 = 1600.0
|
||||
assert result["by_type"]["SHELL"]["count"] == 5
|
||||
assert result["by_type"]["SHELL"]["avg_duration_ms"] == 1600.0
|
||||
|
||||
def test_format_task_counts_with_enum_values(self):
|
||||
"""Verify _format_task_counts handles status/type with .value attribute."""
|
||||
from enum import Enum
|
||||
|
||||
class MockStatus(Enum):
|
||||
COMPLETED = "completed"
|
||||
|
||||
class MockType(Enum):
|
||||
SHELL = "SHELL"
|
||||
|
||||
row = MagicMock()
|
||||
row.status = MockStatus.COMPLETED
|
||||
row.type = MockType.SHELL
|
||||
row.count = 1
|
||||
row.avg_duration_ms = 500.0
|
||||
|
||||
result = HubTelemetryService._format_task_counts([row])
|
||||
|
||||
assert "completed" in result["by_status"]
|
||||
assert "SHELL" in result["by_type"]
|
||||
|
||||
|
||||
class TestHubTelemetryBackoff:
|
||||
"""Tests for backoff and jitter behavior."""
|
||||
|
||||
def test_consecutive_failures_reset_on_init(self):
|
||||
"""Verify consecutive_failures starts at 0."""
|
||||
HubTelemetryService._consecutive_failures = 5
|
||||
HubTelemetryService._consecutive_failures = 0
|
||||
|
||||
assert HubTelemetryService._consecutive_failures == 0
|
||||
|
||||
def test_backoff_calculation(self):
|
||||
"""Verify exponential backoff formula."""
|
||||
# Backoff is min(2^failures, 60)
|
||||
assert min(2**0, 60) == 1
|
||||
assert min(2**1, 60) == 2
|
||||
assert min(2**2, 60) == 4
|
||||
assert min(2**3, 60) == 8
|
||||
assert min(2**6, 60) == 60 # Capped at 60
|
||||
assert min(2**10, 60) == 60 # Still capped
|
||||
314
letsbe-orchestrator/tests/test_local_mode.py
Normal file
314
letsbe-orchestrator/tests/test_local_mode.py
Normal file
@@ -0,0 +1,314 @@
|
||||
"""
|
||||
Tests for LOCAL_MODE behavior.
|
||||
|
||||
Verifies:
|
||||
1. Multi-tenant mode (LOCAL_MODE=false) is unchanged
|
||||
2. Local mode (LOCAL_MODE=true) bootstrap works correctly
|
||||
3. Meta endpoint is stable in all scenarios
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch, AsyncMock
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.config import Settings
|
||||
from app.main import app
|
||||
from app.models import Tenant
|
||||
from app.services.local_bootstrap import LocalBootstrapService
|
||||
|
||||
|
||||
class TestMultiTenantModeUnchanged:
|
||||
"""
|
||||
Tests that multi-tenant mode (LOCAL_MODE=false, the default) remains unchanged.
|
||||
|
||||
Key assertions:
|
||||
- No automatic tenant creation on startup
|
||||
- Tenants must be created manually via API
|
||||
- Bootstrap service does nothing
|
||||
"""
|
||||
|
||||
def test_local_mode_default_is_false(self):
|
||||
"""Verify LOCAL_MODE defaults to False."""
|
||||
settings = Settings()
|
||||
assert settings.LOCAL_MODE is False
|
||||
|
||||
def test_bootstrap_service_skips_when_local_mode_false(self):
|
||||
"""Verify bootstrap does nothing when LOCAL_MODE=false."""
|
||||
# Reset class state
|
||||
LocalBootstrapService._local_tenant_id = None
|
||||
LocalBootstrapService._bootstrap_attempted = False
|
||||
LocalBootstrapService._bootstrap_error = None
|
||||
|
||||
# Patch settings in the bootstrap module where it's used
|
||||
with patch('app.services.local_bootstrap.settings') as mock_settings:
|
||||
mock_settings.LOCAL_MODE = False
|
||||
|
||||
import asyncio
|
||||
asyncio.get_event_loop().run_until_complete(LocalBootstrapService.run())
|
||||
|
||||
# Should not have attempted anything
|
||||
assert LocalBootstrapService._bootstrap_attempted is False
|
||||
assert LocalBootstrapService._local_tenant_id is None
|
||||
|
||||
def test_meta_endpoint_in_multi_tenant_mode(self):
|
||||
"""Verify meta endpoint returns correct values in multi-tenant mode."""
|
||||
# Reset bootstrap state
|
||||
LocalBootstrapService._local_tenant_id = None
|
||||
LocalBootstrapService._bootstrap_attempted = False
|
||||
LocalBootstrapService._bootstrap_error = None
|
||||
|
||||
with patch('app.config.settings') as mock_settings:
|
||||
mock_settings.LOCAL_MODE = False
|
||||
mock_settings.INSTANCE_ID = None
|
||||
mock_settings.APP_VERSION = "0.1.0"
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/v1/meta/instance")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["local_mode"] is False
|
||||
assert data["tenant_id"] is None
|
||||
# instance_id may or may not be set depending on config
|
||||
|
||||
|
||||
class TestLocalModeBootstrap:
|
||||
"""
|
||||
Tests for LOCAL_MODE=true bootstrap behavior.
|
||||
"""
|
||||
|
||||
def test_bootstrap_requires_instance_id(self):
|
||||
"""Verify bootstrap fails gracefully without INSTANCE_ID."""
|
||||
# Reset class state
|
||||
LocalBootstrapService._local_tenant_id = None
|
||||
LocalBootstrapService._bootstrap_attempted = False
|
||||
LocalBootstrapService._bootstrap_error = None
|
||||
|
||||
with patch('app.services.local_bootstrap.settings') as mock_settings:
|
||||
mock_settings.LOCAL_MODE = True
|
||||
mock_settings.INSTANCE_ID = None
|
||||
|
||||
import asyncio
|
||||
asyncio.get_event_loop().run_until_complete(LocalBootstrapService.run())
|
||||
|
||||
assert LocalBootstrapService._bootstrap_attempted is True
|
||||
assert LocalBootstrapService._local_tenant_id is None
|
||||
assert "INSTANCE_ID is required" in LocalBootstrapService._bootstrap_error
|
||||
|
||||
def test_bootstrap_status_tracking(self):
|
||||
"""Verify bootstrap status is correctly tracked."""
|
||||
# Reset class state
|
||||
LocalBootstrapService._local_tenant_id = None
|
||||
LocalBootstrapService._bootstrap_attempted = False
|
||||
LocalBootstrapService._bootstrap_error = None
|
||||
|
||||
status = LocalBootstrapService.get_bootstrap_status()
|
||||
assert status["attempted"] is False
|
||||
assert status["success"] is False
|
||||
assert status["tenant_id"] is None
|
||||
assert status["error"] is None
|
||||
|
||||
|
||||
class TestMetaEndpointStability:
|
||||
"""
|
||||
Tests that /api/v1/meta/instance is stable in all scenarios.
|
||||
|
||||
Key assertions:
|
||||
- Endpoint works before tenant exists
|
||||
- Endpoint works after tenant bootstrap fails
|
||||
- Endpoint always returns required fields
|
||||
"""
|
||||
|
||||
def test_meta_endpoint_returns_required_fields(self):
|
||||
"""Verify meta endpoint always returns required fields."""
|
||||
# Reset bootstrap state
|
||||
LocalBootstrapService._local_tenant_id = None
|
||||
LocalBootstrapService._bootstrap_attempted = False
|
||||
LocalBootstrapService._bootstrap_error = None
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/v1/meta/instance")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# These fields must always be present
|
||||
assert "local_mode" in data
|
||||
assert "version" in data
|
||||
assert "bootstrap_status" in data
|
||||
|
||||
# These fields can be null but must be present
|
||||
assert "instance_id" in data
|
||||
assert "tenant_id" in data
|
||||
|
||||
def test_meta_endpoint_after_failed_bootstrap(self):
|
||||
"""Verify meta endpoint works even after bootstrap failure."""
|
||||
# Simulate failed bootstrap
|
||||
LocalBootstrapService._local_tenant_id = None
|
||||
LocalBootstrapService._bootstrap_attempted = True
|
||||
LocalBootstrapService._bootstrap_error = "Test error"
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/v1/meta/instance")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# Should still return all fields
|
||||
assert data["bootstrap_status"]["attempted"] is True
|
||||
assert data["bootstrap_status"]["success"] is False
|
||||
assert data["bootstrap_status"]["error"] == "Test error"
|
||||
|
||||
def test_meta_endpoint_with_successful_bootstrap(self):
|
||||
"""Verify meta endpoint reflects successful bootstrap."""
|
||||
tenant_id = uuid4()
|
||||
|
||||
# Simulate successful bootstrap
|
||||
LocalBootstrapService._local_tenant_id = tenant_id
|
||||
LocalBootstrapService._bootstrap_attempted = True
|
||||
LocalBootstrapService._bootstrap_error = None
|
||||
|
||||
with patch('app.routes.meta.settings') as mock_settings:
|
||||
mock_settings.LOCAL_MODE = True
|
||||
mock_settings.INSTANCE_ID = "test-instance-123"
|
||||
mock_settings.APP_VERSION = "0.1.0"
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/v1/meta/instance")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
assert data["local_mode"] is True
|
||||
assert data["instance_id"] == "test-instance-123"
|
||||
assert data["tenant_id"] == str(tenant_id)
|
||||
assert data["bootstrap_status"]["success"] is True
|
||||
|
||||
|
||||
class TestConfigDefaults:
|
||||
"""
|
||||
Tests for configuration defaults related to LOCAL_MODE.
|
||||
"""
|
||||
|
||||
def test_all_local_mode_settings_have_safe_defaults(self):
|
||||
"""Verify all LOCAL_MODE settings have safe defaults that don't break multi-tenant mode."""
|
||||
settings = Settings()
|
||||
|
||||
# Core setting defaults to False (multi-tenant)
|
||||
assert settings.LOCAL_MODE is False
|
||||
|
||||
# Optional settings default to None/False
|
||||
assert settings.INSTANCE_ID is None
|
||||
assert settings.HUB_URL is None
|
||||
assert settings.HUB_API_KEY is None
|
||||
assert settings.HUB_TELEMETRY_ENABLED is False
|
||||
|
||||
# Local tenant domain has a default
|
||||
assert settings.LOCAL_TENANT_DOMAIN == "local.letsbe.cloud"
|
||||
|
||||
# Phase 2: LOCAL_AGENT_KEY defaults to None
|
||||
assert settings.LOCAL_AGENT_KEY is None
|
||||
|
||||
|
||||
class TestLocalAgentRegistration:
|
||||
"""
|
||||
Tests for /api/v1/agents/register-local endpoint (Phase 2).
|
||||
|
||||
HTTP Status Semantics:
|
||||
- 404: Endpoint hidden when LOCAL_MODE=false
|
||||
- 401: Invalid or missing LOCAL_AGENT_KEY
|
||||
- 503: Local tenant not bootstrapped
|
||||
- 201: New agent created
|
||||
- 200: Existing agent returned (idempotent)
|
||||
"""
|
||||
|
||||
def test_register_local_hidden_when_local_mode_false(self):
|
||||
"""Verify /register-local returns 404 when LOCAL_MODE=false (security by obscurity)."""
|
||||
# Reset bootstrap state
|
||||
LocalBootstrapService._local_tenant_id = None
|
||||
LocalBootstrapService._bootstrap_attempted = False
|
||||
LocalBootstrapService._bootstrap_error = None
|
||||
|
||||
with patch('app.dependencies.local_agent_auth.get_settings') as mock_get_settings:
|
||||
mock_settings = Settings()
|
||||
mock_settings.LOCAL_MODE = False
|
||||
mock_get_settings.return_value = mock_settings
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.post(
|
||||
"/api/v1/agents/register-local",
|
||||
json={"hostname": "test-agent", "version": "1.0.0"},
|
||||
headers={"X-Local-Agent-Key": "any-key"},
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_register_local_requires_local_agent_key(self):
|
||||
"""Verify /register-local returns 401 without X-Local-Agent-Key header."""
|
||||
# Reset bootstrap state
|
||||
LocalBootstrapService._local_tenant_id = uuid4()
|
||||
LocalBootstrapService._bootstrap_attempted = True
|
||||
LocalBootstrapService._bootstrap_error = None
|
||||
|
||||
with patch('app.dependencies.local_agent_auth.get_settings') as mock_get_settings:
|
||||
mock_settings = Settings()
|
||||
mock_settings.LOCAL_MODE = True
|
||||
mock_settings.LOCAL_AGENT_KEY = "test-key-12345"
|
||||
mock_get_settings.return_value = mock_settings
|
||||
|
||||
client = TestClient(app)
|
||||
# Missing header
|
||||
response = client.post(
|
||||
"/api/v1/agents/register-local",
|
||||
json={"hostname": "test-agent", "version": "1.0.0"},
|
||||
)
|
||||
|
||||
assert response.status_code == 422 # FastAPI validation error for missing header
|
||||
|
||||
def test_register_local_rejects_invalid_key(self):
|
||||
"""Verify /register-local returns 401 with wrong LOCAL_AGENT_KEY."""
|
||||
# Reset bootstrap state
|
||||
LocalBootstrapService._local_tenant_id = uuid4()
|
||||
LocalBootstrapService._bootstrap_attempted = True
|
||||
LocalBootstrapService._bootstrap_error = None
|
||||
|
||||
with patch('app.dependencies.local_agent_auth.get_settings') as mock_get_settings:
|
||||
mock_settings = Settings()
|
||||
mock_settings.LOCAL_MODE = True
|
||||
mock_settings.LOCAL_AGENT_KEY = "correct-key"
|
||||
mock_get_settings.return_value = mock_settings
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.post(
|
||||
"/api/v1/agents/register-local",
|
||||
json={"hostname": "test-agent", "version": "1.0.0"},
|
||||
headers={"X-Local-Agent-Key": "wrong-key"},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_register_local_503_when_not_bootstrapped(self):
|
||||
"""Verify /register-local returns 503 if local tenant not bootstrapped."""
|
||||
# Bootstrap not complete
|
||||
LocalBootstrapService._local_tenant_id = None
|
||||
LocalBootstrapService._bootstrap_attempted = False
|
||||
LocalBootstrapService._bootstrap_error = None
|
||||
|
||||
with patch('app.dependencies.local_agent_auth.get_settings') as mock_get_settings:
|
||||
mock_settings = Settings()
|
||||
mock_settings.LOCAL_MODE = True
|
||||
mock_settings.LOCAL_AGENT_KEY = "test-key"
|
||||
mock_get_settings.return_value = mock_settings
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.post(
|
||||
"/api/v1/agents/register-local",
|
||||
json={"hostname": "test-agent", "version": "1.0.0"},
|
||||
headers={"X-Local-Agent-Key": "test-key"},
|
||||
)
|
||||
|
||||
assert response.status_code == 503
|
||||
assert "Retry-After" in response.headers
|
||||
141
letsbe-orchestrator/tests/test_playbooks_chatwoot.py
Normal file
141
letsbe-orchestrator/tests/test_playbooks_chatwoot.py
Normal file
@@ -0,0 +1,141 @@
|
||||
"""Tests for the Chatwoot playbook module."""
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.task import Task, TaskStatus
|
||||
from app.models.tenant import Tenant
|
||||
from app.playbooks.chatwoot import (
|
||||
CHATWOOT_ENV_PATH,
|
||||
CHATWOOT_STACK_DIR,
|
||||
CompositeStep,
|
||||
build_chatwoot_setup_steps,
|
||||
create_chatwoot_setup_task,
|
||||
)
|
||||
|
||||
|
||||
class TestBuildChatwootSetupSteps:
|
||||
"""Tests for the build_chatwoot_setup_steps function."""
|
||||
|
||||
def test_returns_two_steps(self):
|
||||
"""Verify that build_chatwoot_setup_steps returns exactly 2 steps."""
|
||||
steps = build_chatwoot_setup_steps(domain="support.example.com")
|
||||
assert len(steps) == 2
|
||||
assert all(isinstance(step, CompositeStep) for step in steps)
|
||||
|
||||
def test_env_update_payload(self):
|
||||
"""Verify the ENV_UPDATE step has the correct payload structure."""
|
||||
domain = "support.example.com"
|
||||
steps = build_chatwoot_setup_steps(domain=domain)
|
||||
|
||||
env_step = steps[0]
|
||||
assert env_step.type == "ENV_UPDATE"
|
||||
assert env_step.payload["path"] == CHATWOOT_ENV_PATH
|
||||
assert env_step.payload["updates"]["FRONTEND_URL"] == f"https://{domain}"
|
||||
assert env_step.payload["updates"]["BACKEND_URL"] == f"https://{domain}"
|
||||
|
||||
def test_docker_reload_payload(self):
|
||||
"""Verify the DOCKER_RELOAD step has the correct payload structure."""
|
||||
steps = build_chatwoot_setup_steps(domain="support.example.com")
|
||||
|
||||
docker_step = steps[1]
|
||||
assert docker_step.type == "DOCKER_RELOAD"
|
||||
assert docker_step.payload["compose_dir"] == CHATWOOT_STACK_DIR
|
||||
assert docker_step.payload["pull"] is True
|
||||
|
||||
def test_domain_url_formatting(self):
|
||||
"""Verify that domain URLs are properly formatted with https."""
|
||||
domain = "chat.mycompany.io"
|
||||
steps = build_chatwoot_setup_steps(domain=domain)
|
||||
|
||||
env_step = steps[0]
|
||||
assert env_step.payload["updates"]["FRONTEND_URL"] == "https://chat.mycompany.io"
|
||||
assert env_step.payload["updates"]["BACKEND_URL"] == "https://chat.mycompany.io"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestCreateChatwootSetupTask:
|
||||
"""Tests for the create_chatwoot_setup_task function."""
|
||||
|
||||
async def test_persists_composite_task(self, db: AsyncSession, test_tenant: Tenant):
|
||||
"""Verify that create_chatwoot_setup_task persists a COMPOSITE task."""
|
||||
task = await create_chatwoot_setup_task(
|
||||
db=db,
|
||||
tenant_id=test_tenant.id,
|
||||
agent_id=None,
|
||||
domain="support.example.com",
|
||||
)
|
||||
|
||||
assert task.id is not None
|
||||
assert task.tenant_id == test_tenant.id
|
||||
assert task.type == "COMPOSITE"
|
||||
assert task.status == TaskStatus.PENDING.value
|
||||
|
||||
async def test_task_payload_contains_steps(self, db: AsyncSession, test_tenant: Tenant):
|
||||
"""Verify that the task payload contains the steps array."""
|
||||
task = await create_chatwoot_setup_task(
|
||||
db=db,
|
||||
tenant_id=test_tenant.id,
|
||||
agent_id=None,
|
||||
domain="support.example.com",
|
||||
)
|
||||
|
||||
assert "steps" in task.payload
|
||||
assert len(task.payload["steps"]) == 2
|
||||
|
||||
async def test_task_steps_structure(self, db: AsyncSession, test_tenant: Tenant):
|
||||
"""Verify that the steps in the payload have the correct structure."""
|
||||
task = await create_chatwoot_setup_task(
|
||||
db=db,
|
||||
tenant_id=test_tenant.id,
|
||||
agent_id=None,
|
||||
domain="support.example.com",
|
||||
)
|
||||
|
||||
steps = task.payload["steps"]
|
||||
|
||||
# First step should be ENV_UPDATE
|
||||
assert steps[0]["type"] == "ENV_UPDATE"
|
||||
assert "path" in steps[0]["payload"]
|
||||
assert "updates" in steps[0]["payload"]
|
||||
|
||||
# Second step should be DOCKER_RELOAD
|
||||
assert steps[1]["type"] == "DOCKER_RELOAD"
|
||||
assert "compose_dir" in steps[1]["payload"]
|
||||
assert steps[1]["payload"]["pull"] is True
|
||||
|
||||
async def test_task_with_agent_id(self, db: AsyncSession, test_tenant: Tenant):
|
||||
"""Verify that agent_id is properly assigned when provided."""
|
||||
agent_id = uuid.uuid4()
|
||||
|
||||
# Note: In a real scenario, the agent would need to exist in the DB
|
||||
# For this test, we're just verifying the task stores the agent_id
|
||||
task = await create_chatwoot_setup_task(
|
||||
db=db,
|
||||
tenant_id=test_tenant.id,
|
||||
agent_id=agent_id,
|
||||
domain="support.example.com",
|
||||
)
|
||||
|
||||
assert task.agent_id == agent_id
|
||||
|
||||
async def test_task_persisted_to_database(self, db: AsyncSession, test_tenant: Tenant):
|
||||
"""Verify the task is actually persisted and can be retrieved."""
|
||||
task = await create_chatwoot_setup_task(
|
||||
db=db,
|
||||
tenant_id=test_tenant.id,
|
||||
agent_id=None,
|
||||
domain="support.example.com",
|
||||
)
|
||||
|
||||
# Query the task back from the database
|
||||
result = await db.execute(select(Task).where(Task.id == task.id))
|
||||
retrieved_task = result.scalar_one_or_none()
|
||||
|
||||
assert retrieved_task is not None
|
||||
assert retrieved_task.type == "COMPOSITE"
|
||||
assert retrieved_task.tenant_id == test_tenant.id
|
||||
317
letsbe-orchestrator/tests/test_playbooks_nextcloud.py
Normal file
317
letsbe-orchestrator/tests/test_playbooks_nextcloud.py
Normal file
@@ -0,0 +1,317 @@
|
||||
"""Tests for the Nextcloud playbook module."""
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.task import Task, TaskStatus
|
||||
from app.models.tenant import Tenant
|
||||
from app.playbooks.nextcloud import (
|
||||
NEXTCLOUD_STACK_DIR,
|
||||
CompositeStep,
|
||||
build_nextcloud_initial_setup_step,
|
||||
build_nextcloud_set_domain_steps,
|
||||
create_nextcloud_initial_setup_task,
|
||||
create_nextcloud_set_domain_task,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tests for Playwright Initial Setup
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestBuildNextcloudInitialSetupStep:
|
||||
"""Tests for the build_nextcloud_initial_setup_step function."""
|
||||
|
||||
def test_returns_playwright_payload(self):
|
||||
"""Verify that build_nextcloud_initial_setup_step returns correct structure."""
|
||||
payload = build_nextcloud_initial_setup_step(
|
||||
base_url="https://cloud.example.com",
|
||||
admin_username="admin",
|
||||
admin_password="securepassword123",
|
||||
)
|
||||
|
||||
assert payload["scenario"] == "nextcloud_initial_setup"
|
||||
assert "inputs" in payload
|
||||
assert "timeout" in payload
|
||||
|
||||
def test_inputs_contain_required_fields(self):
|
||||
"""Verify that inputs contain all required fields."""
|
||||
payload = build_nextcloud_initial_setup_step(
|
||||
base_url="https://cloud.example.com",
|
||||
admin_username="admin",
|
||||
admin_password="securepassword123",
|
||||
)
|
||||
|
||||
inputs = payload["inputs"]
|
||||
assert inputs["base_url"] == "https://cloud.example.com"
|
||||
assert inputs["admin_username"] == "admin"
|
||||
assert inputs["admin_password"] == "securepassword123"
|
||||
# allowed_domains should be in options, not inputs
|
||||
assert "options" in payload
|
||||
assert "allowed_domains" in payload["options"]
|
||||
|
||||
def test_allowed_domains_extracted_from_url(self):
|
||||
"""Verify that allowed_domains is extracted from base_url."""
|
||||
payload = build_nextcloud_initial_setup_step(
|
||||
base_url="https://cloud.example.com",
|
||||
admin_username="admin",
|
||||
admin_password="password",
|
||||
)
|
||||
|
||||
assert payload["options"]["allowed_domains"] == ["cloud.example.com"]
|
||||
|
||||
def test_allowed_domains_with_port(self):
|
||||
"""Verify that allowed_domains handles URLs with ports."""
|
||||
payload = build_nextcloud_initial_setup_step(
|
||||
base_url="https://cloud.example.com:8443",
|
||||
admin_username="admin",
|
||||
admin_password="password",
|
||||
)
|
||||
|
||||
assert payload["options"]["allowed_domains"] == ["cloud.example.com:8443"]
|
||||
|
||||
def test_timeout_is_set(self):
|
||||
"""Verify that timeout is set in the payload."""
|
||||
payload = build_nextcloud_initial_setup_step(
|
||||
base_url="https://cloud.example.com",
|
||||
admin_username="admin",
|
||||
admin_password="password",
|
||||
)
|
||||
|
||||
assert payload["timeout"] == 120
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestCreateNextcloudInitialSetupTask:
|
||||
"""Tests for the create_nextcloud_initial_setup_task function."""
|
||||
|
||||
async def test_persists_playwright_task(self, db: AsyncSession, test_tenant: Tenant):
|
||||
"""Verify that create_nextcloud_initial_setup_task persists a PLAYWRIGHT task."""
|
||||
agent_id = uuid.uuid4()
|
||||
|
||||
task = await create_nextcloud_initial_setup_task(
|
||||
db=db,
|
||||
tenant_id=test_tenant.id,
|
||||
agent_id=agent_id,
|
||||
base_url="https://cloud.example.com",
|
||||
admin_username="admin",
|
||||
admin_password="securepassword123",
|
||||
)
|
||||
|
||||
assert task.id is not None
|
||||
assert task.tenant_id == test_tenant.id
|
||||
assert task.agent_id == agent_id
|
||||
assert task.type == "PLAYWRIGHT"
|
||||
assert task.status == TaskStatus.PENDING.value
|
||||
|
||||
async def test_task_payload_contains_scenario(
|
||||
self, db: AsyncSession, test_tenant: Tenant
|
||||
):
|
||||
"""Verify that the task payload contains the scenario field."""
|
||||
task = await create_nextcloud_initial_setup_task(
|
||||
db=db,
|
||||
tenant_id=test_tenant.id,
|
||||
agent_id=uuid.uuid4(),
|
||||
base_url="https://cloud.example.com",
|
||||
admin_username="admin",
|
||||
admin_password="password",
|
||||
)
|
||||
|
||||
assert task.payload["scenario"] == "nextcloud_initial_setup"
|
||||
|
||||
async def test_task_payload_contains_inputs(
|
||||
self, db: AsyncSession, test_tenant: Tenant
|
||||
):
|
||||
"""Verify that the task payload contains the inputs field."""
|
||||
task = await create_nextcloud_initial_setup_task(
|
||||
db=db,
|
||||
tenant_id=test_tenant.id,
|
||||
agent_id=uuid.uuid4(),
|
||||
base_url="https://cloud.example.com",
|
||||
admin_username="testadmin",
|
||||
admin_password="testpassword123",
|
||||
)
|
||||
|
||||
inputs = task.payload["inputs"]
|
||||
assert inputs["base_url"] == "https://cloud.example.com"
|
||||
assert inputs["admin_username"] == "testadmin"
|
||||
assert inputs["admin_password"] == "testpassword123"
|
||||
# allowed_domains should be in options, not inputs
|
||||
assert task.payload["options"]["allowed_domains"] == ["cloud.example.com"]
|
||||
|
||||
async def test_task_persisted_to_database(
|
||||
self, db: AsyncSession, test_tenant: Tenant
|
||||
):
|
||||
"""Verify the task is actually persisted and can be retrieved."""
|
||||
task = await create_nextcloud_initial_setup_task(
|
||||
db=db,
|
||||
tenant_id=test_tenant.id,
|
||||
agent_id=uuid.uuid4(),
|
||||
base_url="https://cloud.example.com",
|
||||
admin_username="admin",
|
||||
admin_password="password",
|
||||
)
|
||||
|
||||
# Query the task back from the database
|
||||
result = await db.execute(select(Task).where(Task.id == task.id))
|
||||
retrieved_task = result.scalar_one_or_none()
|
||||
|
||||
assert retrieved_task is not None
|
||||
assert retrieved_task.type == "PLAYWRIGHT"
|
||||
assert retrieved_task.tenant_id == test_tenant.id
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tests for Set Domain Playbook
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestBuildNextcloudSetDomainSteps:
|
||||
"""Tests for the build_nextcloud_set_domain_steps function."""
|
||||
|
||||
def test_returns_two_steps(self):
|
||||
"""Verify that build_nextcloud_set_domain_steps returns exactly 2 steps."""
|
||||
steps = build_nextcloud_set_domain_steps(
|
||||
public_url="https://cloud.example.com", pull=False
|
||||
)
|
||||
assert len(steps) == 2
|
||||
assert all(isinstance(step, CompositeStep) for step in steps)
|
||||
|
||||
def test_first_step_is_nextcloud_set_domain(self):
|
||||
"""Verify the first step is NEXTCLOUD_SET_DOMAIN."""
|
||||
steps = build_nextcloud_set_domain_steps(
|
||||
public_url="https://cloud.example.com", pull=False
|
||||
)
|
||||
assert steps[0].type == "NEXTCLOUD_SET_DOMAIN"
|
||||
|
||||
def test_nextcloud_set_domain_payload(self):
|
||||
"""Verify NEXTCLOUD_SET_DOMAIN step has correct payload."""
|
||||
public_url = "https://cloud.example.com"
|
||||
steps = build_nextcloud_set_domain_steps(public_url=public_url, pull=False)
|
||||
assert steps[0].payload["public_url"] == public_url
|
||||
|
||||
def test_docker_reload_payload(self):
|
||||
"""Verify the DOCKER_RELOAD step has the correct payload structure."""
|
||||
steps = build_nextcloud_set_domain_steps(
|
||||
public_url="https://cloud.example.com", pull=False
|
||||
)
|
||||
|
||||
docker_step = steps[1]
|
||||
assert docker_step.type == "DOCKER_RELOAD"
|
||||
assert docker_step.payload["compose_dir"] == NEXTCLOUD_STACK_DIR
|
||||
assert docker_step.payload["pull"] is False
|
||||
|
||||
def test_pull_flag_true(self):
|
||||
"""Verify that pull=True is passed correctly."""
|
||||
steps = build_nextcloud_set_domain_steps(
|
||||
public_url="https://cloud.example.com", pull=True
|
||||
)
|
||||
|
||||
docker_step = steps[1]
|
||||
assert docker_step.payload["pull"] is True
|
||||
|
||||
def test_pull_flag_false(self):
|
||||
"""Verify that pull=False is passed correctly."""
|
||||
steps = build_nextcloud_set_domain_steps(
|
||||
public_url="https://cloud.example.com", pull=False
|
||||
)
|
||||
|
||||
docker_step = steps[1]
|
||||
assert docker_step.payload["pull"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestCreateNextcloudSetDomainTask:
|
||||
"""Tests for the create_nextcloud_set_domain_task function."""
|
||||
|
||||
async def test_persists_composite_task(self, db: AsyncSession, test_tenant: Tenant):
|
||||
"""Verify that create_nextcloud_set_domain_task persists a COMPOSITE task."""
|
||||
agent_id = uuid.uuid4()
|
||||
|
||||
task = await create_nextcloud_set_domain_task(
|
||||
db=db,
|
||||
tenant_id=test_tenant.id,
|
||||
agent_id=agent_id,
|
||||
public_url="https://cloud.example.com",
|
||||
pull=False,
|
||||
)
|
||||
|
||||
assert task.id is not None
|
||||
assert task.tenant_id == test_tenant.id
|
||||
assert task.agent_id == agent_id
|
||||
assert task.type == "COMPOSITE"
|
||||
assert task.status == TaskStatus.PENDING.value
|
||||
|
||||
async def test_task_payload_contains_steps(
|
||||
self, db: AsyncSession, test_tenant: Tenant
|
||||
):
|
||||
"""Verify that the task payload contains the steps array."""
|
||||
task = await create_nextcloud_set_domain_task(
|
||||
db=db,
|
||||
tenant_id=test_tenant.id,
|
||||
agent_id=uuid.uuid4(),
|
||||
public_url="https://cloud.example.com",
|
||||
pull=False,
|
||||
)
|
||||
|
||||
assert "steps" in task.payload
|
||||
assert len(task.payload["steps"]) == 2
|
||||
|
||||
async def test_task_steps_structure(self, db: AsyncSession, test_tenant: Tenant):
|
||||
"""Verify that the steps in the payload have the correct structure."""
|
||||
task = await create_nextcloud_set_domain_task(
|
||||
db=db,
|
||||
tenant_id=test_tenant.id,
|
||||
agent_id=uuid.uuid4(),
|
||||
public_url="https://cloud.example.com",
|
||||
pull=True,
|
||||
)
|
||||
|
||||
steps = task.payload["steps"]
|
||||
|
||||
# First step should be NEXTCLOUD_SET_DOMAIN
|
||||
assert steps[0]["type"] == "NEXTCLOUD_SET_DOMAIN"
|
||||
assert steps[0]["payload"]["public_url"] == "https://cloud.example.com"
|
||||
|
||||
# Second step should be DOCKER_RELOAD
|
||||
assert steps[1]["type"] == "DOCKER_RELOAD"
|
||||
assert steps[1]["payload"]["compose_dir"] == NEXTCLOUD_STACK_DIR
|
||||
assert steps[1]["payload"]["pull"] is True
|
||||
|
||||
async def test_task_with_pull_false(self, db: AsyncSession, test_tenant: Tenant):
|
||||
"""Verify that pull=False is correctly stored in the task payload."""
|
||||
task = await create_nextcloud_set_domain_task(
|
||||
db=db,
|
||||
tenant_id=test_tenant.id,
|
||||
agent_id=uuid.uuid4(),
|
||||
public_url="https://cloud.example.com",
|
||||
pull=False,
|
||||
)
|
||||
|
||||
steps = task.payload["steps"]
|
||||
assert steps[1]["payload"]["pull"] is False
|
||||
|
||||
async def test_task_persisted_to_database(
|
||||
self, db: AsyncSession, test_tenant: Tenant
|
||||
):
|
||||
"""Verify the task is actually persisted and can be retrieved."""
|
||||
task = await create_nextcloud_set_domain_task(
|
||||
db=db,
|
||||
tenant_id=test_tenant.id,
|
||||
agent_id=uuid.uuid4(),
|
||||
public_url="https://cloud.example.com",
|
||||
pull=False,
|
||||
)
|
||||
|
||||
# Query the task back from the database
|
||||
result = await db.execute(select(Task).where(Task.id == task.id))
|
||||
retrieved_task = result.scalar_one_or_none()
|
||||
|
||||
assert retrieved_task is not None
|
||||
assert retrieved_task.type == "COMPOSITE"
|
||||
assert retrieved_task.tenant_id == test_tenant.id
|
||||
230
letsbe-orchestrator/tests/test_poste_playbook.py
Normal file
230
letsbe-orchestrator/tests/test_poste_playbook.py
Normal file
@@ -0,0 +1,230 @@
|
||||
"""Tests for the Poste.io playbook module."""
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.task import Task, TaskStatus
|
||||
from app.models.tenant import Tenant
|
||||
from app.playbooks.poste import (
|
||||
POSTE_STACK_DIR,
|
||||
build_poste_initial_setup_step,
|
||||
create_poste_initial_setup_task,
|
||||
)
|
||||
|
||||
|
||||
class TestBuildPosteInitialSetupStep:
|
||||
"""Tests for the build_poste_initial_setup_step function."""
|
||||
|
||||
def test_returns_playwright_payload(self):
|
||||
"""Verify that build_poste_initial_setup_step returns correct structure."""
|
||||
payload = build_poste_initial_setup_step(
|
||||
base_url="https://mail.example.com",
|
||||
admin_email="admin@example.com",
|
||||
)
|
||||
|
||||
assert payload["scenario"] == "poste_initial_setup"
|
||||
assert "inputs" in payload
|
||||
assert "options" in payload
|
||||
assert "timeout" in payload
|
||||
|
||||
def test_inputs_contain_required_fields(self):
|
||||
"""Verify that inputs contain base_url and admin_email."""
|
||||
payload = build_poste_initial_setup_step(
|
||||
base_url="https://mail.example.com",
|
||||
admin_email="admin@example.com",
|
||||
)
|
||||
|
||||
inputs = payload["inputs"]
|
||||
assert inputs["base_url"] == "https://mail.example.com"
|
||||
assert inputs["admin_email"] == "admin@example.com"
|
||||
|
||||
def test_password_included_when_provided(self):
|
||||
"""Verify that admin_password is included in inputs when provided."""
|
||||
payload = build_poste_initial_setup_step(
|
||||
base_url="https://mail.example.com",
|
||||
admin_email="admin@example.com",
|
||||
admin_password="secure-password-123",
|
||||
)
|
||||
|
||||
assert payload["inputs"]["admin_password"] == "secure-password-123"
|
||||
|
||||
def test_password_omitted_when_none(self):
|
||||
"""Verify that admin_password is omitted when not provided."""
|
||||
payload = build_poste_initial_setup_step(
|
||||
base_url="https://mail.example.com",
|
||||
admin_email="admin@example.com",
|
||||
)
|
||||
|
||||
assert "admin_password" not in payload["inputs"]
|
||||
|
||||
def test_allowed_domains_extracted_from_url(self):
|
||||
"""Verify that allowed_domains is extracted from base_url."""
|
||||
payload = build_poste_initial_setup_step(
|
||||
base_url="https://mail.example.com",
|
||||
admin_email="admin@example.com",
|
||||
)
|
||||
|
||||
assert payload["options"]["allowed_domains"] == ["mail.example.com"]
|
||||
|
||||
def test_allowed_domains_with_port(self):
|
||||
"""Verify that allowed_domains handles URLs with ports."""
|
||||
payload = build_poste_initial_setup_step(
|
||||
base_url="https://mail.example.com:8443",
|
||||
admin_email="admin@example.com",
|
||||
)
|
||||
|
||||
assert payload["options"]["allowed_domains"] == ["mail.example.com:8443"]
|
||||
|
||||
def test_timeout_is_set(self):
|
||||
"""Verify that timeout is 120 seconds."""
|
||||
payload = build_poste_initial_setup_step(
|
||||
base_url="https://mail.example.com",
|
||||
admin_email="admin@example.com",
|
||||
)
|
||||
|
||||
assert payload["timeout"] == 120
|
||||
|
||||
def test_domain_url_with_subdomain(self):
|
||||
"""Verify correct domain extraction from a subdomain URL."""
|
||||
payload = build_poste_initial_setup_step(
|
||||
base_url="https://mail.mycompany.io",
|
||||
admin_email="postmaster@mycompany.io",
|
||||
)
|
||||
|
||||
assert payload["options"]["allowed_domains"] == ["mail.mycompany.io"]
|
||||
assert payload["inputs"]["admin_email"] == "postmaster@mycompany.io"
|
||||
|
||||
def test_password_with_special_characters(self):
|
||||
"""Verify that passwords with special characters are handled."""
|
||||
special_password = "p@$$w0rd!#%^&*()"
|
||||
payload = build_poste_initial_setup_step(
|
||||
base_url="https://mail.example.com",
|
||||
admin_email="admin@example.com",
|
||||
admin_password=special_password,
|
||||
)
|
||||
|
||||
assert payload["inputs"]["admin_password"] == special_password
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestCreatePosteInitialSetupTask:
|
||||
"""Tests for the create_poste_initial_setup_task function."""
|
||||
|
||||
async def test_persists_playwright_task(self, db: AsyncSession, test_tenant: Tenant):
|
||||
"""Verify that create_poste_initial_setup_task persists a PLAYWRIGHT task."""
|
||||
agent_id = uuid.uuid4()
|
||||
|
||||
task = await create_poste_initial_setup_task(
|
||||
db=db,
|
||||
tenant_id=test_tenant.id,
|
||||
agent_id=agent_id,
|
||||
base_url="https://mail.example.com",
|
||||
admin_email="admin@example.com",
|
||||
)
|
||||
|
||||
assert task.id is not None
|
||||
assert task.tenant_id == test_tenant.id
|
||||
assert task.agent_id == agent_id
|
||||
assert task.type == "PLAYWRIGHT"
|
||||
assert task.status == TaskStatus.PENDING.value
|
||||
|
||||
async def test_task_payload_contains_scenario(
|
||||
self, db: AsyncSession, test_tenant: Tenant
|
||||
):
|
||||
"""Verify that the task payload contains the scenario field."""
|
||||
task = await create_poste_initial_setup_task(
|
||||
db=db,
|
||||
tenant_id=test_tenant.id,
|
||||
agent_id=uuid.uuid4(),
|
||||
base_url="https://mail.example.com",
|
||||
admin_email="admin@example.com",
|
||||
)
|
||||
|
||||
assert task.payload["scenario"] == "poste_initial_setup"
|
||||
|
||||
async def test_task_payload_contains_inputs(
|
||||
self, db: AsyncSession, test_tenant: Tenant
|
||||
):
|
||||
"""Verify that the task payload contains the inputs field."""
|
||||
task = await create_poste_initial_setup_task(
|
||||
db=db,
|
||||
tenant_id=test_tenant.id,
|
||||
agent_id=uuid.uuid4(),
|
||||
base_url="https://mail.example.com",
|
||||
admin_email="postmaster@example.com",
|
||||
)
|
||||
|
||||
inputs = task.payload["inputs"]
|
||||
assert inputs["base_url"] == "https://mail.example.com"
|
||||
assert inputs["admin_email"] == "postmaster@example.com"
|
||||
|
||||
async def test_task_with_password(self, db: AsyncSession, test_tenant: Tenant):
|
||||
"""Verify that admin_password is included when provided."""
|
||||
task = await create_poste_initial_setup_task(
|
||||
db=db,
|
||||
tenant_id=test_tenant.id,
|
||||
agent_id=uuid.uuid4(),
|
||||
base_url="https://mail.example.com",
|
||||
admin_email="admin@example.com",
|
||||
admin_password="test-password-123",
|
||||
)
|
||||
|
||||
assert task.payload["inputs"]["admin_password"] == "test-password-123"
|
||||
|
||||
async def test_task_without_password(self, db: AsyncSession, test_tenant: Tenant):
|
||||
"""Verify that admin_password is omitted when not provided."""
|
||||
task = await create_poste_initial_setup_task(
|
||||
db=db,
|
||||
tenant_id=test_tenant.id,
|
||||
agent_id=uuid.uuid4(),
|
||||
base_url="https://mail.example.com",
|
||||
admin_email="admin@example.com",
|
||||
)
|
||||
|
||||
assert "admin_password" not in task.payload["inputs"]
|
||||
|
||||
async def test_task_options_contain_allowed_domains(
|
||||
self, db: AsyncSession, test_tenant: Tenant
|
||||
):
|
||||
"""Verify that task options include allowed_domains."""
|
||||
task = await create_poste_initial_setup_task(
|
||||
db=db,
|
||||
tenant_id=test_tenant.id,
|
||||
agent_id=uuid.uuid4(),
|
||||
base_url="https://mail.example.com",
|
||||
admin_email="admin@example.com",
|
||||
)
|
||||
|
||||
assert task.payload["options"]["allowed_domains"] == ["mail.example.com"]
|
||||
|
||||
async def test_task_persisted_to_database(
|
||||
self, db: AsyncSession, test_tenant: Tenant
|
||||
):
|
||||
"""Verify the task is actually persisted and can be retrieved."""
|
||||
task = await create_poste_initial_setup_task(
|
||||
db=db,
|
||||
tenant_id=test_tenant.id,
|
||||
agent_id=uuid.uuid4(),
|
||||
base_url="https://mail.example.com",
|
||||
admin_email="admin@example.com",
|
||||
)
|
||||
|
||||
# Query the task back from the database
|
||||
result = await db.execute(select(Task).where(Task.id == task.id))
|
||||
retrieved_task = result.scalar_one_or_none()
|
||||
|
||||
assert retrieved_task is not None
|
||||
assert retrieved_task.type == "PLAYWRIGHT"
|
||||
assert retrieved_task.tenant_id == test_tenant.id
|
||||
|
||||
|
||||
class TestPosteConstants:
|
||||
"""Tests for module constants."""
|
||||
|
||||
def test_poste_stack_dir(self):
|
||||
"""Verify POSTE_STACK_DIR is set correctly."""
|
||||
assert POSTE_STACK_DIR == "/opt/letsbe/stacks/poste"
|
||||
Reference in New Issue
Block a user