88ab5b917e
Renames the Python package directory, CLI command, env var prefix, docker-compose service/container/image, Postgres role/db, and all visible branding. Marketing form is "Fabled Steward". Clean break from the previous rebrand: drops the fabledscryer→roundtable import shim in __init__.py and the FABLEDSCRYER_* env var fallback in config.py and migrations/env.py. Env vars are now STEWARD_* only. Heads-up for existing deployments: - Postgres user/db renamed fabledscryer → steward in docker-compose.yml. Existing volumes need the role/db renamed inside Postgres, or override POSTGRES_USER/POSTGRES_DB to keep the old names. - Host-agent systemd unit is now steward-agent.service. Existing agents keep running under the old name; reinstall to switch. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
106 lines
4.6 KiB
Markdown
106 lines
4.6 KiB
Markdown
# Architecture
|
|
|
|
Steward is a single Quart (async Python) process. There are no separate worker processes, no message brokers, and no build pipeline for the frontend. All monitoring, scheduling, and request handling runs on a single asyncio event loop.
|
|
|
|
---
|
|
|
|
## Startup Sequence
|
|
|
|
`create_app()` in `steward/app.py` runs these steps synchronously before the event loop starts:
|
|
|
|
| Step | What happens |
|
|
|---|---|
|
|
| 1 | Bootstrap config loaded (`database_url`, `secret_key`, `plugin_dir`) |
|
|
| 2 | SQLAlchemy async engine and session factory attached to `app` |
|
|
| 3 | Core Alembic migrations applied (creates `app_settings` table among others) |
|
|
| 4 | All settings loaded from `app_settings` DB table into `app.config` |
|
|
| 5 | Each enabled plugin's Alembic migrations applied |
|
|
| 6 | Alert pipeline initialised (`init_alerts(app)` stores app ref for deferred notifications) |
|
|
| 7 | Core blueprints registered (auth, dashboard, hosts, ping, dns, alerts, ansible, settings) |
|
|
| 8 | Core scheduled tasks registered into `app._task_registry` |
|
|
| 9 | Plugins loaded via `load_plugins(app)` — blueprints and tasks appended |
|
|
| 10 | `/health` endpoint registered |
|
|
| 11 | `before_serving` hook starts the scheduler as an `asyncio.create_task()` |
|
|
|
|
The two-phase migration approach (step 3 core → step 4 load settings → step 5 plugins) exists because plugin enabling is stored in the settings DB, which requires the core schema to exist first.
|
|
|
|
---
|
|
|
|
## Request Routing
|
|
|
|
All routes are Quart Blueprints registered in `app.py`:
|
|
|
|
| Prefix | Blueprint | Module |
|
|
|---|---|---|
|
|
| `/auth/` | `auth_bp` | `steward/auth/routes.py` |
|
|
| `/` | `dashboard_bp` | `steward/dashboard/routes.py` |
|
|
| `/hosts/` | `hosts_bp` | `steward/hosts/routes.py` |
|
|
| `/ping/` | `ping_bp` | `steward/ping/routes.py` |
|
|
| `/dns/` | `dns_bp` | `steward/dns/routes.py` |
|
|
| `/alerts/` | `alerts_bp` | `steward/alerts/routes.py` |
|
|
| `/ansible/` | `ansible_bp` | `steward/ansible/routes.py` |
|
|
| `/settings/` | `settings_bp` | `steward/settings/routes.py` |
|
|
| `/plugins/<name>/` | plugin blueprint | `plugins/<name>/routes.py` |
|
|
|
|
Plugin blueprints are mounted automatically by `load_plugins()` using the plugin directory name as the URL prefix.
|
|
|
|
---
|
|
|
|
## Scheduler
|
|
|
|
`steward/core/scheduler.py` exports two things:
|
|
|
|
- `ScheduledTask` dataclass — holds a `name`, `coro_factory` (zero-argument callable returning a coroutine), `interval_seconds`, and optional `run_on_startup` flag
|
|
- `start_scheduler(tasks)` — async function that loops every second, calling `asyncio.create_task()` for each task whose interval has elapsed
|
|
|
|
Tasks are never awaited serially — each runs as a fully independent asyncio task. Exceptions inside tasks are caught and logged; they do not crash other tasks or the app.
|
|
|
|
Core tasks registered in `app._task_registry`:
|
|
- `ping_monitor` — runs every `monitors.poll_interval_seconds` (default 60s), `run_on_startup=True`
|
|
- `dns_monitor` — same interval, `run_on_startup=True`
|
|
- `data_cleanup` — runs hourly, `run_on_startup=False`
|
|
- `ansible_git_pull_<name>` — one per configured Git source, interval from source config
|
|
|
|
---
|
|
|
|
## Database Session Pattern
|
|
|
|
`app.db_sessionmaker` is an SQLAlchemy `async_sessionmaker`. All DB access follows this pattern:
|
|
|
|
```python
|
|
async with current_app.db_sessionmaker() as session:
|
|
async with session.begin():
|
|
# queries and writes here
|
|
# transaction commits on exit, rolls back on exception
|
|
```
|
|
|
|
Sessions are never shared across request boundaries or between tasks.
|
|
|
|
---
|
|
|
|
## Config Two-Layer Design
|
|
|
|
There are exactly two config layers:
|
|
|
|
1. **Bootstrap** (`config.yaml` + env vars) — `database_url`, `secret_key`, `plugin_dir` only. Read once at startup before the event loop.
|
|
2. **App settings** (`app_settings` DB table) — everything else: SMTP, webhooks, Ansible sources, monitor intervals, ping thresholds, plugin config. Read at startup via `load_settings_sync()` and written via the Settings UI at runtime.
|
|
|
|
This means the only file you must touch to get the app running is the database URL. Everything else can be configured through the web UI.
|
|
|
|
---
|
|
|
|
## Frontend Approach
|
|
|
|
No JavaScript framework, no build step. The frontend is:
|
|
|
|
- **Jinja2 templates** rendered server-side (`steward/templates/`)
|
|
- **HTMX** for live-updating fragments (dashboard widgets, ping pills, DNS status)
|
|
- A single CSS design system in `steward/templates/base.html` using CSS custom properties
|
|
|
|
Live-updating widgets use HTMX polling:
|
|
```html
|
|
<div hx-get="/ping/rows" hx-trigger="load, every 30s" hx-swap="innerHTML">
|
|
```
|
|
|
|
Fragment endpoints (`/ping/rows`, `/dns/rows`, `/plugins/traefik/widget`) return partial HTML that HTMX swaps in without a full page reload.
|