Skip to content
This repository was archived by the owner on Jun 26, 2026. It is now read-only.

Commit fd1e947

Browse files
authored
Merge pull request #40 from cozystack/fix/backup-dropdowns-from-crd-annotations
fix(console): reattach backup dropdown options from CRD annotations
2 parents 1d9d26b + 80fcfb2 commit fd1e947

3 files changed

Lines changed: 201 additions & 2 deletions

File tree

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import { describe, it, expect } from "vitest"
2+
import { graftOptionSources } from "./crd-option-sources.ts"
3+
4+
interface SchemaNode {
5+
type?: string
6+
properties?: Record<string, SchemaNode>
7+
"x-cozystack-options"?: { source: string }
8+
}
9+
10+
describe("graftOptionSources", () => {
11+
it("grafts a source onto a nested field (applicationRef.kind)", () => {
12+
const spec: SchemaNode = {
13+
type: "object",
14+
properties: {
15+
applicationRef: {
16+
type: "object",
17+
properties: {
18+
kind: { type: "string" },
19+
name: { type: "string" },
20+
},
21+
},
22+
},
23+
}
24+
25+
const out = graftOptionSources(spec, {
26+
"options.cozystack.io/source.applicationRef.kind": "appkind",
27+
}) as SchemaNode
28+
29+
expect(out.properties?.applicationRef?.properties?.kind?.["x-cozystack-options"]).toEqual({
30+
source: "appkind",
31+
})
32+
// Sibling left untouched.
33+
expect(
34+
out.properties?.applicationRef?.properties?.name?.["x-cozystack-options"],
35+
).toBeUndefined()
36+
})
37+
38+
it("grafts a source onto a top-level spec field (backupClassName)", () => {
39+
const spec: SchemaNode = {
40+
type: "object",
41+
properties: { backupClassName: { type: "string" } },
42+
}
43+
44+
const out = graftOptionSources(spec, {
45+
"options.cozystack.io/source.backupClassName": "backupclass",
46+
}) as SchemaNode
47+
48+
expect(out.properties?.backupClassName?.["x-cozystack-options"]).toEqual({
49+
source: "backupclass",
50+
})
51+
})
52+
53+
it("applies every source annotation on a CRD (BackupJob shape)", () => {
54+
const spec: SchemaNode = {
55+
type: "object",
56+
properties: {
57+
applicationRef: { type: "object", properties: { kind: { type: "string" } } },
58+
planRef: { type: "object", properties: { name: { type: "string" } } },
59+
backupClassName: { type: "string" },
60+
},
61+
}
62+
63+
const out = graftOptionSources(spec, {
64+
"controller-gen.kubebuilder.io/version": "v0.16.4",
65+
"options.cozystack.io/source.applicationRef.kind": "appkind",
66+
"options.cozystack.io/source.planRef.name": "plan",
67+
"options.cozystack.io/source.backupClassName": "backupclass",
68+
}) as SchemaNode
69+
70+
expect(out.properties?.applicationRef?.properties?.kind?.["x-cozystack-options"]).toEqual({
71+
source: "appkind",
72+
})
73+
expect(out.properties?.planRef?.properties?.name?.["x-cozystack-options"]).toEqual({
74+
source: "plan",
75+
})
76+
expect(out.properties?.backupClassName?.["x-cozystack-options"]).toEqual({
77+
source: "backupclass",
78+
})
79+
})
80+
81+
it("ignores annotations without the option-source prefix", () => {
82+
const spec: SchemaNode = {
83+
type: "object",
84+
properties: { backupClassName: { type: "string" } },
85+
}
86+
87+
const out = graftOptionSources(spec, {
88+
"controller-gen.kubebuilder.io/version": "v0.16.4",
89+
"options.cozystack.io/other": "noise",
90+
}) as SchemaNode
91+
92+
expect(out.properties?.backupClassName?.["x-cozystack-options"]).toBeUndefined()
93+
})
94+
95+
it("is a no-op for a path that does not exist in the schema", () => {
96+
const spec: SchemaNode = {
97+
type: "object",
98+
properties: { backupClassName: { type: "string" } },
99+
}
100+
101+
expect(() =>
102+
graftOptionSources(spec, {
103+
"options.cozystack.io/source.missing.field": "appkind",
104+
}),
105+
).not.toThrow()
106+
})
107+
108+
it("does not mutate the input schema, including nested nodes", () => {
109+
const spec: SchemaNode = {
110+
type: "object",
111+
properties: {
112+
backupClassName: { type: "string" },
113+
applicationRef: { type: "object", properties: { kind: { type: "string" } } },
114+
},
115+
}
116+
117+
graftOptionSources(spec, {
118+
"options.cozystack.io/source.backupClassName": "backupclass",
119+
"options.cozystack.io/source.applicationRef.kind": "appkind",
120+
})
121+
122+
// Both a top-level field and a nested one must be left untouched, so a
123+
// future shallow/partial clone that corrupts deep nodes can't slip through.
124+
expect(spec.properties?.backupClassName?.["x-cozystack-options"]).toBeUndefined()
125+
expect(spec.properties?.applicationRef?.properties?.kind?.["x-cozystack-options"]).toBeUndefined()
126+
})
127+
128+
it("returns the schema unchanged when there are no annotations", () => {
129+
const spec: SchemaNode = {
130+
type: "object",
131+
properties: { backupClassName: { type: "string" } },
132+
}
133+
134+
expect(graftOptionSources(spec, undefined)).toBe(spec)
135+
expect(graftOptionSources(spec, {})).toEqual(spec)
136+
})
137+
})
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/**
2+
* Graft the `x-cozystack-options` vendor keyword back onto a CRD's spec schema
3+
* from its metadata annotations.
4+
*
5+
* The backups.cozystack.io CRDs cannot carry `x-cozystack-options` in their
6+
* OpenAPI schema: apiextensions `JSONSchemaProps` is a closed struct that only
7+
* preserves `x-kubernetes-*` extensions, and server-side apply rejects any
8+
* other `x-` key outright. The field→source mapping the dropdowns need is
9+
* therefore stored in CRD metadata annotations (which the apiserver does
10+
* preserve) and reattached client-side here, so DynamicOptionsWidget — which
11+
* reads `x-cozystack-options.source` off the schema node — keeps working.
12+
*
13+
* Annotation contract (emitted by kubebuilder markers on the Go types):
14+
* options.cozystack.io/source.<dotted spec-relative path> = <option source>
15+
* e.g. `options.cozystack.io/source.applicationRef.kind: appkind`.
16+
*/
17+
18+
const SOURCE_ANNOTATION_PREFIX = "options.cozystack.io/source."
19+
20+
/**
21+
* Return a copy of `specSchema` with `x-cozystack-options: { source }` set on
22+
* every field named by a matching annotation. The input is not mutated. Paths
23+
* that do not resolve in the schema are skipped silently.
24+
*/
25+
export function graftOptionSources(
26+
specSchema: unknown,
27+
annotations: Record<string, string> | undefined | null,
28+
): unknown {
29+
if (!specSchema || typeof specSchema !== "object" || !annotations) {
30+
return specSchema
31+
}
32+
33+
const cloned = structuredClone(specSchema)
34+
35+
for (const [key, source] of Object.entries(annotations)) {
36+
if (!source || !key.startsWith(SOURCE_ANNOTATION_PREFIX)) continue
37+
const path = key.slice(SOURCE_ANNOTATION_PREFIX.length).split(".")
38+
applySource(cloned, path, source)
39+
}
40+
41+
return cloned
42+
}
43+
44+
function applySource(specSchema: unknown, path: string[], source: string): void {
45+
let node = specSchema
46+
for (const segment of path) {
47+
const properties = (node as { properties?: Record<string, unknown> })?.properties
48+
if (!properties || typeof properties !== "object" || !(segment in properties)) {
49+
return
50+
}
51+
node = properties[segment]
52+
}
53+
if (node && typeof node === "object") {
54+
;(node as Record<string, unknown>)["x-cozystack-options"] = { source }
55+
}
56+
}

apps/console/src/lib/use-crd-schema.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import { useK8sGet } from "@cozystack/k8s-client"
2+
import { graftOptionSources } from "./crd-option-sources.ts"
23

34
interface CRDVersion {
45
name: string
56
storage?: boolean
67
schema?: {
78
openAPIV3Schema?: {
89
properties?: {
9-
spec?: any
10+
spec?: unknown
1011
}
1112
}
1213
}
@@ -17,6 +18,7 @@ interface CRD {
1718
kind: string
1819
metadata: {
1920
name: string
21+
annotations?: Record<string, string>
2022
}
2123
spec: {
2224
group: string
@@ -40,7 +42,11 @@ export function useCRDSchema(crdName: string) {
4042

4143
// Use the storage version (authoritative) or fall back to the first listed version
4244
const version = crd?.spec?.versions?.find((v) => v.storage) ?? crd?.spec?.versions?.[0]
43-
const schema = version?.schema?.openAPIV3Schema?.properties?.spec
45+
const specSchema = version?.schema?.openAPIV3Schema?.properties?.spec
46+
47+
// Reattach the x-cozystack-options dropdown hints the apiserver strips from
48+
// the CRD schema; they are carried in metadata annotations instead.
49+
const schema = graftOptionSources(specSchema, crd?.metadata?.annotations)
4450

4551
return {
4652
schema: schema ? JSON.stringify(schema) : null,

0 commit comments

Comments
 (0)