README
¶
Control-plane admin console
The control plane (multi-tenant / kubernetes build tag) serves a React admin
console + REST API on :8080. It is the operate-everything surface: metrics,
live queries/sessions/connections, the worker fleet, the full config store, user
impersonation, and an audit log — sliceable by org and user.
Exposure is VPC-private: an internal-scheme ALB + Cognito (Google Workspace SSO),
reachable only over the Tailscale subnet router. See docs/design/admin-ui.md.
Architecture
- Frontend (
ui/): React + Vite + TypeScript + Tailwind + shadcn/ui, TanStack Query/Table, Recharts. Built toui/dist/and embedded via//go:embed all:ui/dist(embed_ui.go), served by Gin with SPA fallback. The built bundle is a gitignored build artifact — onlyui/dist/.gitkeepis tracked, so the embed has a target andgo buildcompiles without node (the server then serves a "UI not built" notice).just ui-buildproduces it locally; bothDockerfileandDockerfile.controlplanerebuild it in a node stage beforego build, so a shipped image always has the fresh UI. - Backend: Gin on
:8080, all routes under/api/v1(the SPA owns/).
Auth + RBAC
AuthMiddleware (authz.go) resolves every /api/v1 request to an Identity
with a Role:
- A valid
TokenSettoken (X-Duckgres-Internal-Secretheader or theduckgres_admin_tokencookie) → admin. This is the service-to-service / break-glass path (RegisterLoginmints the cookie viaPOST /login). - Otherwise the ALB-injected
X-Amzn-Oidc-DataJWT (Cognito/Google) yields the caller's email (only@posthog.com,email_verified != false; otherwise treated as unauthenticated). The role is then resolved per-request from theduckgres_operatorstable in the config schema (goose migration000006_create_operators.sql): anadminrow → admin, anything else (including no row) → viewer. Operators are managed by admins under Admin → Operators in the config-store explorer (and the/api/v1/operatorsAPI). The first SSO login auto-provisions a create-only viewer operator row; to mint the first admin, log in over the break-glass internal token and patch that row toadminunder Admin → Operators.
RoleGate enforces the split: mutating verbs (POST/PUT/PATCH/DELETE) and the
audit-log GET require admin; other GETs allow viewer. AuditMiddleware records
every mutation. The ALB OIDC JWT's ES256 signature is verified against AWS's
regional public-key endpoint. Its expiry, signer region, configured issuer, and
configured client are validated from the protected header before payload user
claims are trusted. DUCKGRES_ADMIN_SSO_ISSUER enables SSO; when unset, SSO is
disabled and only bearer tokens authenticate. DUCKGRES_ADMIN_SSO_CLIENT_ID
pins the client when set. DUCKGRES_ADMIN_SSO_REGION selects the key endpoint
and signer region, defaulting to DUCKGRES_AWS_REGION.
?token= URL auth is deliberately rejected (#721).
API surface
Existing typed CRUD (api.go): orgs, users, managed warehouses (+ tenant
pinning). Generic read-only models explorer (models_api.go): GET /api/v1/models, GET /api/v1/models/:model — secret columns (json:"-")
dropped by the typed scan; never swap in a raw map scan.
Added for the console:
| Route | Role | Purpose |
|---|---|---|
GET /api/v1/me |
any | caller identity + role (SPA tailors its UI) |
GET /api/v1/queries |
viewer | running queries w/ progress, ?org=&user= slicing |
GET /api/v1/queries/by-worker/:wid |
viewer | one query's detail: redacted SQL text + conn metadata + progress, addressed by cluster-unique worker id (pid is per-org, not unique). Scatter-gathers like /queries — checks locally, else fans out to peer CPs (?scope=local guard); 404 only if no replica owns the worker |
GET /api/v1/errors |
viewer | recent redacted query errors (live-triage ring, newest-first), ?org=&user=&sqlstate=&category=&limit= slicing. Fans out + merges across CPs (each error belongs to one CP — disjoint, no dedup). query/message redacted server-side |
GET /api/v1/sessions, /workers |
viewer | live sessions / session-holding workers |
GET /api/v1/workers/fleet |
viewer | cluster worker counts by lifecycle state |
GET /api/v1/workers/hot-idle |
viewer | per-org hot-idle pool reporting (count, vCPU, memory, oldest park) + each org's configured max_hot_idle_* caps; backs the Workers page "Hot idle by org" card |
GET /api/v1/cluster/instances |
viewer | live CP replicas (self-flagged) |
POST /api/v1/sessions/:pid/cancel |
admin | tear down a session by pid — LOCAL only (pid is per-CP); prefer the worker-id form |
POST /api/v1/sessions/by-worker/:wid/cancel |
admin | tear down the session on a cluster-unique worker id; fans out to whichever CP owns it (pid can't be fanned out — it collides across CPs). Returns {killed, cp_responders, cp_total} |
POST /api/v1/orgs/:id/users/:username/kill |
admin | per-user kill switch (one-shot): tear down ALL of a user's sessions + in-flight queries cluster-wide. Returns {killed, cp_responders, cp_total}. Does NOT block reconnects |
POST /api/v1/orgs/:id/users/:username/disable |
admin | persist disabled=true (refused at pgwire connect), reload the snapshot cluster-wide so the block is immediate, AND kill the user's live sessions. Returns {disabled, killed, …} |
POST /api/v1/orgs/:id/users/:username/enable |
admin | persist disabled=false + reload cluster-wide so the user can reconnect at once |
GET /api/v1/metrics/panels, /metrics/query_range |
viewer | Prometheus proxy (allow-listed panels only) |
GET /api/v1/usage/monthly |
admin | cumulative per-team usage per UTC month (CPU-seconds, memory GiB-seconds, S3 GiB-seconds), backing the Usage page. The response also identifies the control plane's effective aws_region and its derived customer_pricing_region. Self-gates with RequireAdmin (not just RoleGate's method check) because per-team cost data across all orgs is as sensitive as the raw billing families. Reads the SAME billing buffer as GET /billing/usage, so retention is the buffer's: acked buckets are deleted, >30d buckets GC'd — watermark_low in the response marks where billed data was removed. ?months=N (default 6, max 36) sets the window |
GET /api/v1/orgs/:id/usage/daily |
admin | one org's daily per-team usage series (same families), backing the org detail page's Usage charts. Same RequireAdmin gate and buffer-retention semantics; the org scope is the :id path segment flowing into the queries' WHERE clause. ?days=N (default 14, max 31 — the buffer's 30d GC bounds useful range) |
GET /api/v1/orgs/:id/monitoring/snapshot |
internal secret | Customer-safe org warehouse state, resource limits, workers, sessions, queue depth, and CP coverage. Omits user, pod, image, SQL, client, trace, and control-plane identifiers |
GET /api/v1/orgs/:id/monitoring/series |
internal secret | Customer-safe, org-forced Prometheus range query. Requires an allow-listed metric; window is one of 1h, 6h, 24h (default), 7d, 30d |
GET /api/v1/orgs/:id/users/:username/secrets, DELETE .../:name |
viewer/admin | list/delete stored persistent secrets (ciphertext never returned) |
POST /api/v1/orgs/:id/impersonate/query |
admin | run SQL as an org user on their worker |
GET /api/v1/trino/status |
viewer | Trino cell overview: cell id, coordinator /v1/info (version, environment, uptime, starting), query counts by state, blocked-query count, node/failed-node counts, and Trino-enabled orgs by provisioning state. available:false + error when the coordinator can't be read — the provisioning half still comes from the config store, so "the cell is down" and "these tenants never provisioned" stay distinguishable |
GET /api/v1/trino/queries |
viewer | live queries, ?org=&state=&active=1 slicing, longest-running first. SQL is redacted server-side; each row is stamped with the duckgres org resolved from the Trino principal |
GET /api/v1/trino/queries/:id |
viewer | one query; 404 when the coordinator has aged it out (410 Gone upstream) |
POST /api/v1/trino/queries/:id/kill |
admin | fail a query with a reason (PUT /v1/query/{id}/killed). The reason reaches the TENANT as the query's error message. Audited as trino.query.kill with the owning org |
GET /api/v1/trino/nodes |
viewer | the cell's fleet: /v1/node + /v1/node/failed where bound, else /v1/announce membership; the response names which (source) |
GET /api/v1/trino/orgs |
viewer | per-org Trino provisioning state + that org's live query counts |
GET /api/v1/orgs/:id/trino |
viewer | one org's Trino state; a ready, reachable tenant includes non-secret status.connection (host, port, username), status.trino_catalog_name is the provisioned catalog identifier, and enabled:false represents an org with no Trino row. The tenant password is returned only by provision/reset APIs |
GET /api/v1/audit |
admin | admin action log |
GET /api/v1/operators |
admin | list console operators (email → role) |
POST /api/v1/operators |
admin | add/update an operator ({email, role}; last-admin demotion → 409) |
DELETE /api/v1/operators/:email |
admin | remove an operator (removing the last admin → 409) |
The Usage page currently presents storage only. It converts retained S3 GiB·h
to GiB-month using the actual number of hours in the selected UTC calendar
month. Customer pricing is selected automatically from the control plane's
effective DUCKGRES_AWS_REGION: us-* uses the US schedule and eu-* uses
the EU schedule. Missing or unsupported regions make this endpoint return 503
rather than showing a plausible but incorrect price. Customer tiers are
progressive and calculated separately for each organization. Every column is
sortable; unavailable gross margins stay last in both directions. “Price at
80% margin” is a what-if value equal to allocated AWS cost divided by 20%
(5 × cost), calculated before currency rounding.
The AWS cost model remains separate from customer pricing: it is a capacity-only estimate using public S3 Standard us-east-1 rates across aggregate usage in the view, then allocated to organizations in proportion to their usage. It excludes requests, transfers, taxes, credits, negotiated discounts, and other AWS account usage, so it is not an invoice or an exact CUR charge.
Cross-CP live-state aggregation (live_aggregate.go + controlplane/live_aggregator.go)
Live session/query state is in-memory per CP — each replica only knows the
sessions it owns. Behind the load-balancer that made the dashboard's numbers
flicker as polls landed on different pods. The session/query endpoints
(/queries, /errors, /sessions, /workers, /status) fan out: the serving CP
discovers its peer CP pods (K8s pod list, name-prefix match), GETs each peer's
?scope=local view (the recursion guard — a peer returns only its own slice)
with the internal secret, and concatenates (a session is owned by exactly one
CP, so the union is disjoint — no dedup). Peers are fetched concurrently with a
short per-peer timeout; a slow/down peer is omitted, and /queries reports
cp_responders/cp_total for coverage. PeerFetcher is nil in single-CP /
test setups (local-only). /workers/fleet is already cluster-wide (config
store) and is not fanned out.
The same fan-out also powers the per-user kill switch as a mutation:
PeerFetcher.PostPeers POSTs …/kill (or …/disable) ?scope=local to every
peer so the user's sessions are torn down on whichever replica owns them, and the
per-CP killed counts are summed. The disable/enable handlers additionally
call ConfigStore.ReloadSnapshot() on every replica so the connect-time block
(the duckgres_org_users.disabled column, goose migration
000011_add_org_user_disabled.sql) takes effect cluster-wide immediately rather
than one config-poll later. The disabled flag is enforced at auth in
control.go (pgwire → distinct 28000 "account is disabled" error, only after
the password checks out so it never leaks account existence).
Impersonation (impersonate.go + controlplane/admin_providers.go)
POST /api/v1/orgs/:id/impersonate/query {username, sql, allow_write} opens a
real session as the target org+user (workers trust the CP — no password),
runs the SQL via the returned FlightExecutor, streams rows back, and always
destroys the session. It is admin-only, every statement is audited with the admin
actor + redacted SQL, and a write statement requires allow_write=true (the SQL
classifier is conservative — WITH/CTEs and anything non-obviously-read-only count
as writes). Caveat: the session consumes a worker exclusively
(one-session-per-worker), counts against the org's connection limits, and appears
in the org's session accounting. Rows capped at maxImpersonationRows.
Metrics proxy (metrics_proxy.go)
Not an open PromQL relay: the client passes a panel KEY (+ optional org/window);
the PromQL is built server-side from the allow-list (rangePanels). Forwards to
DUCKGRES_PROMETHEUS_URL (the in-cluster VictoriaMetrics vmselect, Prometheus-
compatible). Org-labelled panels (duckgres_query_total{org,status,reason} etc.) keep
slicing enforced. Unset URL → 503 so the UI shows "metrics not configured".
Product monitoring API (monitoring.go)
The PostHog backend reads GET /api/v1/orgs/:id/monitoring/snapshot and
/series with the internal secret. These routes are not operator-dashboard
shortcuts: they are a deliberately smaller customer-safe contract. The org is
fixed by the path, every config-store query uses that org, and every Prometheus
query includes an exact org label selector. SSO identities, including admin
operators, receive 403.
The snapshot combines durable worker records and connection leases/queue rows
with the cross-CP live-session fan-out. It reports cp_responders, cp_total,
and partial so a missing control plane never looks like zero activity. Worker
limits use the current org defaults with deployment fallbacks. Empty worker
profile and zero-TTL sentinels use deployment defaults because org-shaped
workers persist explicit profiles. Query progress is null when DuckDB does not
provide a percentage. It intentionally omits usernames, PIDs, pod/image names,
control-plane ownership, SQL, client metadata, trace identifiers, and secrets.
The series endpoint accepts only query_rate, error_ratio, duration_p50,
duration_p95, sessions_active, acquire_p95,
acquire_by_source, storage_bytes, and worker_crash_rate. It normalizes the
Prometheus response and retains only the status, reason, or source labels needed by
the corresponding chart. Unknown metrics and windows return 400; unknown orgs
return 404 with code: "managed_warehouse_not_found" before Prometheus is called.
Trino cell views (trino.go + trino_client.go)
The console observes the shared Trino cell through the coordinator's REST
API, as a dedicated observer principal (opa.ObserverPrincipal =
__duckgres_observer) that the provisioner mints alongside the admin pair
and projects into password.db / group.db.
The existing deployment appears as legacy in the Trino API's cell.id and
its owned orgs' status.cell / orgs[].cell. This is an API alias, not a storage
migration: DUCKGRES_TRINO_CELL_ID, persisted org assignments, and Trino's
catalog-store key retain their existing values. The general org endpoint still
returns the persisted trino.trino_cell_id. Registered cells use separate
registered:<cell-id> ownership values. Unknown stored owners fail closed.
GET /api/v1/trino/cells lists configured logical cells. Operational Trino
routes accept ?cell=<logical-id> and default to legacy only when configured.
Registry-only deployments require an explicit cell parameter; the Trino pages
provide a cell selector. Unassigned org details expose no coordinator or client
coordinates and allow initial selection before enablement. Org detail resolves
its authoritative stored assignment regardless of a supplied cell parameter.
Each coordinator has separate caches; tenant counts include only its cell.
Admins can select an initial cell at the top of Org configuration or send
PUT /api/v1/orgs/:id/trino/cell with {"cell":"cell-001"}. This stores a
disabled assignment before the normal enable endpoint is called. The warehouse
must already exist. Selection does not enable Trino or move an existing tenant.
An existing assignment is immutable, including after disable/re-enable; repeat
selection of the same assignment is idempotent. An enabled unassigned row also
rejects selection because its first provisioning tick may already be running.
On a 409, inspect the current assignment rather than editing its database row.
Moving an existing tenant requires a separate maintenance/drain workflow.
The Trino section has separate Select cell, Enable Trino, and Disable Trino actions. These do not submit the other org configuration fields. The UI requires a saved assignment before enabling and preserves the stored resource-group tier when re-enabling. Selection alone never enables Trino; disabling retains the assignment and is not a maintenance barrier. Viewers can read the configuration but cannot change it. The lower Trino card continues to show provisioning status and live query counts.
Enablement uses the existing same-origin POST/DELETE /api/v1/orgs/:id/trino
routes, protected by the shared admin authentication, role gate, and audit
middleware. The browser receives no provisioning token. Vitest covers these
UI actions and request shapes; the isolated Trino suite exercises the unchanged
enable/disable endpoints against real infrastructure.
For newly registered cells, configure the shared customer endpoint separately from the observer coordinator URL. This API does not configure Gateway routing.
For local verification, run just test-controlplane-k8s and just ui-test.
The isolated Trino end-to-end suite checks both the API alias and unchanged
persisted ownership. If connection details disappear after an upgrade, check
the org's persisted ID against the configured DUCKGRES_TRINO_CELL_ID; do not
rename stored IDs to match the API alias. Roll back the application version if
an API consumer requires the previous displayed ID.
- Why a second principal. Trino routes operator reads through the same
access-control SPI as everything else —
GET /v1/queryfilters throughFilterViewQueryOwnedBy,/v1/query/{id}is gated onViewQueryOwnedBy, kill onKillQueryOwnedBy, and/v1/node+/v1/resourceGroupStateareMANAGEMENT_READ(checkCanReadSystemInformation). A console credential with no grant sees an empty cluster. Note thatReadSystemInformationis a single operation gating everyMANAGEMENT_READresource — also/v1/thread,/v1/announce(GET),/v1/maxActiveSplitsand/v1/integrations/gateway. All are GETs of cluster-operational state and none reads a catalog, a table or SQL text; the node-registering POST on/v1/announceisINTERNAL_ONLY. The enumeration lives in policy.rego so the real blast radius is visible where the grant is written. The grant is deliberately NOT added to__admin_provisioner: that credential can CREATE/DROP catalogs and by policy sees only its own queries, while the observer sees every tenant's query metadata and holds no catalog at all (no entry indata.group_catalogs; the observer group is excluded fromtenant_owns_catalogand from the same-org query match). One leaked credential yields one half of that authority, never both. - SQL text is redacted at decode (
usersecrets.RedactForLog, intoTrinoQuery), not per-caller, so no handler can leak it by forgetting. Query text is tenant data: table names, filter literals, customer identifiers, and a failedCREATE SECRETcarries a credential. - Airlift units are STRINGS on the wire.
io.airlift.units.Durationserializes as"%.2f<unit>"("12.34ms","1.50s") andDataSizeas an exact byte count with aBsuffix ("1234B"). Decoding either into a numeric Go field silently yields a page of zeroes — henceparseAirliftDurationMS/parseAirliftDataSizeBytes, both of which return 0 rather than failing so a unit-spelling change costs one column, not the operator's whole view. - Reads are cached (2s for queries, 15s for nodes/info) because the
console polls from every open tab and
/v1/querywalks every query the coordinator holds. A refresh in flight does not block readers — they get the previous value — so a slow coordinator can't turn N polling tabs into N stuck requests during the incident the console exists for. - Both sides of the console tag
X-Trino-Source(duckgres-adminhere,duckgres-provisionerin the reconcile loop), so control-plane traffic is distinguishable from tenant SQL insystem.runtime.queriesand in the console's own live view. - Trino binds exactly one node-listing route, chosen by
discovery.type./v1/node(heartbeat health, plus/v1/node/failed) exists only underAIRLIFT_DISCOVERY;/v1/announce(the set of announced node URIs, no health) is bound underANNOUNCE— Trino's default, and what these cells run — and underDNS. The client tries/v1/nodeand falls back, and the payload carriessourceso the SPA renders membership-only rows instead of zero-filled health columns. Both routes are@ResourceSecurity(MANAGEMENT_READ), so the observer's existingReadSystemInformationgrant covers both. system.runtime.nodesis preferred over/v1/announceon a cell that binds no/v1/node: it is served regardless ofdiscovery.typeand is the only source carryingnode_version, so it is where worker version skew becomes visible. It is the observer's one data grant —AccessCatalogonsystemplusSelectFromColumnspinned to that single table — and it needs its own resource-group lane, because the catch-all selector would otherwise file the console's query as a tenant. Order is/v1/node→system.runtime.nodes→/v1/announce, so a cell where the grant has not rolled out yet still lists its fleet.- Neither route carries a node id or version, so worker version skew is not observable there; the Nodes page's pod projection (running images) is where that lives.
- No legacy URL or registry configured leaves every route unregistered, and the SPA renders a "no cell" state off the 404.
Touching this → update trino_test.go, trino_client_test.go,
ui/src/lib/trino.test.ts, ui/src/pages/TrinoQueries.test.tsx, and
ui/src/pages/OrgTrinoCard.test.tsx.
Local UI development
Two ways to iterate without redeploying:
- Vite dev server (live React/HMR):
cd controlplane/admin/ui && npm run dev, withVITE_PROXY_TARGETpointing at a port-forwarded CP (or the devserver). - Go devserver (
devserver/): serves the built UI off disk and proxies/api,/login,/healthto a deployed CP, injecting the internal secret server-side. One--contextdrives secret fetch + port-forward, with a RED banner when the context name containsprod.
just ui-dev mw-dev-admin # → http://127.0.0.1:5173 (dev banner)
just ui-dev mw-prod-us-admin # → RED prod banner
The SPA uses relative /api/v1 paths, so the same bundle runs identically
embedded, under Vite, or under the Go devserver.
For local usage-pricing development, start the proxied control plane with an
explicit --aws-region or DUCKGRES_AWS_REGION; there is intentionally no
pricing-region default. If /usage reports that customer pricing is
unsupported, inspect the effective AWS region, correct it to the deployment's
us-* or eu-* region, and restart/redeploy the control plane. Do not work
around the error by inferring the browser domain or an individual org's bucket
region.
Tests
Backend: authz_test.go (SSO role mapping, RoleGate, SQL classifier),
dashboard_test.go (TokenSet / break-glass login / cookie), api_test.go +
api_postgres_test.go (CRUD), models_api_test.go (redaction). e2e: the
admin_* / impersonation_* / models_explorer_api assertions in
tests/mw-dev/e2e/harness.sh.
Frontend (ui/, Vitest + Testing Library — just ui-test, CI job
ui-tests): the dashboard's data-derivation logic has shipped wrong more than
once (worker hot/idle counts; a leak warning firing while every worker was
busy), so that math lives in pure, unit-tested modules (src/lib/*.ts) instead
of inline JSX. src/lib/fleet.test.ts pins the worker-fleet/load math
(busy=hot vs idle=hot_idle, the leak threshold, per-org load %);
src/pages/Overview.test.tsx renders the page with mocked hooks and asserts the
Workers card + leak warning. New derivation/display logic on a page must get
a *.test.ts(x) here — keep computed values out of the JSX so they're testable.
Directories
¶
| Path | Synopsis |
|---|---|
|
Command devserver serves the admin dashboard's static assets off disk and reverse-proxies the API to a control plane, so the UI can be iterated on locally without rebuilding/redeploying the control-plane image.
|
Command devserver serves the admin dashboard's static assets off disk and reverse-proxies the API to a control plane, so the UI can be iterated on locally without rebuilding/redeploying the control-plane image. |