Skip to content

Architecture

Rod Christiansen edited this page Oct 8, 2026 · 3 revisions

Architecture

How the application is put together: one FastAPI process, a stack of middleware, ten routers, an in-process response cache, and a pooled connection to PostgreSQL. There is no ORM and no background worker.

Repository layout

Path What it holds
main.py App factory. Middleware order, router mounting, OpenAPI security schemes, error handlers.
dependencies.py Everything shared: authentication, connection pool, response cache, SQL loader, Pydantic models, app-name canonicalization, WebPubSub broadcast.
routers/ Endpoint logic, one module per area: health, devices, fleet, events, statistics, admin, settings, api_keys, auth_config, inventory_state.
sql/ Parameterized SQL held in .sql files, loaded by name at startup.
alembic/versions/ Schema migrations for the tables this service owns.
oidc_auth.py Provider-agnostic OIDC bearer validation.
rate_limit.py Two-bucket fixed-window rate limiter.
etag.py Weak ETags and 304 handling on /api/v1/ GETs.
pagination.py Link / X-Total-Count / X-Limit / X-Offset headers.
metrics.py Prometheus registry, middleware, and exposition.
log_tails.py Strips and reselects log tails inside the management module, and classifies log lines by level for the fleet sweep.
install_status.py The one classifier for managed-software items on both platforms: stamps each item's state at ingest, suppresses transient network failures, and computes the dashboard counters.
scripts/export_openapi.py Writes the OpenAPI document to docs/api/openapi.json.
migrations.py Runs Alembic programmatically from the app lifespan.

Request path

Middleware is added in main.py; the last one added is the outermost. A request passes through them in this order:

  1. Request ID — honours an inbound X-Request-ID or mints a 12-hex-character one, binds it to a contextvar for logging, and echoes it on the response.
  2. Metrics — counts and times every request except the metrics scrape itself, labelled by matched route template rather than raw path so serial numbers cannot explode label cardinality.
  3. Rate limit — see Security. Returns 429 before the handler runs.
  4. GZip — compresses responses over 1024 bytes.
  5. ETag — buffers successful /api/v1/ GET bodies, hashes them into a weak ETag, and returns an empty 304 when If-None-Match matches.
  6. CORS — origins come from CORS_ORIGINS; the list is empty by default, so cross-origin browser calls are refused until you configure it. Allowed methods are GET, POST, PATCH, DELETE and OPTIONS.
  7. Route handler — with verify_authentication as a dependency on every route except /, /health, /health/live, /health/ready, and /auth/config.

ETag sits inside GZip so the tag is computed over the uncompressed body and a 304 skips compression entirely. Rate limiting sits outside so a 429 still carries a request id.

Routers

All ten routers mount under the /api/v1 prefix. The unversioned / root endpoint lives directly on the app and returns a service banner.

Router Area
health Health probes, WebPubSub negotiate, Prometheus scrape
devices Per-device reads
fleet Fleet-wide bulk and analytics reads
events Event list, ingest-failure view, event payload, and the ingest POST
statistics Consolidated dashboard: devices, install statistics, recent events
admin Archive, delete, usage-history maintenance, install reclassification, orphan cleanup, diagnostics
settings Org settings document and inventory key discovery
api_keys Mint, list, and revoke per-client API keys
auth_config Public description of the accepted credentials and OIDC details
inventory_state Asset-inventory state per device: bulk write and read

Data access

Endpoints are ordinary synchronous functions, so Starlette runs them in its threadpool. Each borrows a connection from a DBUtils PooledDB over the pure Python pg8000 driver and returns it by calling close().

The pool is built lazily on first use and rebuilt if DATABASE_URL changes. Sizing defaults to DB_POOL_MIN=2 / DB_POOL_MAX=40 — the maximum is matched to the threadpool so a full house of workers can each hold one connection. Every physical connection gets statement_timeout = 120s applied with autocommit on so it survives the pool's rollback-on-return, plus TCP keepalives so an idle socket is not silently dropped by a NAT gateway.

Long-running maintenance work uses get_maintenance_db_connection(), an unpooled connection with a caller-chosen statement timeout (default 900s). The usage-history baseline reset uses it because table-wide work does not fit inside the 120s request ceiling, and a session-level SET must not leak back into the pool.

Queries are either inline parameterized statements or files under sql/, loaded through load_sql() — which rejects path traversal and resolves against the sql/ directory — and preloaded into memory at import.

Response cache

dependencies.py holds a plain in-process dictionary cache keyed by (namespace, key) with a per-namespace TTL: 15s for events, 30s for most device and fleet reads, 60–120s for the application analytics endpoints.

The cache is bounded two ways, because most namespaces key on the whole query string and so have an open-ended key space: expired entries are swept on every write, and a hard cap (RESPONSE_CACHE_MAX_ENTRIES, default 256) evicts the oldest entry first.

Admin, settings, and inventory-state writes call invalidate_caches(), which clears everything. The ingest path deliberately does not invalidate: devices report constantly, and wiping on every check-in meant the 30-second aggregates never survived long enough to serve a second request. Ingested data becomes visible as caches expire.

/api/v1/dashboard additionally holds a per-key asyncio lock so a cache miss under load recomputes once rather than once per caller, and runs the synchronous compute in a threadpool so it cannot stall the event loop.

Startup and shutdown

The lifespan handler runs three things in order:

  1. assert_auth_enabled_for_prod() — refuses to boot if DISABLE_AUTH is set in a production-like configuration.
  2. run_migrations() — Alembic upgrade head. A migration failure is logged but is not fatal; the readiness probe stays the source of truth for whether the database is usable.
  3. On shutdown, the connection pool is drained.

Real-time events

If EVENTS_CONNECTION is set and the Azure Web PubSub SDK is installed, each stored event is broadcast to the events hub, and GET /api/v1/negotiate issues a 60-minute client access token so the dashboard can subscribe. With neither configured, both paths degrade to a response carrying a null token and an error string, and the dashboard falls back to polling.

Clone this wiki locally