Plane Configuration
Plane environment variables: database, auth, hub, reactor, metrics, setup chart.
The plane is configured entirely through environment variables typed by app/config/settings.py. Each field reads HANGAR_<NAME> and nothing else; a bare DATABASE_URL or APP_ENV is ignored.
Both processes (API and hub) read the same environment and the same database. The reactor runs under its own process.
Environment and mode
| Variable | Default | Description |
|---|---|---|
HANGAR_APP_ENV | production | local is opt-in; compose sets it |
HANGAR_APP_DEBUG | false | Debug mode |
HANGAR_APP_LOG_LEVEL | INFO | Log level |
HANGAR_APP_LOG_FORMAT | console (json in production) | Log format |
APP_ENV defaults to production, so a plane started with no environment fails closed. Under APP_ENV=local, which has to be asked for:
ADMIN_AUTHdefaults todisabled: a request with no credential resolves to the admin principal- The API binds
127.0.0.1 - The process logs a warning at startup
Outside local, the plane refuses to boot with ADMIN_AUTH=disabled, without SESSION_SIGNING_SECRET, or without ADMIN_PASSWORD_HASH.
Database
| Variable | Default | Description |
|---|---|---|
HANGAR_DATABASE_URL | postgresql+psycopg://hangar:hangar@localhost:5442/hangar | Database connection string |
HANGAR_DB_ECHO | false | Echo SQL statements |
HANGAR_DB_POOL_SIZE | 10 | Connection pool size |
HANGAR_DB_POOL_MAX_OVERFLOW | 10 | Max overflow connections |
HANGAR_DB_POOL_PRE_PING | true | Test connections before using |
Requires Postgres 15 or later.
API process
| Variable | Default | Description |
|---|---|---|
HANGAR_API_HOST | unset (127.0.0.1 in local, 0.0.0.0 otherwise) | API bind address |
HANGAR_API_PORT | 8000 | API port |
The API process serves its own Prometheus metrics at GET /metrics, unauthenticated.
Hub process
| Variable | Default | Description |
|---|---|---|
HANGAR_HUB_HOST | 0.0.0.0 | Hub bind address |
HANGAR_HUB_PORT | 8001 | Hub port |
HANGAR_HUB_PATH | /v1/hub/sync | WebSocket path |
HANGAR_HUB_INSTANCE_ID | unset | Names a pod in the claim table; falls back to $HOSTNAME, then a random id |
HANGAR_HUB_SESSION_TTL_SECONDS | 600 | Session lifetime without an attached connection |
HANGAR_HUB_REAPER_INTERVAL_SECONDS | 60 | How often to reap abandoned sessions |
HANGAR_HUB_OWNERSHIP_CHECK_SECONDS | 10 | How often a session checks its own claim row |
Hub scaling
The hub scales by shedding session state instead of sharing it. SessionState (cursors and replay buffer) stays in one pod’s memory, never in Postgres. Reconnecting to the pod you left resumes exactly; reconnecting to a different one costs a full_resync.
What is shared in Postgres is one fact: exactly one hub session may be the writer for a given cluster_id at a time. hello claims that cluster with one atomic INSERT ... ON CONFLICT DO UPDATE. The session it displaces discovers the loss by polling its own claim row on HUB_OWNERSHIP_CHECK_SECONDS and closes the connection the moment the row names someone else.
A session whose cluster never reconnects is reaped after HUB_SESSION_TTL_SECONDS of no attached connection, so an abandoned replay buffer does not sit in memory for the life of the process.
Reactor process
| Variable | Default | Description |
|---|---|---|
HANGAR_REACTOR_ENABLED | true | Run the reactor |
HANGAR_REACTOR_INTERVAL_SECONDS | 60 | Reactor loop interval |
HANGAR_REACTOR_LEASE_SECONDS | 180 | Reactor lease duration |
HANGAR_REACTOR_EVENT_BATCH | 500 | Event batch size |
HANGAR_REACTOR_NOTIFY_TIMEOUT | 5 | Notification timeout |
The reactor derives alerts on its own loop under a lease. It is a separate process from the API and hub.
Authentication
| Variable | Default | Description |
|---|---|---|
HANGAR_ADMIN_AUTH | unset | disabled when APP_ENV=local; always enabled otherwise |
HANGAR_ADMIN_PASSWORD_HASH | unset | Required outside local |
HANGAR_SESSION_SIGNING_SECRET | unset | Required outside local; random per-process key in local |
HANGAR_SESSION_TTL_SECONDS | 900 | Access token lifetime |
HANGAR_REFRESH_TTL_SECONDS | 604800 | Absolute session lifetime |
HANGAR_TRUSTED_PROXIES | unset | Proxies whose X-Forwarded-For is believed when charging failed sign-ins |
HANGAR_ALLOWED_ORIGINS | http://localhost:5273,http://127.0.0.1:5273 | CORS allowed origins |
Wingsmith is single-operator software: there is one admin identity, and every person signed in as admin can do everything. There are no per-user accounts, roles, or scopes.
Four principal kinds
| Kind | Credential | Reaches |
|---|---|---|
admin | Session from POST /v1/auth/login with admin password | Every route except the gateway’s |
tenant | Session from POST /v1/auth/login with tenant’s console token (hgrt1.) | GET /v1/serving/... only |
gateway | GATEWAY_SERVICE_TOKEN, static bearer | GET /v1/gateway/tenants, GET /v1/endpoints/resolved, POST /v1/clusters/tunnel-auth |
operator | Cluster’s own enrolment token (hgro1.), minted at registration | Hub only, bound to that one cluster; never the HTTP API |
There is no shared operator token. Each cluster’s enrolment token is stored as a hash and resolves to an operator principal bound to that cluster.
Sessions
Login returns a short-lived access token (hgrs1., SESSION_TTL_SECONDS) and a refresh token (hgrr1.). Refreshing rotates both atomically. A session lasts at most REFRESH_TTL_SECONDS from sign-in, however often it is refreshed.
The guard checks the session on every request, so POST /v1/auth/logout, a revoked tenant token, or a disabled tenant ends access on the next request. Changing ADMIN_PASSWORD_HASH ends every admin session.
Failed sign-ins
Failed sign-ins are budgeted per caller address and account. Behind a proxy, set TRUSTED_PROXIES or every caller shares the proxy’s budget.
Cross-site writes
A POST, PUT, PATCH, or DELETE whose Origin is neither the plane’s own nor in ALLOWED_ORIGINS is 403, and a request body that is not JSON is 415. While admin auth is off, any request whose Host is not loopback, PUBLIC_HOST, or a host in ALLOWED_ORIGINS is 403 too.
Every response carries frame-ancestors 'none' and X-Frame-Options: DENY, so no other site can frame the console.
Generate password hash
uv run python -m app.auth hash # prompts twice
uv run python -m app.auth hash --stdin # reads one line from a pipe
Gateway integration
| Variable | Default | Description |
|---|---|---|
HANGAR_GATEWAY_SERVICE_TOKEN | unset | Falls back to hangar-local-gateway-token only when APP_ENV=local |
The gateway’s service credential for the plane, accepted on GET /v1/endpoints/resolved, GET /v1/gateway/tenants, and POST /v1/clusters/tunnel-auth only.
Metrics and observability
| Variable | Default | Description |
|---|---|---|
HANGAR_METRICS_SOURCES | unset | Takes default=<url>|token=...; per-cluster label is Cluster.metrics_label |
HANGAR_METRICS_TIMEOUT | 5 | Metrics query timeout |
HANGAR_METRICS_WINDOW | 5m | Metrics query window |
HANGAR_METRICS_STEP | 60s | Metrics query step |
HANGAR_METRICS_REMOTE_WRITE_URL | unset | Where the fleet pushes; rendered into setup values. Empty renders monitoring.enabled: false |
Remote write authentication
| Variable | Default | Description |
|---|---|---|
HANGAR_REMOTE_WRITE_AUTH_ENABLED | false | Keep an htpasswd Secret for a basic-auth write receiver |
HANGAR_REMOTE_WRITE_AUTH_NAMESPACE | unset | Namespace for the auth Secret |
HANGAR_REMOTE_WRITE_AUTH_SECRET | prom-remote-write-auth | Secret name |
HANGAR_REMOTE_WRITE_AUTH_KEY | users | Secret key |
Setup chart and images
| Variable | Default | Description |
|---|---|---|
HANGAR_SETUP_CHART_REF | oci://ghcr.io/hangar-dev/charts/cluster-setup | Chart reference |
HANGAR_SETUP_CHART_VERSION | unset | Chart version |
HANGAR_SETUP_CHART_PRIVATE | false | Chart requires authentication |
HANGAR_OPERATOR_IMAGE | unset | Operator image (overrides chart default) |
HANGAR_AGENT_IMAGE | unset | Agent image (overrides chart default) |
The plane renders setup values for each cluster on the register screen. These variables control the chart reference and image overrides.
Benchmarks
| Variable | Default | Description |
|---|---|---|
HANGAR_BENCHMARK_CONFORMANCE_IMAGE | ghcr.io/hangar-dev/conformance:0.1.0 | Conformance harness image |
HANGAR_BENCHMARK_EVALSCOPE_IMAGE | ghcr.io/hangar-dev/evalscope:1.11.1 | Evalscope harness image |
These images are passed to the operator for benchmark runs.
Integrations
| Variable | Default | Description |
|---|---|---|
HANGAR_HF_TOKEN | unset | Hugging Face token for artifact resolution |
Running the plane
Both processes read the same environment:
uv sync
export HANGAR_APP_ENV=local
uv run python -m app.api # HTTP API on :8000
uv run python -m app.api --reload # with autoreload
uv run python -m app.hub # operator hub on :8001
uv run python -m app.reactor # reactor
--check on any process builds it, runs its lifecycle, and exits, which proves wiring without binding a long-lived listener.