Skip to content

Commit 63aec48

Browse files
authored
warehouse: make duckling_name mandatory (NOT NULL) + editable in admin UI (#858)
* warehouse: make duckling_name mandatory (NOT NULL) and editable in admin UI The duckling_name column (warehouse → Duckling CR link) was nullable and only populated by best-effort backfill/set-on-create. Make it authoritative: - Migration 000012: re-backfill any NULL/empty to lower(org_id), then ALTER COLUMN duckling_name SET NOT NULL. GORM tag gains not null; migration parity/version test bumped 11→12. - App-level non-empty guard (NOT NULL still allows ''): provisionWarehouse rejects an empty duckling_name on create; putManagedWarehouse rejects blanking it on edit. - Editable via the admin UI: duckling_name added to the warehouse PUT allow-list and surfaced as a required field in the org warehouse editor, so corrections flow through the audited PUT path. * tests/seeds: set duckling_name for NOT NULL; fix v8-upgrade baseline configstore-integration-tests was red on the new NOT NULL constraint: - The dev/kind/tenant-isolation config-store seed SQL INSERTed warehouses without duckling_name → not-null violation. Set it to the org_id (= the canonical CR name) in all four seed files. - TestConfigStoreSQLMigrationsUpgradeVersion8Schema pinned its pre-upgrade baseline at goose v8 but only deleted versions 9-11, leaving 12 recorded (latest read 12, want 8). Drop the NOT NULL and delete version 12 too so the baseline is a true v8 and migration 000012 re-applies on upgrade. - Set duckling_name in the two Postgres warehouse test fixtures for clarity.
1 parent 048a94a commit 63aec48

12 files changed

Lines changed: 73 additions & 11 deletions

File tree

controlplane/admin/api.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -456,6 +456,7 @@ type apiHandler struct {
456456
type managedWarehouseRequest struct {
457457
Image string `json:"image"`
458458
DuckLakeVersion string `json:"ducklake_version"`
459+
DucklingName string `json:"duckling_name"`
459460
WarehouseDatabase configstore.ManagedWarehouseDatabase `json:"warehouse_database"`
460461
MetadataStore configstore.ManagedWarehouseMetadataStore `json:"metadata_store"`
461462
PgBouncer configstore.ManagedWarehousePgBouncer `json:"pgbouncer"`
@@ -818,6 +819,9 @@ func (h *apiHandler) putManagedWarehouse(c *gin.Context) {
818819
if err := json.Unmarshal(body, w); err != nil {
819820
return warehouseBadRequestError{err}
820821
}
822+
if w.DucklingName == "" {
823+
return warehouseBadRequestError{errors.New("duckling_name cannot be empty")}
824+
}
821825
cfgView := &configstore.ManagedWarehouseConfig{
822826
OrgID: orgID,
823827
WarehouseDatabase: w.WarehouseDatabase,

controlplane/admin/api_test.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,8 @@ func newTestAPIRouter(store apiStore) *gin.Engine {
222222

223223
func seedOrgWithWarehouse(store *fakeAPIStore, name string) {
224224
warehouse := &configstore.ManagedWarehouse{
225-
OrgID: name,
225+
OrgID: name,
226+
DucklingName: name,
226227
WarehouseDatabase: configstore.ManagedWarehouseDatabase{
227228
Endpoint: fmt.Sprintf("%s.cluster.example", name),
228229
Port: 5432,
@@ -611,6 +612,7 @@ func TestPutWarehouseUpsertsForExistingOrg(t *testing.T) {
611612
router := newTestAPIRouter(store)
612613

613614
body := []byte(`{
615+
"duckling_name": "analytics",
614616
"warehouse_database": {
615617
"endpoint": "analytics.cluster.example",
616618
"port": 5432
@@ -864,6 +866,7 @@ func TestPutWarehouseRejectsSecretRefsWithoutWorkerNamespace(t *testing.T) {
864866
router := newTestAPIRouter(store)
865867

866868
body := []byte(`{
869+
"duckling_name": "analytics",
867870
"worker_identity": {
868871
"iam_role_arn": "arn:aws:iam::123456789012:role/analytics-worker"
869872
},
@@ -930,6 +933,7 @@ func TestPutWarehouseAllowsCustomProvisioningStates(t *testing.T) {
930933
router := newTestAPIRouter(store)
931934

932935
body := []byte(`{
936+
"duckling_name": "analytics",
933937
"state": "awaiting-human-approval",
934938
"metadata_store_state": "vendor-pending",
935939
"s3_state": "bucket-handshake",
@@ -965,6 +969,7 @@ func TestPutWarehouseRejectsCrossTenantSecretRefs(t *testing.T) {
965969
router := newTestAPIRouter(store)
966970

967971
body := []byte(`{
972+
"duckling_name": "analytics",
968973
"worker_identity": {
969974
"namespace": "tenant-analytics"
970975
},
@@ -994,6 +999,7 @@ func TestPutWarehouseRejectsSecretRefWithoutExplicitNamespace(t *testing.T) {
994999
router := newTestAPIRouter(store)
9951000

9961001
body := []byte(`{
1002+
"duckling_name": "analytics",
9971003
"worker_identity": {
9981004
"namespace": "tenant-analytics"
9991005
},
@@ -1080,6 +1086,7 @@ func TestPutWarehouseRejectsSecretReferenceWithoutOrgPrefix(t *testing.T) {
10801086
router := newTestAPIRouter(store)
10811087

10821088
body := []byte(`{
1089+
"duckling_name": "analytics",
10831090
"worker_identity": {
10841091
"namespace": "tenant-a"
10851092
},

controlplane/admin/ui/src/pages/OrgDetail.tsx

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -288,14 +288,16 @@ function WarehousePanel({
288288
const update = useUpdateWarehouse(orgId);
289289
const [image, setImage] = useState("");
290290
const [version, setVersion] = useState("");
291+
const [ducklingNameInput, setDucklingNameInput] = useState("");
291292
const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
292293

293294
useEffect(() => {
294295
if (data) {
295296
setImage(data.image ?? "");
296297
setVersion(data.ducklake_version ?? "");
298+
setDucklingNameInput(data.duckling_name || ducklingName(orgId));
297299
}
298-
}, [data]);
300+
}, [data, orgId]);
299301

300302
const notFound = error instanceof ApiError && error.status === 404;
301303
const missing = notFound || !data;
@@ -308,10 +310,20 @@ function WarehousePanel({
308310
? `Duckling unhealthy (state: ${data?.state})`
309311
: null;
310312

313+
const ducklingNameEmpty = ducklingNameInput.trim() === "";
314+
311315
const save = async () => {
312316
setMsg(null);
317+
if (ducklingNameEmpty) {
318+
setMsg({ kind: "err", text: "Duckling name is required." });
319+
return;
320+
}
313321
try {
314-
await update.mutateAsync({ image, ducklake_version: version });
322+
await update.mutateAsync({
323+
image,
324+
ducklake_version: version,
325+
duckling_name: ducklingNameInput,
326+
});
315327
setMsg({ kind: "ok", text: "Saved." });
316328
} catch (e) {
317329
setMsg({ kind: "err", text: e instanceof Error ? e.message : "Save failed" });
@@ -374,6 +386,13 @@ function WarehousePanel({
374386
{/* Editable pinning */}
375387
<div className="space-y-3 border-t border-border pt-3">
376388
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Pinning</p>
389+
<Field label="Duckling name">
390+
<Input
391+
value={ducklingNameInput}
392+
onChange={(e) => setDucklingNameInput(e.target.value)}
393+
className="font-mono text-xs"
394+
/>
395+
</Field>
377396
<Field label="Worker image">
378397
<Input value={image} onChange={(e) => setImage(e.target.value)} className="font-mono text-xs" />
379398
</Field>
@@ -387,7 +406,7 @@ function WarehousePanel({
387406
</Field>
388407
<div className="flex items-center gap-3">
389408
<AdminGate>
390-
<Button size="sm" onClick={save} disabled={update.isPending}>
409+
<Button size="sm" onClick={save} disabled={update.isPending || ducklingNameEmpty}>
391410
<Save className="h-4 w-4" /> {update.isPending ? "Saving…" : "Save warehouse"}
392411
</Button>
393412
</AdminGate>
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
-- +goose Up
2+
3+
-- Make the Duckling CR name mandatory. Re-run the lower(org_id) backfill first
4+
-- as a safety net (mirrors migration 000008) so any row left NULL/empty is
5+
-- filled before the NOT NULL constraint is applied, then enforce NOT NULL.
6+
UPDATE duckgres_managed_warehouses
7+
SET duckling_name = lower(org_id)
8+
WHERE duckling_name IS NULL OR duckling_name = '';
9+
10+
ALTER TABLE duckgres_managed_warehouses ALTER COLUMN duckling_name SET NOT NULL;
11+
12+
-- +goose Down
13+
14+
ALTER TABLE duckgres_managed_warehouses ALTER COLUMN duckling_name DROP NOT NULL;

controlplane/configstore/models.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -290,7 +290,7 @@ type ManagedWarehouse struct {
290290
// DucklingName is the k8s Duckling CR name; canonical = lowercased org ID.
291291
// Authoritative, not derived — stored explicitly so lookups stop guessing
292292
// it from the org ID (see provisioner.ducklingName for the canonical form).
293-
DucklingName string `gorm:"size:255" json:"duckling_name"`
293+
DucklingName string `gorm:"size:255;not null" json:"duckling_name"`
294294

295295
WarehouseDatabase ManagedWarehouseDatabase `gorm:"embedded;embeddedPrefix:warehouse_database_" json:"warehouse_database"`
296296
MetadataStore ManagedWarehouseMetadataStore `gorm:"embedded;embeddedPrefix:metadata_store_" json:"metadata_store"`

controlplane/provisioning/api.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,10 @@ func (h *handler) provisionWarehouse(c *gin.Context) {
274274
// we inline it rather than import provisioner into this package.
275275
DucklingName: strings.ToLower(orgID),
276276
}
277+
if warehouse.DucklingName == "" {
278+
c.JSON(http.StatusBadRequest, gin.H{"error": "duckling_name is required"})
279+
return
280+
}
277281
if icebergEnabled {
278282
warehouse.Iceberg = configstore.ManagedWarehouseIceberg{
279283
Enabled: true,

k8s/kind/config-store-seed.sql

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ SET updated_at = NOW();
55

66
INSERT INTO duckgres_managed_warehouses (
77
org_id,
8+
duckling_name,
89
warehouse_database_endpoint,
910
warehouse_database_port,
1011
metadata_store_kind,
@@ -45,6 +46,7 @@ INSERT INTO duckgres_managed_warehouses (
4546
updated_at
4647
)
4748
VALUES (
49+
'local',
4850
'local',
4951
'local-warehouse-db',
5052
5432,

k8s/kind/config-store.seed.sql

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ SET updated_at = NOW();
55

66
INSERT INTO duckgres_managed_warehouses (
77
org_id,
8+
duckling_name,
89
image,
910
warehouse_database_endpoint,
1011
warehouse_database_port,
@@ -46,6 +47,7 @@ INSERT INTO duckgres_managed_warehouses (
4647
updated_at
4748
)
4849
VALUES (
50+
'local',
4951
'local',
5052
'',
5153
'local-warehouse-db',

k8s/local-config-store.seed.sql

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ SET updated_at = NOW();
55

66
INSERT INTO duckgres_managed_warehouses (
77
org_id,
8+
duckling_name,
89
warehouse_database_endpoint,
910
warehouse_database_port,
1011
metadata_store_kind,
@@ -45,6 +46,7 @@ INSERT INTO duckgres_managed_warehouses (
4546
updated_at
4647
)
4748
VALUES (
49+
'local',
4850
'local',
4951
'host.docker.internal',
5052
35434,

tests/configstore/managed_warehouse_postgres_test.go

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@ func TestManagedWarehouseConfigStorePostgres(t *testing.T) {
3131
t.Fatalf("create user: %v", err)
3232
}
3333
if err := store.DB().Create(&configstore.ManagedWarehouse{
34-
OrgID: "analytics",
34+
OrgID: "analytics",
35+
DucklingName: "analytics",
3536
WarehouseDatabase: configstore.ManagedWarehouseDatabase{
3637
Endpoint: "analytics.cluster.example",
3738
Port: 5432,
@@ -84,8 +85,9 @@ func TestManagedWarehouseConfigStorePostgres(t *testing.T) {
8485
t.Fatalf("create cleanup org: %v", err)
8586
}
8687
if err := store.DB().Create(&configstore.ManagedWarehouse{
87-
OrgID: "cleanup",
88-
State: configstore.ManagedWarehouseStateReady,
88+
OrgID: "cleanup",
89+
DucklingName: "cleanup",
90+
State: configstore.ManagedWarehouseStateReady,
8991
}).Error; err != nil {
9092
t.Fatalf("create cleanup warehouse: %v", err)
9193
}

0 commit comments

Comments
 (0)