From ac3a97c75337eb81b37c4a47b8bd212ac38863d6 Mon Sep 17 00:00:00 2001 From: Benjamin Knofe-Vider Date: Thu, 9 Jul 2026 13:42:00 +0200 Subject: [PATCH] Admin console: Reshards nav page + auto-discovering config-store explorer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reshards in the left nav: new GET /api/v1/reshards (all orgs, newest first) + a Reshards page listing every operation — org link, from->to, state, current step, runtime, maintenance-mode duration — each row linking to the live operation page. Starting a reshard stays on the org detail page. Config Store page becomes a real database explorer: the models API now auto-discovers every base table in the config and runtime schemas from information_schema (fresh per request, so tables created by migrations or AutoMigrate after boot appear immediately) and surfaces the ones without a typed descriptor under an "Other" group, columns in ordinal order. Typed descriptors keep their precise json-tag redaction; the auto path errs toward redaction instead — values of credential-shaped columns (password/secret/ciphertext/token/nonce/credential) come back as [redacted], and auto keys are validated against a fresh information_schema read so a crafted key can never smuggle SQL. Tests: models postgres test seeds an unregistered table with an api_password column and asserts discovery + redaction + the crafted-key 404; reshard handler test covers the global list; e2e models_explorer asserts goose_db_version + duckgres_reshard_operations are discovered and list with columns, and reshard_validation checks the global-list envelope. --- controlplane/admin/models_api.go | 181 ++++++++++++++++++- controlplane/admin/models_api_test.go | 62 +++++++ controlplane/admin/reshard.go | 16 ++ controlplane/admin/reshard_test.go | 13 ++ controlplane/admin/ui/src/App.tsx | 2 + controlplane/admin/ui/src/components/nav.ts | 2 + controlplane/admin/ui/src/hooks/useApi.ts | 10 + controlplane/admin/ui/src/lib/api.ts | 3 + controlplane/admin/ui/src/pages/Reshards.tsx | 119 ++++++++++++ controlplane/configstore/reshard.go | 13 ++ tests/e2e-mw-dev/harness.sh | 13 ++ 11 files changed, 427 insertions(+), 7 deletions(-) create mode 100644 controlplane/admin/ui/src/pages/Reshards.tsx diff --git a/controlplane/admin/models_api.go b/controlplane/admin/models_api.go index a19f684c..a6ad2f65 100644 --- a/controlplane/admin/models_api.go +++ b/controlplane/admin/models_api.go @@ -5,6 +5,8 @@ package admin import ( "net/http" "reflect" + "regexp" + "sort" "strings" "github.com/gin-gonic/gin" @@ -24,12 +26,20 @@ const ( modelGroupTenants modelGroup = "Tenants" modelGroupRuntime modelGroup = "Runtime" modelGroupAdmin modelGroup = "Admin" + // Other holds AUTO-DISCOVERED tables: everything information_schema shows + // in the config + runtime schemas that no typed descriptor covers. New + // tables (migrations, AutoMigrated operational state) appear here with no + // registry edit — the explorer is a database explorer, not a curated list. + modelGroupOther modelGroup = "Other" ) // modelDescriptor describes one browsable config-store table for the generic -// models explorer. The descriptors are the single source of truth the sidebar, -// the listing endpoint, and the column derivation all read from — adding a new -// config-store model is one entry here. +// models explorer. A typed descriptor gives a table PRECISE redaction (fields +// tagged json:"-" vanish) plus a human label/group; every table WITHOUT a +// descriptor is still browsable via information_schema auto-discovery (group +// "Other"), where credential-shaped column values are redacted by name +// pattern. Add a descriptor when a table needs exact redaction or a curated +// spot in the sidebar — nothing breaks if you don't. type modelDescriptor struct { // Key is the URL slug and stable identifier (e.g. "orgs"). Key string @@ -55,10 +65,9 @@ type modelDescriptor struct { elem reflect.Type } -// modelDescriptors returns the ordered registry of browsable models. Order is -// the sidebar order. Every persisted configstore model that an operator might -// want to inspect belongs here; if you add a model in configstore/models.go, -// add it here too (and assert it in models_api_test.go). +// modelDescriptors returns the ordered registry of typed models. Order is the +// sidebar order for the curated groups; tables not listed here surface +// automatically under "Other" via information_schema discovery. func modelDescriptors() []modelDescriptor { mk := func(key, label string, group modelGroup, runtime bool, sample any) modelDescriptor { // table name from the model's TableName() via the gorm Tabler contract. @@ -103,6 +112,92 @@ type modelsHandler struct { store *configstore.ConfigStore } +// autoKeyPrefix namespaces the keys of auto-discovered tables ("auto:."). +const autoKeyPrefix = "auto:" + +// sensitiveColumnRe redacts VALUES of auto-discovered columns whose name looks +// credential-shaped. The typed descriptors above redact precisely via json +// tags; auto-discovered tables have no type to consult, so err toward +// redaction — a bcrypt hash or ciphertext leaking is fatal, an over-redacted +// secret NAME is a shrug. (Matches e.g. password, password_aws_secret, +// ciphertext, token, nonce, credential.) +var sensitiveColumnRe = regexp.MustCompile(`(?i)(password|secret|ciphertext|token|nonce|credential)`) + +// autoTable is one information_schema-discovered table. +type autoTable struct { + Schema string + Name string +} + +func (a autoTable) key() string { return autoKeyPrefix + a.Schema + "." + a.Name } +func (a autoTable) label() string { return a.Name } + +// discoverTables lists every base table in the config schema (current_schema) +// and the CP runtime schema that is NOT already covered by a typed descriptor. +// Reading information_schema fresh per request keeps the explorer honest: a +// table created by a migration or AutoMigrate after boot appears immediately. +func (h *modelsHandler) discoverTables() ([]autoTable, error) { + covered := map[string]struct{}{} + for _, d := range modelDescriptors() { + covered[d.Table] = struct{}{} + } + runtimeSchema := h.store.RuntimeSchema() + + type row struct { + TableSchema string + TableName string + } + var rows []row + err := h.store.DB().Raw(` + SELECT table_schema, table_name + FROM information_schema.tables + WHERE table_type = 'BASE TABLE' + AND table_schema IN (current_schema(), ?) + ORDER BY table_schema, table_name`, runtimeSchema).Scan(&rows).Error + if err != nil { + return nil, err + } + var out []autoTable + for _, r := range rows { + if _, ok := covered[r.TableName]; ok { + continue + } + out = append(out, autoTable{Schema: r.TableSchema, Name: r.TableName}) + } + sort.Slice(out, func(i, j int) bool { return out[i].key() < out[j].key() }) + return out, nil +} + +// resolveAutoTable validates an auto key against a FRESH discovery — the only +// table names ever interpolated into SQL are ones information_schema just +// returned, so a crafted key can't smuggle SQL. +func (h *modelsHandler) resolveAutoTable(key string) (autoTable, bool) { + if !strings.HasPrefix(key, autoKeyPrefix) { + return autoTable{}, false + } + tables, err := h.discoverTables() + if err != nil { + return autoTable{}, false + } + for _, t := range tables { + if t.key() == key { + return t, true + } + } + return autoTable{}, false +} + +// autoColumns returns the table's column names in ordinal order. +func (h *modelsHandler) autoColumns(t autoTable) ([]string, error) { + var cols []string + err := h.store.DB().Raw(` + SELECT column_name + FROM information_schema.columns + WHERE table_schema = ? AND table_name = ? + ORDER BY ordinal_position`, t.Schema, t.Name).Scan(&cols).Error + return cols, err +} + // registerModelsAPI registers the read-only generic models explorer used by the // admin dashboard's models browser. func registerModelsAPI(r *gin.RouterGroup, store *configstore.ConfigStore) { @@ -146,6 +241,21 @@ func (h *modelsHandler) listModels(c *gin.Context) { Count: count, }) } + autoTables, err := h.discoverTables() + if err == nil { + for _, t := range autoTables { + var count int64 + if err := db.Table(t.Schema + "." + t.Name).Count(&count).Error; err != nil { + count = -1 + } + out = append(out, modelSummary{ + Key: t.key(), + Label: t.label(), + Group: string(modelGroupOther), + Count: count, + }) + } + } c.JSON(http.StatusOK, gin.H{"models": out}) } @@ -172,6 +282,10 @@ func (h *modelsHandler) getModel(c *gin.Context) { } } if desc == nil { + if t, ok := h.resolveAutoTable(key); ok { + h.getAutoTable(c, t) + return + } c.JSON(http.StatusNotFound, gin.H{"error": "unknown model: " + key}) return } @@ -240,3 +354,56 @@ func jsonFieldOrder(t reflect.Type) []string { } return cols } + +// getAutoTable lists an auto-discovered table: columns from +// information_schema, rows as generic maps, values of credential-shaped +// columns redacted (see sensitiveColumnRe — auto tables have no typed model +// whose json tags could redact precisely). +func (h *modelsHandler) getAutoTable(c *gin.Context, t autoTable) { + db := h.store.DB() + table := t.Schema + "." + t.Name + + columns, err := h.autoColumns(t) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + var total int64 + if err := db.Table(table).Count(&total).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + var rows []map[string]interface{} + if err := db.Table(table).Limit(modelsRowLimit + 1).Find(&rows).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + truncated := false + if len(rows) > modelsRowLimit { + truncated = true + rows = rows[:modelsRowLimit] + } + for _, row := range rows { + for col, v := range row { + if v != nil && sensitiveColumnRe.MatchString(col) { + row[col] = "[redacted]" + } + } + } + if rows == nil { + rows = []map[string]interface{}{} + } + + c.JSON(http.StatusOK, modelListing{ + Key: t.key(), + Label: t.label(), + Group: string(modelGroupOther), + Table: table, + Columns: columns, + Count: total, + Truncated: truncated, + Rows: rows, + }) +} diff --git a/controlplane/admin/models_api_test.go b/controlplane/admin/models_api_test.go index 47d23d10..f73b4594 100644 --- a/controlplane/admin/models_api_test.go +++ b/controlplane/admin/models_api_test.go @@ -150,4 +150,66 @@ func TestModelsAPIPostgres(t *testing.T) { if rec.Code != http.StatusNotFound { t.Errorf("GET /models/nope = %d, want 404", rec.Code) } + + // ---- auto-discovery: the explorer is a database explorer, not a curated + // list. Tables with no typed descriptor (goose bookkeeping, migrations + // added later) must show up under "Other" with information_schema columns, + // and credential-shaped column VALUES must come back redacted. + if err := store.DB().Exec(` + CREATE TABLE explorer_probe ( + id BIGSERIAL PRIMARY KEY, + note TEXT NOT NULL, + api_password TEXT NOT NULL + ); + INSERT INTO explorer_probe (note, api_password) VALUES ('visible', 'super-secret-value'); + `).Error; err != nil { + t.Fatalf("seed probe table: %v", err) + } + + rec, body = get("/api/v1/models") + if rec.Code != http.StatusOK { + t.Fatalf("GET /models (post-probe) = %d", rec.Code) + } + if err := json.Unmarshal(body["models"], &summaries); err != nil { + t.Fatalf("decode summaries: %v", err) + } + probeKey := "" + gooseSeen := false + for _, s := range summaries { + if strings.HasSuffix(s.Key, ".explorer_probe") && s.Group == "Other" { + probeKey = s.Key + if s.Count != 1 { + t.Errorf("probe table count = %d, want 1", s.Count) + } + } + if strings.HasSuffix(s.Key, ".goose_db_version") { + gooseSeen = true + } + } + if probeKey == "" { + t.Fatalf("auto-discovery missed explorer_probe; sidebar: %v", summaries) + } + if !gooseSeen { + t.Fatalf("auto-discovery missed goose_db_version; sidebar: %v", summaries) + } + + rec, _ = get("/api/v1/models/" + probeKey) + if rec.Code != http.StatusOK { + t.Fatalf("GET auto table = %d body %s", rec.Code, rec.Body.String()) + } + if strings.Contains(rec.Body.String(), "super-secret-value") { + t.Fatalf("auto table leaked a credential-shaped column value:\n%s", rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "[redacted]") || !strings.Contains(rec.Body.String(), "visible") { + t.Fatalf("auto table body missing redaction marker or plain column:\n%s", rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), `"api_password"`) { + t.Fatalf("auto table columns missing api_password header:\n%s", rec.Body.String()) + } + + // A crafted auto key that information_schema does not return → 404, never SQL. + rec, _ = get("/api/v1/models/auto:public.pg_shadow%3B--") + if rec.Code != http.StatusNotFound { + t.Errorf("crafted auto key = %d, want 404", rec.Code) + } } diff --git a/controlplane/admin/reshard.go b/controlplane/admin/reshard.go index 857dbc9b..181d2cdb 100644 --- a/controlplane/admin/reshard.go +++ b/controlplane/admin/reshard.go @@ -32,6 +32,7 @@ type ReshardStore interface { CreateReshardOperation(op *configstore.ReshardOperation) error GetReshardOperation(id int64) (*configstore.ReshardOperation, error) ListReshardOperationsForOrg(orgID string, limit int) ([]configstore.ReshardOperation, error) + ListReshardOperations(limit int) ([]configstore.ReshardOperation, error) ListReshardLog(opID, afterID int64, limit int) ([]configstore.ReshardLogEntry, error) RequestReshardCancel(id int64) (bool, error) FinishPendingReshardOperation(id int64, state configstore.ReshardState, errMsg string) (bool, error) @@ -78,6 +79,7 @@ func RegisterReshardAPI(r *gin.RouterGroup, store ReshardStore, lister DucklingM h := &reshardHandler{store: store, lister: lister, stash: stash} r.POST("/orgs/:id/reshard", h.start) r.GET("/orgs/:id/reshards", h.listForOrg) + r.GET("/reshards", h.listAll) r.GET("/reshards/:opid", h.get) r.GET("/reshards/:opid/log", h.log) r.POST("/reshards/:opid/cancel", h.cancel) @@ -245,6 +247,20 @@ func (h *reshardHandler) currentShard(c *gin.Context, ducklingName string) strin return cnpgShardFromEndpoint(ms.Endpoint) } +// listAll returns operations across every org, newest first — the console's +// global Reshards page. +func (h *reshardHandler) listAll(c *gin.Context) { + ops, err := h.store.ListReshardOperations(parseIntDefault(c.Query("limit"), 100)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if ops == nil { + ops = []configstore.ReshardOperation{} + } + c.JSON(http.StatusOK, gin.H{"operations": ops}) +} + func (h *reshardHandler) listForOrg(c *gin.Context) { ops, err := h.store.ListReshardOperationsForOrg(c.Param("id"), parseIntDefault(c.Query("limit"), 50)) if err != nil { diff --git a/controlplane/admin/reshard_test.go b/controlplane/admin/reshard_test.go index 4fd25d27..0e438f61 100644 --- a/controlplane/admin/reshard_test.go +++ b/controlplane/admin/reshard_test.go @@ -68,6 +68,14 @@ func (f *fakeReshardStore) ListReshardOperationsForOrg(orgID string, _ int) ([]c return out, nil } +func (f *fakeReshardStore) ListReshardOperations(_ int) ([]configstore.ReshardOperation, error) { + var out []configstore.ReshardOperation + for _, op := range f.ops { + out = append(out, *op) + } + return out, nil +} + func (f *fakeReshardStore) ListReshardLog(opID, afterID int64, _ int) ([]configstore.ReshardLogEntry, error) { var out []configstore.ReshardLogEntry for _, e := range f.logs { @@ -257,6 +265,11 @@ func TestReshardGetListLogCancel(t *testing.T) { if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `"operations"`) { t.Fatalf("list status = %d body %s", w.Code, w.Body.String()) } + // Global list (nav page): same envelope, all orgs. + w = doJSON(r, http.MethodGet, "/api/v1/reshards", "") + if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `"operations"`) { + t.Fatalf("global list status = %d body %s", w.Code, w.Body.String()) + } // Incremental log poll. _ = store.AppendReshardLog(1, "info", "alpha") diff --git a/controlplane/admin/ui/src/App.tsx b/controlplane/admin/ui/src/App.tsx index c3eb7d5c..4edbf595 100644 --- a/controlplane/admin/ui/src/App.tsx +++ b/controlplane/admin/ui/src/App.tsx @@ -7,6 +7,7 @@ import { Overview } from "@/pages/Overview"; import { Orgs } from "@/pages/Orgs"; import { OrgDetail } from "@/pages/OrgDetail"; import { ReshardForm } from "@/pages/ReshardForm"; +import { Reshards } from "@/pages/Reshards"; import { ReshardOperation } from "@/pages/ReshardOperation"; import { UsersPage } from "@/pages/Users"; import { Live } from "@/pages/Live"; @@ -33,6 +34,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/controlplane/admin/ui/src/components/nav.ts b/controlplane/admin/ui/src/components/nav.ts index 54814b6f..c9e5b4a8 100644 --- a/controlplane/admin/ui/src/components/nav.ts +++ b/controlplane/admin/ui/src/components/nav.ts @@ -1,6 +1,7 @@ import { Activity, AlertTriangle, + ArrowLeftRight, Building2, Database, LayoutDashboard, @@ -30,6 +31,7 @@ export const NAV: NavItem[] = [ { to: "/nodes", label: "Nodes", icon: Network }, { to: "/workers", label: "Workers", icon: Server }, { to: "/metrics", label: "Metrics", icon: LineChart }, + { to: "/reshards", label: "Reshards", icon: ArrowLeftRight }, { to: "/configstore", label: "Config Store", icon: Database }, { to: "/impersonate", label: "Impersonate", icon: TerminalSquare, adminOnly: true }, { to: "/audit", label: "Audit", icon: ScrollText, adminOnly: true }, diff --git a/controlplane/admin/ui/src/hooks/useApi.ts b/controlplane/admin/ui/src/hooks/useApi.ts index 1036b73b..896ec01f 100644 --- a/controlplane/admin/ui/src/hooks/useApi.ts +++ b/controlplane/admin/ui/src/hooks/useApi.ts @@ -527,6 +527,16 @@ export function useReshardLog(opId: number | null, opState: string | undefined) return entries; } +// Global operation list for the Reshards nav page. Polls at the normal +// cadence; running ops keep their live detail on the op page itself. +export function useAllReshards() { + return useQuery({ + queryKey: ["reshards", "all"], + queryFn: () => tolerateStatus([], 403, 404, 503)(api.listAllReshards()), + refetchInterval: POLL.normal, + }); +} + export function useCancelReshard() { const qc = useQueryClient(); return useMutation({ diff --git a/controlplane/admin/ui/src/lib/api.ts b/controlplane/admin/ui/src/lib/api.ts index 49afa464..ed51f328 100644 --- a/controlplane/admin/ui/src/lib/api.ts +++ b/controlplane/admin/ui/src/lib/api.ts @@ -237,4 +237,7 @@ export const api = { ), cancelReshard: (opId: number) => post<{ cancel_requested?: boolean; state?: string }>(`/reshards/${opId}/cancel`, {}), + // Global operation list (all orgs, newest first) for the Reshards nav page. + listAllReshards: () => + get<{ operations: ReshardOperation[] }>("/reshards").then((r) => r.operations ?? []), }; diff --git a/controlplane/admin/ui/src/pages/Reshards.tsx b/controlplane/admin/ui/src/pages/Reshards.tsx new file mode 100644 index 00000000..294437b9 --- /dev/null +++ b/controlplane/admin/ui/src/pages/Reshards.tsx @@ -0,0 +1,119 @@ +import { Link } from "react-router-dom"; +import { ArrowRight } from "lucide-react"; +import { PageBody, PageHeader } from "@/components/AppShell"; +import { Card, CardContent } from "@/components/ui/card"; +import { StateBadge } from "@/components/StateBadge"; +import { ErrorState, LoadingState } from "@/components/states"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { fmtTime } from "@/lib/format"; +import { useAllReshards } from "@/hooks/useApi"; +import type { ReshardOperation } from "@/types/api"; + +function describeStore(kind: string, shard: string, endpoint: string): string { + if (kind === "cnpg-shard") return shard ? `cnpg ${shard}` : "cnpg"; + return endpoint ? `external ${endpoint}` : "external"; +} + +function duration(from: string | null, to: string | null): string { + if (!from) return "—"; + const end = to ? new Date(to).getTime() : Date.now(); + const s = Math.round((end - new Date(from).getTime()) / 1000); + if (s < 0) return "—"; + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + if (h > 0) return `${h}h ${m}m`; + if (m > 0) return `${m}m ${s % 60}s`; + return `${s}s`; +} + +// Global list of metadata-store reshard operations across every org, newest +// first. Each row links to the operation page (live log, cancel). Starting a +// reshard lives on the org detail page — this page is the fleet overview. +export function Reshards() { + const reshards = useAllReshards(); + const ops = reshards.data ?? []; + + return ( + <> + + + + + {reshards.isLoading ? ( + + ) : reshards.isError ? ( + reshards.refetch()} /> + ) : ops.length === 0 ? ( +

+ No reshard operations yet. Start one from an org's detail page (Danger zone → + "Reshard metadata store…"). +

+ ) : ( +
+ + + Op + Org + Migration + State + Step + Started + Runtime + Maintenance + + + + {ops.map((op: ReshardOperation) => ( + + + + #{op.id} + + + + + {op.org_id} + + + + + {describeStore(op.source_kind, op.from_shard, op.source_endpoint)} + + {describeStore(op.target_kind, op.to_shard, op.target_endpoint)} + + + + + + + {op.state === "running" || op.state === "pending" ? op.step || "—" : "—"} + + {op.started_at ? fmtTime(op.started_at) : "—"} + {duration(op.started_at, op.finished_at)} + + {op.blocked_at ? duration(op.blocked_at, op.unblocked_at) : "—"} + + + ))} + +
+ )} + + + + + ); +} diff --git a/controlplane/configstore/reshard.go b/controlplane/configstore/reshard.go index 2bc0ba9b..d5656f0c 100644 --- a/controlplane/configstore/reshard.go +++ b/controlplane/configstore/reshard.go @@ -162,6 +162,19 @@ func (cs *ConfigStore) ListReshardOperationsForOrg(orgID string, limit int) ([]R return ops, nil } +// ListReshardOperations returns operations across ALL orgs, newest first — +// the admin console's global reshards page. +func (cs *ConfigStore) ListReshardOperations(limit int) ([]ReshardOperation, error) { + if limit <= 0 || limit > 500 { + limit = 100 + } + var ops []ReshardOperation + if err := cs.db.Order("id DESC").Limit(limit).Find(&ops).Error; err != nil { + return nil, fmt.Errorf("list reshard operations: %w", err) + } + return ops, nil +} + // ClaimReshardOperation atomically claims a claimable operation for a runner: // state pending, or state running with a heartbeat older than staleAfter (a // takeover of a dead runner). The claim bumps runner_epoch — the fence that diff --git a/tests/e2e-mw-dev/harness.sh b/tests/e2e-mw-dev/harness.sh index a1788fc0..03e351f7 100755 --- a/tests/e2e-mw-dev/harness.sh +++ b/tests/e2e-mw-dev/harness.sh @@ -1603,6 +1603,16 @@ models_explorer_api() { # Unknown model → 404. code="$(curl -s -o /dev/null -w '%{http_code}' -H "$H" "$API/api/v1/models/not-a-model")" [ "$code" = "404" ] || fail "GET /api/v1/models/not-a-model returned $code, want 404" + # Auto-discovery: tables with no typed descriptor (goose bookkeeping, the + # reshard tables) must appear under "Other" and list with columns. + out="$(curl -fsS -H "$H" "$API/api/v1/models")" || fail "models: request failed" + echo "$out" | jq -e '.models[] | select(.group == "Other" and (.key | endswith(".goose_db_version")))' >/dev/null \ + || fail "models: auto-discovered goose_db_version missing: $out" + rk="$(echo "$out" | jq -r '.models[] | select(.group == "Other" and (.key | endswith(".duckgres_reshard_operations"))) | .key')" + [ -n "$rk" ] || fail "models: auto-discovered duckgres_reshard_operations missing" + curl -fsS -H "$H" "$API/api/v1/models/$rk" | jq -e '.columns | index("org_id") != null' >/dev/null \ + || fail "models: auto table listing lacks org_id column" + log "models explorer auto-discovery OK ($rk)" } # ---- admin console: identity + live state + auth gate ---------------------- @@ -2266,6 +2276,9 @@ reshard_validation() { # cnpg-org currently on shard-001 -d "$body" "$API/api/v1/orgs/$1/reshard")" [ "$code" = "400" ] || fail "reshard validation: body $body -> $code, want 400" done + # Global list (Reshards nav page): envelope across all orgs. + curl -fsS -H "$H" "$API/api/v1/reshards" | jq -e '.operations | type == "array"' >/dev/null \ + || fail "reshard validation: global list envelope wrong" log "reshard validation OK" }