From 80fcfb261805c078f6bfc84684830982c6334faf Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin Date: Thu, 4 Jun 2026 12:28:44 +0300 Subject: [PATCH] fix(console): reattach backup dropdown options from CRD annotations The backup forms drive the backupClassName, planRef.name and applicationRef.kind dropdowns from the CRD's x-cozystack-options keyword via DynamicOptionsWidget. apiextensions strips that vendor extension from the live CRD schema, so the keyword never reached the form and the dropdowns degraded to plain text inputs. The backups.cozystack.io CRDs now carry the field-to-source mapping in metadata.annotations. Graft the x-cozystack-options keyword back onto the spec schema from those annotations in useCRDSchema, so DynamicOptionsWidget binds again. Assisted-By: Claude Signed-off-by: Aleksei Sviridkin --- .../src/lib/crd-option-sources.test.ts | 137 ++++++++++++++++++ apps/console/src/lib/crd-option-sources.ts | 56 +++++++ apps/console/src/lib/use-crd-schema.ts | 10 +- 3 files changed, 201 insertions(+), 2 deletions(-) create mode 100644 apps/console/src/lib/crd-option-sources.test.ts create mode 100644 apps/console/src/lib/crd-option-sources.ts diff --git a/apps/console/src/lib/crd-option-sources.test.ts b/apps/console/src/lib/crd-option-sources.test.ts new file mode 100644 index 0000000..1cd9b5f --- /dev/null +++ b/apps/console/src/lib/crd-option-sources.test.ts @@ -0,0 +1,137 @@ +import { describe, it, expect } from "vitest" +import { graftOptionSources } from "./crd-option-sources.ts" + +interface SchemaNode { + type?: string + properties?: Record + "x-cozystack-options"?: { source: string } +} + +describe("graftOptionSources", () => { + it("grafts a source onto a nested field (applicationRef.kind)", () => { + const spec: SchemaNode = { + type: "object", + properties: { + applicationRef: { + type: "object", + properties: { + kind: { type: "string" }, + name: { type: "string" }, + }, + }, + }, + } + + const out = graftOptionSources(spec, { + "options.cozystack.io/source.applicationRef.kind": "appkind", + }) as SchemaNode + + expect(out.properties?.applicationRef?.properties?.kind?.["x-cozystack-options"]).toEqual({ + source: "appkind", + }) + // Sibling left untouched. + expect( + out.properties?.applicationRef?.properties?.name?.["x-cozystack-options"], + ).toBeUndefined() + }) + + it("grafts a source onto a top-level spec field (backupClassName)", () => { + const spec: SchemaNode = { + type: "object", + properties: { backupClassName: { type: "string" } }, + } + + const out = graftOptionSources(spec, { + "options.cozystack.io/source.backupClassName": "backupclass", + }) as SchemaNode + + expect(out.properties?.backupClassName?.["x-cozystack-options"]).toEqual({ + source: "backupclass", + }) + }) + + it("applies every source annotation on a CRD (BackupJob shape)", () => { + const spec: SchemaNode = { + type: "object", + properties: { + applicationRef: { type: "object", properties: { kind: { type: "string" } } }, + planRef: { type: "object", properties: { name: { type: "string" } } }, + backupClassName: { type: "string" }, + }, + } + + const out = graftOptionSources(spec, { + "controller-gen.kubebuilder.io/version": "v0.16.4", + "options.cozystack.io/source.applicationRef.kind": "appkind", + "options.cozystack.io/source.planRef.name": "plan", + "options.cozystack.io/source.backupClassName": "backupclass", + }) as SchemaNode + + expect(out.properties?.applicationRef?.properties?.kind?.["x-cozystack-options"]).toEqual({ + source: "appkind", + }) + expect(out.properties?.planRef?.properties?.name?.["x-cozystack-options"]).toEqual({ + source: "plan", + }) + expect(out.properties?.backupClassName?.["x-cozystack-options"]).toEqual({ + source: "backupclass", + }) + }) + + it("ignores annotations without the option-source prefix", () => { + const spec: SchemaNode = { + type: "object", + properties: { backupClassName: { type: "string" } }, + } + + const out = graftOptionSources(spec, { + "controller-gen.kubebuilder.io/version": "v0.16.4", + "options.cozystack.io/other": "noise", + }) as SchemaNode + + expect(out.properties?.backupClassName?.["x-cozystack-options"]).toBeUndefined() + }) + + it("is a no-op for a path that does not exist in the schema", () => { + const spec: SchemaNode = { + type: "object", + properties: { backupClassName: { type: "string" } }, + } + + expect(() => + graftOptionSources(spec, { + "options.cozystack.io/source.missing.field": "appkind", + }), + ).not.toThrow() + }) + + it("does not mutate the input schema, including nested nodes", () => { + const spec: SchemaNode = { + type: "object", + properties: { + backupClassName: { type: "string" }, + applicationRef: { type: "object", properties: { kind: { type: "string" } } }, + }, + } + + graftOptionSources(spec, { + "options.cozystack.io/source.backupClassName": "backupclass", + "options.cozystack.io/source.applicationRef.kind": "appkind", + }) + + // Both a top-level field and a nested one must be left untouched, so a + // future shallow/partial clone that corrupts deep nodes can't slip through. + expect(spec.properties?.backupClassName?.["x-cozystack-options"]).toBeUndefined() + expect(spec.properties?.applicationRef?.properties?.kind?.["x-cozystack-options"]).toBeUndefined() + }) + + it("returns the schema unchanged when there are no annotations", () => { + const spec: SchemaNode = { + type: "object", + properties: { backupClassName: { type: "string" } }, + } + + expect(graftOptionSources(spec, undefined)).toBe(spec) + expect(graftOptionSources(spec, {})).toEqual(spec) + }) +}) diff --git a/apps/console/src/lib/crd-option-sources.ts b/apps/console/src/lib/crd-option-sources.ts new file mode 100644 index 0000000..f2f7c0f --- /dev/null +++ b/apps/console/src/lib/crd-option-sources.ts @@ -0,0 +1,56 @@ +/** + * Graft the `x-cozystack-options` vendor keyword back onto a CRD's spec schema + * from its metadata annotations. + * + * The backups.cozystack.io CRDs cannot carry `x-cozystack-options` in their + * OpenAPI schema: apiextensions `JSONSchemaProps` is a closed struct that only + * preserves `x-kubernetes-*` extensions, and server-side apply rejects any + * other `x-` key outright. The field→source mapping the dropdowns need is + * therefore stored in CRD metadata annotations (which the apiserver does + * preserve) and reattached client-side here, so DynamicOptionsWidget — which + * reads `x-cozystack-options.source` off the schema node — keeps working. + * + * Annotation contract (emitted by kubebuilder markers on the Go types): + * options.cozystack.io/source. =