From 5c89fceb217d591233f81b61dea8d4d9e823859c Mon Sep 17 00:00:00 2001 From: Benjamin Knofe-Vider Date: Thu, 30 Jul 2026 12:20:37 +0100 Subject: [PATCH 1/3] feat(controlplane): expose metadata Postgres proxy --- CLAUDE.md | 62 ++ README.md | 50 +- cmd/duckgres-controlplane/main.go | 2 + configresolve/resolve.go | 18 + configresolve/resolve_k8s_test.go | 21 + controlplane/admin/api.go | 24 +- controlplane/admin/api_test.go | 61 ++ .../admin/ui/src/pages/OrgDetail.test.tsx | 153 +++++ controlplane/admin/ui/src/pages/OrgDetail.tsx | 22 + controlplane/admin/ui/src/types/api.ts | 1 + controlplane/admin_providers.go | 14 +- .../000033_add_metadata_proxy_enabled.sql | 7 + controlplane/configstore/models.go | 10 +- controlplane/configstore/store.go | 79 ++- controlplane/configstore/store_test.go | 132 +++- controlplane/control.go | 26 +- controlplane/metadata_proxy.go | 404 +++++++++++ controlplane/metadata_proxy_kubernetes.go | 15 + controlplane/metadata_proxy_metrics.go | 154 +++++ controlplane/metadata_proxy_test.go | 633 ++++++++++++++++++ controlplane/multitenant.go | 29 +- controlplane/shared_worker_activator.go | 21 +- controlplane/sni_kubernetes.go | 10 +- controlplane/sni_other.go | 4 + docs/metrics.md | 97 +++ main.go | 2 + tests/configstore/migrations_postgres_test.go | 11 +- tests/mw-dev/README.md | 19 + tests/mw-dev/e2e/harness.sh | 190 +++++- tests/mw-dev/manifests.tmpl.yaml | 8 +- tests/mw-dev/run_sh_test.go | 59 ++ 31 files changed, 2293 insertions(+), 45 deletions(-) create mode 100644 controlplane/admin/ui/src/pages/OrgDetail.test.tsx create mode 100644 controlplane/configstore/migrations/000033_add_metadata_proxy_enabled.sql create mode 100644 controlplane/metadata_proxy.go create mode 100644 controlplane/metadata_proxy_kubernetes.go create mode 100644 controlplane/metadata_proxy_metrics.go create mode 100644 controlplane/metadata_proxy_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 2154ddb6..faa67702 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,6 +27,68 @@ PG Client → TLS/Auth/PG Protocol → Control Plane pod → Flight SQL (TCP+TLS) → per-org Worker pod (DuckDB) ``` +### Native metadata Postgres proxy + +The Kubernetes control plane can expose explicitly opted-in CNPG-backed +warehouse metadata databases on a separate SNI suffix +(`DUCKGRES_METADATA_HOSTNAME_SUFFIXES`, for example +`.md.us.postwh.com`). This is an early connection branch, not a DuckDB +executor: the existing org `root` password authenticates at Duckgres, the +startup database MUST be exactly `metadata`, and the control plane resolves +the real endpoint/database/tenant role/password internally through +`SharedWorkerActivator.MetadataPostgresURL`. After authenticating upstream +with that internal credential, protocol traffic is relayed byte-for-byte. The +endpoint and password remain internal; the upstream role and database can be +visible to the fully privileged client through normal PostgreSQL introspection +such as `current_user` and `current_database()`. + +Access is fail-closed on the warehouse row's `metadata_proxy_enabled` flag, +`state=ready`, and `metadata_store_kind=cnpg-shard`. Never infer publication +from shard placement and never pass a client-supplied upstream database, +username, host, or password. `DUCKGRES_METADATA_PROXY_MAX_CONNECTIONS_PER_ORG` +(default 20 per control-plane replica) bounds public sessions so they cannot +exhaust the internal PgBouncer pool. These sessions participate in the +existing per-user kill / disable fan-out, and an established session is closed +if the warehouse gate stops being eligible. An admin warehouse PUT that +explicitly includes `metadata_proxy_enabled` reloads the local config snapshot +and notifies peer replicas; established sessions observe the new gate on their +next five-second recheck after snapshot propagation. + +The initial scope is dedicated, single-customer CNPG shards only. Do not enable +an org on a shared shard until upstream `CONNECT` ACLs and role hardening are in +place. The exact virtual database check prevents selecting a different startup +database, but after connection the customer `root` credential has the full +access of the internally resolved metadata role. + +Observability stays explicit at the branch boundary: +`duckgres_connections_open` includes these client sockets because it is the +process-wide accepted-connection gauge, while +`duckgres_metadata_proxy_connections_open`, +`duckgres_metadata_proxy_connection_attempts_total`, +`duckgres_metadata_proxy_connection_duration_seconds`, +`duckgres_metadata_proxy_upstream_connect_duration_seconds`, and +`duckgres_metadata_proxy_bytes_total` isolate proxy load and failures. The +relay is intentionally opaque, so DuckDB query metrics, query logs, and query +traces do not include SQL executed through it. Use the CNPG/PgBouncer metrics +and the fixed upstream `application_name=duckgres-metadata-proxy` for +database-side attribution; never make one org's metadata target part of the +control-plane health check. Target resolution plus upstream +connect/auth/synchronization has a fixed 10-second bootstrap deadline; the +deadline is canceled after hijack and never applies to established relay +traffic. `duckgres_auth_failures_total` includes wrong-password proxy attempts; +the dedicated attempt counter with `outcome="auth_failed"` is the proxy split. +Pre-TLS `duckgres_rate_limit_rejects_total` events cannot be assigned to either +endpoint because SNI has not yet been observed. + +Metadata-proxy `CancelRequest` handling is session-terminating. Synthetic +backend keys map to the exact established frontend/upstream connection pair on +the owning control-plane replica, which closes both rather than redialing a +PgBouncer Service where instance-local cancel keys could reach the wrong pod. +Raw cancel connections remain control-plane-local behind the NLB, matching the +existing cancellation locality: a synthetic-key miss on another replica is +absorbed and counted by +`duckgres_metadata_proxy_cancel_requests_total{outcome="not_local"}`. + In topologies 2 and 3, the control plane also exposes an Arrow Flight SQL ingress (`--flight-port`) for clients that prefer Flight over the PG wire protocol. ### Key Components diff --git a/README.md b/README.md index f09c63cd..0c592d4c 100644 --- a/README.md +++ b/README.md @@ -68,12 +68,18 @@ labels, aggregation rules, PromQL examples, and admission metric migration. | Metric | Type | Description | |--------|------|-------------| -| `duckgres_connections_open` | Gauge | Number of currently open client connections | -| `duckgres_connection_duration_seconds{org}` | Histogram | Client connection lifetime, accept→disconnect (includes `_count`, `_sum`, `_bucket`); `_sum` is exact total connection-seconds (per org) with no scrape-integral bias. The disconnect log also carries `duration_ms` | +| `duckgres_connections_open` | Gauge | Process-wide number of currently open client connections, including native metadata-proxy sockets | +| `duckgres_connection_duration_seconds{org}` | Histogram | Worker-backed Duckgres connection lifetime, accept→disconnect (includes `_count`, `_sum`, `_bucket`); excludes native metadata-proxy connections, which use their dedicated duration family | +| `duckgres_metadata_proxy_connections_open{org}` | Gauge | Current admitted native metadata Postgres proxy connections; process-local, so sum across control-plane replicas | +| `duckgres_metadata_proxy_connection_attempts_total{org,outcome}` | Counter | Metadata proxy attempts by bounded terminal outcome | +| `duckgres_metadata_proxy_connection_duration_seconds{org}` | Histogram | Lifetime of admitted metadata proxy connections, including upstream bootstrap | +| `duckgres_metadata_proxy_upstream_connect_duration_seconds{org,outcome}` | Histogram | Internal metadata Postgres connect/auth latency; outcome is `success` or `error` | +| `duckgres_metadata_proxy_bytes_total{org,direction}` | Counter | Post-authentication pgwire bytes relayed in `client_to_upstream` or `upstream_to_client` direction | +| `duckgres_metadata_proxy_cancel_requests_total{outcome}` | Counter | Raw metadata-proxy CancelRequests handled as `session_terminated` on the owning control-plane replica or `not_local` on another replica | | `duckgres_query_total{org,status,reason}` | Counter | Total non-empty query attempts. Valid status/reason pairs: `success/none`; `failure/user`, `failure/canceled`, `failure/conflict`; `error/metadata_connection_lost`, `error/system`. | | `duckgres_query_duration_seconds{org}` | Histogram | Simple/extended query execution latency (includes `_count`, `_sum`, `_bucket`); use `duckgres_query_total` for attempt totals | -| `duckgres_auth_failures_total` | Counter | Total number of authentication failures | -| `duckgres_rate_limit_rejects_total` | Counter | Total number of connections rejected due to rate limiting | +| `duckgres_auth_failures_total` | Counter | Process-wide authentication failures, including wrong-password metadata-proxy attempts; use `duckgres_metadata_proxy_connection_attempts_total{outcome="auth_failed"}` for the proxy-specific split | +| `duckgres_rate_limit_rejects_total` | Counter | Process-wide pre-TLS connection rejections due to rate limiting; these cannot be attributed to the worker or metadata endpoint because SNI is not available yet | | `duckgres_rate_limited_ips` | Gauge | Number of currently rate-limited IP addresses | | `duckgres_flight_auth_sessions_active` | Gauge | Number of active Flight auth sessions on the control plane | | `duckgres_control_plane_workers_active` | Gauge | Number of active control-plane worker processes | @@ -367,6 +373,8 @@ Run with config file: | `DUCKGRES_HANDOVER_DRAIN_TIMEOUT` | Max time to drain planned shutdowns and upgrades before forcing exit | `24h` in process mode, `15m` in remote K8s mode | | `DUCKGRES_SNI_ROUTING_MODE` | Multi-tenant managed-hostname routing: `off`, `passthrough`, or `enforce`. Postgres uses the requested dbname first; managed SNI must resolve to the same org, and SNI supplies the database only when dbname is empty. | `off` | | `DUCKGRES_MANAGED_HOSTNAME_SUFFIXES` | Comma-separated managed hostname suffixes such as `.dw.us.postwh.com` | - | +| `DUCKGRES_METADATA_HOSTNAME_SUFFIXES` | Comma-separated SNI suffixes for the explicitly enabled native metadata Postgres proxy, such as `.md.us.postwh.com` | - | +| `DUCKGRES_METADATA_PROXY_MAX_CONNECTIONS_PER_ORG` | Maximum admitted metadata proxy sessions per org on each control-plane replica | `20` | | `DUCKGRES_DUCKLAKE_METADATA_STORE` | DuckLake metadata connection string | - | | `DUCKGRES_DUCKLAKE_DELTA_CATALOG_ENABLED` | Attach a Delta Lake catalog/table during worker boot/activation | `false` | | `DUCKGRES_DUCKLAKE_DELTA_CATALOG_PATH` | Delta Lake catalog/table path; defaults to sibling `delta/` prefix at the DuckLake object-store root when enabled | Derived | @@ -889,6 +897,40 @@ In Kubernetes environments, `--worker-backend remote` is the multitenant path. I Managed-hostname routing is controlled by `--sni-routing-mode` and `--managed-hostname-suffixes`. For Postgres, an explicit startup `database`/`dbname` takes priority, but when SNI matches a managed suffix the hostname prefix and requested database must resolve to the same org. If the startup database is empty, the managed SNI prefix is used as the database fallback. Unknown `--sni-routing-mode` values behave like `off`. +The native metadata Postgres proxy is a separate, fail-closed SNI path selected +by `DUCKGRES_METADATA_HOSTNAME_SUFFIXES`. It is available only when an org's +warehouse explicitly has `metadata_proxy_enabled=true`, is ready, and uses a +CNPG-shard metadata store. The client must connect as `root` with the org's +existing Duckgres password and must send the exact non-empty +`dbname=metadata`. Duckgres resolves and uses the real metadata role, password, +database, and PgBouncer endpoint internally. The endpoint and password are +never sent to the client; the upstream role and database may be visible +through normal PostgreSQL introspection such as `current_user` and +`current_database()`. The per-org connection limit is enforced independently +on every control-plane replica. Internal target resolution and +connect/auth/synchronization have a fixed 10-second bootstrap deadline; the +deadline does not apply after the relay is established. An admin/UI update that +includes `metadata_proxy_enabled` reloads the local config snapshot and +notifies peer replicas; established sessions close on their next five-second +authorization recheck after the updated snapshot arrives. + +The initial rollout is restricted operationally to dedicated, +single-customer CNPG shards. Do not enable `metadata_proxy_enabled` for an org +on a shared shard until upstream database `CONNECT` ACLs and role hardening are +in place: once the exact `metadata` database connection is established, the +customer's `root` credential intentionally receives full access available to +the internally resolved metadata role. + +Metadata-proxy cancellation is deliberately session-terminating: when a raw +PostgreSQL `CancelRequest` reaches the control-plane replica that owns the +synthetic backend key, Duckgres closes that exact frontend and upstream +connection pair. PgBouncer cancellation keys are instance-local, so Duckgres +does not redial the pooler Service. Like existing Duckgres cancellation, the +raw follow-up TCP connection is control-plane-local behind the NLB; a request +routed to another replica is counted as +`duckgres_metadata_proxy_cancel_requests_total{outcome="not_local"}` and cannot +terminate the owning session. + Workers are spawned on demand: when an org opens a session with no reusable worker, the control plane creates a worker pod (sized from the connection's `duckgres.worker_cpu`/`worker_memory` request, or a default), activates it over the worker control RPC, and it becomes hot for that org. When its last session ends, the worker moves to `hot_idle` instead of being retired immediately: it keeps the org assignment and DuckLake attachment so any control-plane replica can reclaim it for the same org (by exact worker shape) without full reactivation, until its `duckgres.worker_ttl` expires. Hot-idle reuse is image/version strict. The janitor retires hot-idle workers at their TTL, but `default_worker_min_hot_idle` lets an org retain a minimum number of compatible default-profile hot-idle workers by skipping TTL retirement when the count is already at or below the floor. The default is `0` (disabled). The main lifecycle is: idle → reserved → activating → hot → hot_idle → retired. Workers can also move through `draining` during shutdown, rollout, or cleanup. (Spawn latency is hidden by the node-headroom controller, which keeps placeholder pods ready for real workers to preempt.) ```bash diff --git a/cmd/duckgres-controlplane/main.go b/cmd/duckgres-controlplane/main.go index 30aea276..810b1118 100644 --- a/cmd/duckgres-controlplane/main.go +++ b/cmd/duckgres-controlplane/main.go @@ -246,6 +246,8 @@ func main() { ReadOnlySecretFallbacks: resolved.ReadOnlySecretFallbacks, SNIRoutingMode: resolved.SNIRoutingMode, ManagedHostnameSuffixes: resolved.ManagedHostnameSuffixes, + MetadataHostnameSuffixes: resolved.MetadataHostnameSuffixes, + MetadataProxyMaxConns: resolved.MetadataProxyMaxConns, DucklingBucketSuffix: resolved.DucklingBucketSuffix, DuckLakeDefaultSpecVersion: resolved.DuckLakeDefaultSpecVersion, diff --git a/configresolve/resolve.go b/configresolve/resolve.go index 5e3dd16c..988055fe 100644 --- a/configresolve/resolve.go +++ b/configresolve/resolve.go @@ -133,6 +133,8 @@ type Resolved struct { UserSecretKey string SNIRoutingMode string ManagedHostnameSuffixes []string + MetadataHostnameSuffixes []string + MetadataProxyMaxConns int DucklingBucketSuffix string DuckLakeDefaultSpecVersion string @@ -222,6 +224,8 @@ func ResolveEffective(fileCfg *configloader.FileConfig, cli CLIInputs, getenv fu var userSecretKey string var sniRoutingMode string var managedHostnameSuffixes []string + var metadataHostnameSuffixes []string + metadataProxyMaxConnections := 20 var ducklingBucketSuffix string if fileCfg != nil { @@ -786,6 +790,18 @@ func ResolveEffective(fileCfg *configloader.FileConfig, cli CLIInputs, getenv fu if v := getenv("DUCKGRES_MANAGED_HOSTNAME_SUFFIXES"); v != "" { managedHostnameSuffixes = splitAndTrim(v, ",") } + if v := getenv("DUCKGRES_METADATA_HOSTNAME_SUFFIXES"); v != "" { + metadataHostnameSuffixes = splitAndTrim(v, ",") + } + if v := getenv("DUCKGRES_METADATA_PROXY_MAX_CONNECTIONS_PER_ORG"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + metadataProxyMaxConnections = n + } else if err != nil { + warn("Invalid DUCKGRES_METADATA_PROXY_MAX_CONNECTIONS_PER_ORG: " + err.Error()) + } else { + warn("DUCKGRES_METADATA_PROXY_MAX_CONNECTIONS_PER_ORG must be > 0") + } + } // Env-only (set by the duckgres Helm chart per environment). The env suffix // the control plane uses to name a type=s3bucket Duckling's per-org bucket: // posthog-duckling--. Must equal crossplane-config's @@ -1244,6 +1260,8 @@ func ResolveEffective(fileCfg *configloader.FileConfig, cli CLIInputs, getenv fu UserSecretKey: userSecretKey, SNIRoutingMode: sniRoutingMode, ManagedHostnameSuffixes: managedHostnameSuffixes, + MetadataHostnameSuffixes: metadataHostnameSuffixes, + MetadataProxyMaxConns: metadataProxyMaxConnections, DucklingBucketSuffix: ducklingBucketSuffix, DuckLakeDefaultSpecVersion: cfg.DuckLake.SpecVersion, diff --git a/configresolve/resolve_k8s_test.go b/configresolve/resolve_k8s_test.go index 34797aa0..18a62269 100644 --- a/configresolve/resolve_k8s_test.go +++ b/configresolve/resolve_k8s_test.go @@ -1,6 +1,7 @@ package configresolve import ( + "slices" "testing" ) @@ -25,6 +26,26 @@ func TestResolveEffectiveExposesDuckLakeDefaultSpecVersionForControlPlane(t *tes } } +func TestResolveEffectiveParsesMetadataHostnameSuffixes(t *testing.T) { + resolved := ResolveEffective(nil, CLIInputs{}, func(key string) string { + switch key { + case "DUCKGRES_METADATA_HOSTNAME_SUFFIXES": + return " .md.us.postwh.com, .md.eu.postwh.com " + case "DUCKGRES_METADATA_PROXY_MAX_CONNECTIONS_PER_ORG": + return "7" + } + return "" + }, nil) + + want := []string{".md.us.postwh.com", ".md.eu.postwh.com"} + if !slices.Equal(resolved.MetadataHostnameSuffixes, want) { + t.Fatalf("expected metadata hostname suffixes %v, got %v", want, resolved.MetadataHostnameSuffixes) + } + if resolved.MetadataProxyMaxConns != 7 { + t.Fatalf("expected metadata per-org connection cap 7, got %d", resolved.MetadataProxyMaxConns) + } +} + func TestResolveEffectiveParsesK8sWorkerMaxTTL(t *testing.T) { resolved := ResolveEffective(nil, CLIInputs{}, func(key string) string { if key == "DUCKGRES_K8S_WORKER_MAX_TTL" { diff --git a/controlplane/admin/api.go b/controlplane/admin/api.go index 5d00279f..2220fbb5 100644 --- a/controlplane/admin/api.go +++ b/controlplane/admin/api.go @@ -747,6 +747,7 @@ func managedWarehouseUpsertColumns() []string { // DB column name. Mismatching this against the actual column makes // the ON CONFLICT … DO UPDATE clause throw 42703. "duck_lake_version", + "metadata_proxy_enabled", "duckling_name", "warehouse_database_endpoint", "warehouse_database_port", @@ -807,6 +808,7 @@ type apiHandler struct { type managedWarehouseRequest struct { Image string `json:"image"` DuckLakeVersion string `json:"ducklake_version"` + MetadataProxyEnabled bool `json:"metadata_proxy_enabled"` DucklingName string `json:"duckling_name"` WarehouseDatabase configstore.ManagedWarehouseDatabase `json:"warehouse_database"` MetadataStore configstore.ManagedWarehouseMetadataStore `json:"metadata_store"` @@ -1420,8 +1422,16 @@ func (h *apiHandler) putManagedWarehouse(c *gin.Context) { // NAMES only — the values can carry credentials/secret refs, so we never put // them in the audit log). Gives "changed: metadata_store, s3" instead of a // bare "warehouse.update". - if changed := topLevelJSONKeys(body); len(changed) > 0 { - setAuditDetail(c, "changed: "+strings.Join(changed, ", ")) + changedFields := topLevelJSONKeys(body) + if len(changedFields) > 0 { + setAuditDetail(c, "changed: "+strings.Join(changedFields, ", ")) + } + metadataProxyChanged := false + for _, field := range changedFields { + if field == "metadata_proxy_enabled" { + metadataProxyChanged = true + break + } } // MutateManagedWarehouse locks the row inside a transaction, runs the @@ -1467,6 +1477,16 @@ func (h *apiHandler) putManagedWarehouse(c *gin.Context) { c.JSON(http.StatusNotFound, gin.H{"error": "org not found"}) return } + if metadataProxyChanged { + // The proxy gate is consumed from each CP's in-memory snapshot. Reload + // locally and notify peers now so an explicit admin/UI toggle does not + // wait for the normal config poll interval. Established sessions + // recheck the refreshed gate at most five seconds later. + if err := h.notifyPeersOfChange(c); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + } c.JSON(http.StatusOK, stored) } diff --git a/controlplane/admin/api_test.go b/controlplane/admin/api_test.go index e3e3e3ad..19d669ee 100644 --- a/controlplane/admin/api_test.go +++ b/controlplane/admin/api_test.go @@ -1176,6 +1176,64 @@ func TestPutWarehouseDisablesPgBouncerWhenSetToFalse(t *testing.T) { } } +func TestPutWarehouseTogglesMetadataProxy(t *testing.T) { + const reloadPath = "/api/v1/internal/reload-snapshot" + store := newFakeAPIStore() + seedOrgWithWarehouse(store, "analytics") + fetcher := &fakePeerFetcher{} + router := newTestAPIRouterWithFetcher(store, fetcher) + + for i, enabled := range []bool{true, false} { + body := []byte(fmt.Sprintf(`{"metadata_proxy_enabled": %t}`, enabled)) + req := httptest.NewRequest(http.MethodPut, "/api/v1/orgs/analytics/warehouse", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("enabled=%v: status = %d, want %d: %s", enabled, rec.Code, http.StatusOK, rec.Body.String()) + } + if got := store.warehouses["analytics"].MetadataProxyEnabled; got != enabled { + t.Fatalf("enabled=%v: stored metadata_proxy_enabled = %v", enabled, got) + } + if got := store.reloadSnapshotCalls; got != i+1 { + t.Fatalf("enabled=%v: reloadSnapshotCalls = %d, want %d", enabled, got, i+1) + } + if got := fetcher.postCallCount(); got != int32(i+1) { + t.Fatalf("enabled=%v: peer fan-out POSTs = %d, want %d", enabled, got, i+1) + } + fetcher.mu.Lock() + lastPath := fetcher.postPaths[len(fetcher.postPaths)-1] + fetcher.mu.Unlock() + if lastPath != reloadPath { + t.Fatalf("enabled=%v: peer fan-out path = %q, want %q", enabled, lastPath, reloadPath) + } + } +} + +func TestPutWarehouseWithoutMetadataProxyFieldDoesNotReloadSnapshots(t *testing.T) { + store := newFakeAPIStore() + seedOrgWithWarehouse(store, "analytics") + fetcher := &fakePeerFetcher{} + router := newTestAPIRouterWithFetcher(store, fetcher) + + body := []byte(`{"pgbouncer":{"enabled":true}}`) + req := httptest.NewRequest(http.MethodPut, "/api/v1/orgs/analytics/warehouse", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d: %s", rec.Code, http.StatusOK, rec.Body.String()) + } + if got := store.reloadSnapshotCalls; got != 0 { + t.Fatalf("reloadSnapshotCalls = %d, want 0 for unrelated warehouse PUT", got) + } + if got := fetcher.postCallCount(); got != 0 { + t.Fatalf("peer fan-out POSTs = %d, want 0 for unrelated warehouse PUT", got) + } +} + func TestPutWarehouseRejectsRemovedIcebergField(t *testing.T) { // Iceberg support was removed: "iceberg" is no longer a whitelisted field // on the warehouse PUT, so the strict decode rejects it as unknown. @@ -1699,6 +1757,9 @@ func TestManagedWarehouseUpsertColumnsExcludeCreatedAt(t *testing.T) { if !slices.Contains(columns, "metadata_store_database_name") { t.Fatal("expected metadata_store_database_name to be included in managed warehouse upserts") } + if !slices.Contains(columns, "metadata_proxy_enabled") { + t.Fatal("expected metadata_proxy_enabled to be included in managed warehouse upserts") + } // Regression guards: image and duck_lake_version must be in the upsert // column list so the per-tenant pinning patch endpoint actually // persists. If either is missing, PATCH /orgs/:id/warehouse/pinning diff --git a/controlplane/admin/ui/src/pages/OrgDetail.test.tsx b/controlplane/admin/ui/src/pages/OrgDetail.test.tsx new file mode 100644 index 00000000..0bc9e9c8 --- /dev/null +++ b/controlplane/admin/ui/src/pages/OrgDetail.test.tsx @@ -0,0 +1,153 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import type { ManagedWarehouse, Org } from "@/types/api"; + +const hooks = vi.hoisted(() => ({ + useDeleteOrg: vi.fn(), + useDeprovisionWarehouse: vi.fn(), + useDucklingsMetadata: vi.fn(), + useOrg: vi.fn(), + useOrgReshards: vi.fn(), + useOrgTeams: vi.fn(), + useUpdateOrg: vi.fn(), + useUpdateWarehouse: vi.fn(), + useWarehouse: vi.fn(), +})); +vi.mock("@/hooks/useApi", () => hooks); + +const identity = vi.hoisted(() => ({ useIdentity: vi.fn() })); +vi.mock("@/components/IdentityProvider", () => identity); + +vi.mock("@/components/OrgTeamDialogs", () => ({ + BackfillBadge: () => null, + CreateTeamDialog: () => null, + DeleteTeamDialog: () => null, + EarliestEventDateCell: () => null, + EditTeamDialog: () => null, + LegacyNamesBadge: () => null, +})); + +import { OrgDetail } from "./OrgDetail"; + +const warehouseUpdate = vi.fn(); +const ok = (data: T) => ({ + data, + isSuccess: true, + isLoading: false, + isError: false, + error: null, + refetch: vi.fn(), +}); +const mut = (mutateAsync = vi.fn().mockResolvedValue(undefined)) => ({ + mutateAsync, + isPending: false, +}); + +const ORG: Org = { + name: "acme", + database_name: "acme", + hostname_alias: null, + max_workers: 1, + max_vcpus: 2, + default_worker_cpu: "2", + default_worker_memory: "8Gi", + default_worker_ttl: "75m", + default_worker_min_hot_idle: 0, + created_at: "2026-07-01T00:00:00Z", + updated_at: "2026-07-01T00:00:00Z", +}; + +function warehouse(metadataProxyEnabled: boolean): ManagedWarehouse { + return { + org_id: "acme", + duckling_name: "duckling-acme", + image: "duckgres:latest", + ducklake_version: "0.4", + metadata_proxy_enabled: metadataProxyEnabled, + warehouse_database: { endpoint: "", port: 5432 }, + metadata_store: { + kind: "cnpg-shard", + endpoint: "", + port: 5432, + database_name: "acme", + username: "acme", + }, + pgbouncer: { enabled: true }, + s3: { + provider: "aws", + region: "us-east-1", + bucket: "test", + path_prefix: "", + endpoint: "", + use_ssl: true, + url_style: "vhost", + delta_catalog_enabled: false, + delta_catalog_path: "", + }, + worker_identity: { namespace: "ducklings", iam_role_arn: "" }, + warehouse_database_credentials: { namespace: "ducklings", name: "warehouse", key: "url" }, + metadata_store_credentials: { namespace: "ducklings", name: "metadata", key: "url" }, + s3_credentials: { namespace: "ducklings", name: "s3", key: "credentials" }, + runtime_config: { namespace: "ducklings", name: "runtime", key: "config" }, + state: "ready", + status_message: "", + metadata_store_state: "ready", + s3_state: "ready", + identity_state: "ready", + secrets_state: "ready", + created_at: "2026-07-01T00:00:00Z", + updated_at: "2026-07-01T00:00:00Z", + } as ManagedWarehouse; +} + +function renderPage(metadataProxyEnabled: boolean) { + hooks.useWarehouse.mockReturnValue(ok(warehouse(metadataProxyEnabled))); + render( + + + + } /> + + + , + ); +} + +describe("Org warehouse metadata proxy setting", () => { + beforeEach(() => { + vi.clearAllMocks(); + identity.useIdentity.mockReturnValue({ + isAdmin: true, + me: { email: "admin@example.com", role: "admin", source: "sso" }, + }); + hooks.useOrg.mockReturnValue(ok(ORG)); + hooks.useUpdateOrg.mockReturnValue(mut()); + hooks.useDeleteOrg.mockReturnValue(mut()); + hooks.useUpdateWarehouse.mockReturnValue(mut(warehouseUpdate)); + hooks.useDeprovisionWarehouse.mockReturnValue(mut()); + hooks.useDucklingsMetadata.mockReturnValue(ok({ available: true, entries: [] })); + hooks.useOrgReshards.mockReturnValue(ok([])); + hooks.useOrgTeams.mockReturnValue(ok([])); + }); + + it.each([ + { current: false, next: true }, + { current: true, next: false }, + ])("saves an explicit $next when the current value is $current", async ({ current, next }) => { + const user = userEvent.setup(); + renderPage(current); + + const toggle = screen.getByRole("switch", { name: /public metadata postgres/i }); + expect(toggle).toHaveAttribute("aria-checked", String(current)); + + await user.click(toggle); + expect(toggle).toHaveAttribute("aria-checked", String(next)); + await user.click(screen.getByRole("button", { name: /save warehouse/i })); + + expect(warehouseUpdate).toHaveBeenCalledTimes(1); + expect(warehouseUpdate).toHaveBeenCalledWith({ metadata_proxy_enabled: next }); + }); +}); diff --git a/controlplane/admin/ui/src/pages/OrgDetail.tsx b/controlplane/admin/ui/src/pages/OrgDetail.tsx index 57bc5b49..69cb56bc 100644 --- a/controlplane/admin/ui/src/pages/OrgDetail.tsx +++ b/controlplane/admin/ui/src/pages/OrgDetail.tsx @@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Badge } from "@/components/ui/badge"; +import { Switch } from "@/components/ui/switch"; import { StateBadge } from "@/components/StateBadge"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { AdminGate } from "@/components/AdminOnly"; @@ -359,6 +360,7 @@ function WarehousePanel({ const metadata = useDucklingsMetadata(); const [image, setImage] = useState(""); const [version, setVersion] = useState(""); + const [metadataProxyEnabled, setMetadataProxyEnabled] = useState(false); const [ducklingNameInput, setDucklingNameInput] = useState(""); const [confirmDeprovision, setConfirmDeprovision] = useState(false); const [deprovisionConfirmText, setDeprovisionConfirmText] = useState(""); @@ -368,6 +370,7 @@ function WarehousePanel({ if (data) { setImage(data.image ?? ""); setVersion(data.ducklake_version ?? ""); + setMetadataProxyEnabled(data.metadata_proxy_enabled ?? false); setDucklingNameInput(data.duckling_name ?? ""); } }, [data]); @@ -397,6 +400,9 @@ function WarehousePanel({ const body: Partial = {}; if (image !== (data?.image ?? "")) body.image = image; if (version !== (data?.ducklake_version ?? "")) body.ducklake_version = version; + if (metadataProxyEnabled !== (data?.metadata_proxy_enabled ?? false)) { + body.metadata_proxy_enabled = metadataProxyEnabled; + } if (ducklingNameInput !== (data?.duckling_name ?? "")) { body.duckling_name = ducklingNameInput; } @@ -513,6 +519,22 @@ function WarehousePanel({ className="font-mono text-xs" /> +
+
+ +

+ Initial scope: dedicated, single-customer CNPG shards only. Do not enable this + for a shared shard until upstream CONNECT and role hardening lands. Clients use + the org's root credential and exact dbname=metadata, with full metadata + database access. +

+
+ +