From 38a4dc3276bca6d077751bf2ccb2e19f1d207247 Mon Sep 17 00:00:00 2001 From: Abby Bangser Date: Mon, 27 Apr 2026 21:04:53 +0100 Subject: [PATCH 1/6] fix: upgrade crossplane init command to work with v2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Panic fix: xrd.Spec.ClaimNames.Kind → nil-safe with fallback to xrd.Spec.Names.Kind. In Crossplane v2, claimNames is replaced by scope: Namespaced on the XRD spec, so ClaimNames is always nil. 2. Dependency fidelity: Replaced runtime.DefaultUnstructuredConverter.ToUnstructured(xrd) with a new readFileAsUnstructured() that reads the raw YAML. This preserves v2-specific fields like scope: Namespaced that aren't in the v1 Go struct, and drops spurious fields that the typed conversion was injecting (metadata.creationTimestamp: null, status.controllers.*). 3. Nil guard in generateCRDFromXRD: Added a check for version.Schema == nil before accessing version.Schema.OpenAPIV3Schema. --- cmd/init_crossplane_promise.go | 29 +++++++++++++++---- .../promise.yaml | 9 ------ .../promise.yaml | 9 ------ .../promise.yaml | 9 ------ .../dependencies.yaml | 9 ------ .../crossplane/expected-output/promise.yaml | 9 ------ 6 files changed, 24 insertions(+), 50 deletions(-) diff --git a/cmd/init_crossplane_promise.go b/cmd/init_crossplane_promise.go index 32f476f0..49d7bd13 100644 --- a/cmd/init_crossplane_promise.go +++ b/cmd/init_crossplane_promise.go @@ -14,7 +14,6 @@ import ( corev1 "k8s.io/api/core/v1" apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/runtime" "sigs.k8s.io/yaml" xrdv1 "github.com/crossplane/crossplane/apis/apiextensions/v1" @@ -81,11 +80,11 @@ func InitCrossplanePromise(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to generate dependencies from compositions: %w", err) } } - objMap, err := runtime.DefaultUnstructuredConverter.ToUnstructured(xrd) + xrdRaw, err := readFileAsUnstructured(xrdPath) if err != nil { - return fmt.Errorf("Failed to parse xrd: %w", err) + return fmt.Errorf("failed to parse xrd: %w", err) } - dependencies = append(dependencies, v1alpha1.Dependency{Unstructured: unstructured.Unstructured{Object: objMap}}) + dependencies = append(dependencies, v1alpha1.Dependency{Unstructured: unstructured.Unstructured{Object: xrdRaw}}) } xrdStoredVersion, err := getXRDStoredVersion(xrd) @@ -98,6 +97,11 @@ func InitCrossplanePromise(cmd *cobra.Command, args []string) error { return err } + xrdKind := xrd.Spec.Names.Kind + if xrd.Spec.ClaimNames != nil { + xrdKind = xrd.Spec.ClaimNames.Kind + } + pipelines := generateResourceConfigurePipelines(crossplaneContainerName, crossplaneContainerImage, []corev1.EnvVar{ { Name: XRD_GROUP_ENV_VAR, @@ -109,7 +113,7 @@ func InitCrossplanePromise(cmd *cobra.Command, args []string) error { }, { Name: XRD_KIND_ENV_VAR, - Value: xrd.Spec.ClaimNames.Kind, + Value: xrdKind, }, }) @@ -181,6 +185,9 @@ func generateDependenciesFromCompositions(compositionsFilepath string) ([]v1alph } func generateCRDFromXRD(version *xrdv1.CompositeResourceDefinitionVersion) (*apiextensionsv1.CustomResourceDefinition, error) { + if version.Schema == nil { + return nil, fmt.Errorf("version %s has no schema", version.Name) + } schemaRaw := version.Schema.OpenAPIV3Schema schema := &apiextensionsv1.JSONSchemaProps{} if err := yaml.Unmarshal(schemaRaw.Raw, schema); err != nil { @@ -237,3 +244,15 @@ func getXRD(path string) (*xrdv1.CompositeResourceDefinition, error) { return xrd, nil } + +func readFileAsUnstructured(path string) (map[string]any, error) { + contents, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read file %s: %w", path, err) + } + var obj map[string]any + if err := yaml.Unmarshal(contents, &obj); err != nil { + return nil, fmt.Errorf("failed to unmarshal file %s: %w", path, err) + } + return obj, nil +} diff --git a/test/assets/crossplane/expected-output-with-compositions/promise.yaml b/test/assets/crossplane/expected-output-with-compositions/promise.yaml index 216fbda5..79745950 100644 --- a/test/assets/crossplane/expected-output-with-compositions/promise.yaml +++ b/test/assets/crossplane/expected-output-with-compositions/promise.yaml @@ -253,7 +253,6 @@ spec: - apiVersion: apiextensions.crossplane.io/v1 kind: CompositeResourceDefinition metadata: - creationTimestamp: null name: xobjectstorages.awsblueprints.io spec: claimNames: @@ -324,14 +323,6 @@ spec: type: object type: object served: true - status: - controllers: - compositeResourceClaimType: - apiVersion: "" - kind: "" - compositeResourceType: - apiVersion: "" - kind: "" destinationSelectors: - matchLabels: crossplane: enabled diff --git a/test/assets/crossplane/expected-output-with-empty-openAPIV3Schema/promise.yaml b/test/assets/crossplane/expected-output-with-empty-openAPIV3Schema/promise.yaml index 1829bdad..0cadd72b 100644 --- a/test/assets/crossplane/expected-output-with-empty-openAPIV3Schema/promise.yaml +++ b/test/assets/crossplane/expected-output-with-empty-openAPIV3Schema/promise.yaml @@ -130,7 +130,6 @@ spec: - apiVersion: apiextensions.crossplane.io/v1 kind: CompositeResourceDefinition metadata: - creationTimestamp: null name: xobjectstorages.awsblueprints.io spec: claimNames: @@ -151,14 +150,6 @@ spec: openAPIV3Schema: type: object served: true - status: - controllers: - compositeResourceClaimType: - apiVersion: "" - kind: "" - compositeResourceType: - apiVersion: "" - kind: "" destinationSelectors: - matchLabels: crossplane: enabled diff --git a/test/assets/crossplane/expected-output-with-no-spec-properties/promise.yaml b/test/assets/crossplane/expected-output-with-no-spec-properties/promise.yaml index a5de13e9..5bc4bdf2 100644 --- a/test/assets/crossplane/expected-output-with-no-spec-properties/promise.yaml +++ b/test/assets/crossplane/expected-output-with-no-spec-properties/promise.yaml @@ -139,7 +139,6 @@ spec: - apiVersion: apiextensions.crossplane.io/v1 kind: CompositeResourceDefinition metadata: - creationTimestamp: null name: xobjectstorages.awsblueprints.io spec: claimNames: @@ -172,14 +171,6 @@ spec: type: object type: object served: true - status: - controllers: - compositeResourceClaimType: - apiVersion: "" - kind: "" - compositeResourceType: - apiVersion: "" - kind: "" destinationSelectors: - matchLabels: crossplane: enabled diff --git a/test/assets/crossplane/expected-output-with-split/dependencies.yaml b/test/assets/crossplane/expected-output-with-split/dependencies.yaml index d72ca18b..5c1635ef 100644 --- a/test/assets/crossplane/expected-output-with-split/dependencies.yaml +++ b/test/assets/crossplane/expected-output-with-split/dependencies.yaml @@ -1,7 +1,6 @@ - apiVersion: apiextensions.crossplane.io/v1 kind: CompositeResourceDefinition metadata: - creationTimestamp: null name: xobjectstorages.awsblueprints.io spec: claimNames: @@ -72,11 +71,3 @@ type: object type: object served: true - status: - controllers: - compositeResourceClaimType: - apiVersion: "" - kind: "" - compositeResourceType: - apiVersion: "" - kind: "" diff --git a/test/assets/crossplane/expected-output/promise.yaml b/test/assets/crossplane/expected-output/promise.yaml index 171bce39..069a65e0 100644 --- a/test/assets/crossplane/expected-output/promise.yaml +++ b/test/assets/crossplane/expected-output/promise.yaml @@ -177,7 +177,6 @@ spec: - apiVersion: apiextensions.crossplane.io/v1 kind: CompositeResourceDefinition metadata: - creationTimestamp: null name: xobjectstorages.awsblueprints.io spec: claimNames: @@ -248,14 +247,6 @@ spec: type: object type: object served: true - status: - controllers: - compositeResourceClaimType: - apiVersion: "" - kind: "" - compositeResourceType: - apiVersion: "" - kind: "" destinationSelectors: - matchLabels: crossplane: enabled From b4b2c36de09e516f1d32ab709d9a14347d6c1cb1 Mon Sep 17 00:00:00 2001 From: Abby Bangser Date: Mon, 27 Apr 2026 21:26:31 +0100 Subject: [PATCH 2/6] chore: add more gitignore dirs for easier local dev --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index d3f7ee6f..5186850e 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,6 @@ bin/ .idea/ dist/ +.gocache/ +.claude/ +.tmp/ From 090b87a2272f17cd2437df6a3308f53a977f8def Mon Sep 17 00:00:00 2001 From: Abby Bangser Date: Mon, 27 Apr 2026 21:29:38 +0100 Subject: [PATCH 3/6] feat: allow for functions to package with crossplane promises --- cmd/init_crossplane_promise.go | 31 +- .../promise.yaml | 282 ++++++++++++++++++ test/assets/crossplane/function.yaml | 6 + test/init_crossplane_promise_test.go | 23 ++ 4 files changed, 333 insertions(+), 9 deletions(-) create mode 100644 test/assets/crossplane/expected-output-with-functions/promise.yaml create mode 100644 test/assets/crossplane/function.yaml diff --git a/cmd/init_crossplane_promise.go b/cmd/init_crossplane_promise.go index 49d7bd13..b22c4c32 100644 --- a/cmd/init_crossplane_promise.go +++ b/cmd/init_crossplane_promise.go @@ -49,6 +49,7 @@ var ( xrdPath string compositions string + functions string skipDependencies bool ) @@ -56,6 +57,7 @@ func init() { initCmd.AddCommand(crossplanePromiseCmd) crossplanePromiseCmd.Flags().StringVarP(&xrdPath, "xrd", "x", "", "Filepath to the XRD file") crossplanePromiseCmd.Flags().StringVarP(&compositions, "compositions", "c", "", "Filepath to the Compositions file. Can contain a single Composition or multiple Compositions.") + crossplanePromiseCmd.Flags().StringVarP(&functions, "functions", "f", "", "Filepath to the Functions file. Can contain a single Function or multiple Functions.") crossplanePromiseCmd.Flags().BoolVarP(&skipDependencies, "skip-dependencies", "s", false, "Skip generating dependencies. For when the XRD and Compositions are already deployed to Crossplane") crossplanePromiseCmd.MarkFlagRequired("xrd") } @@ -74,11 +76,19 @@ func InitCrossplanePromise(cmd *cobra.Command, args []string) error { var dependencies []v1alpha1.Dependency if !skipDependencies { + if functions != "" { + functionDeps, err := generateDependenciesFromFile(functions) + if err != nil { + return fmt.Errorf("failed to generate dependencies from functions: %w", err) + } + dependencies = append(dependencies, functionDeps...) + } if compositions != "" { - dependencies, err = generateDependenciesFromCompositions(compositions) + compDeps, err := generateDependenciesFromFile(compositions) if err != nil { return fmt.Errorf("failed to generate dependencies from compositions: %w", err) } + dependencies = append(dependencies, compDeps...) } xrdRaw, err := readFileAsUnstructured(xrdPath) if err != nil { @@ -119,6 +129,9 @@ func InitCrossplanePromise(cmd *cobra.Command, args []string) error { exampleResource := generateExampleResource(crd) flags := fmt.Sprintf("--xrd %s", xrdPath) + if functions != "" { + flags = fmt.Sprintf("%s --functions %s", flags, functions) + } if compositions != "" { flags = fmt.Sprintf("%s --compositions %s", flags, compositions) } @@ -162,26 +175,26 @@ func generateExampleResource(crd *apiextensionsv1.CustomResourceDefinition) *uns } } -func generateDependenciesFromCompositions(compositionsFilepath string) ([]v1alpha1.Dependency, error) { - contents, err := os.ReadFile(compositionsFilepath) +func generateDependenciesFromFile(filepath string) ([]v1alpha1.Dependency, error) { + contents, err := os.ReadFile(filepath) if err != nil { - return nil, fmt.Errorf("failed to read file %s: %w", compositions, err) + return nil, fmt.Errorf("failed to read file %s: %w", filepath, err) } - var compositions []v1alpha1.Dependency + var deps []v1alpha1.Dependency docs := goyaml.NewDecoder(bytes.NewReader(contents)) for { - var comp map[string]any - if err := docs.Decode(&comp); err != nil { + var obj map[string]any + if err := docs.Decode(&obj); err != nil { if err.Error() == "EOF" { break } log.Fatalf("Failed to decode YAML: %v", err) } - compositions = append(compositions, v1alpha1.Dependency{Unstructured: unstructured.Unstructured{Object: comp}}) + deps = append(deps, v1alpha1.Dependency{Unstructured: unstructured.Unstructured{Object: obj}}) } - return compositions, nil + return deps, nil } func generateCRDFromXRD(version *xrdv1.CompositeResourceDefinitionVersion) (*apiextensionsv1.CustomResourceDefinition, error) { diff --git a/test/assets/crossplane/expected-output-with-functions/promise.yaml b/test/assets/crossplane/expected-output-with-functions/promise.yaml new file mode 100644 index 00000000..f16a4cd6 --- /dev/null +++ b/test/assets/crossplane/expected-output-with-functions/promise.yaml @@ -0,0 +1,282 @@ +apiVersion: platform.kratix.io/v1alpha1 +kind: Promise +metadata: + creationTimestamp: null + labels: + kratix.io/promise-version: v0.0.1 + name: s3buckets +spec: + api: + apiVersion: apiextensions.k8s.io/v1 + kind: CustomResourceDefinition + metadata: + creationTimestamp: null + name: s3buckets.syntasso.io + spec: + group: syntasso.io + names: + kind: S3Bucket + plural: s3buckets + singular: s3bucket + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + properties: + spec: + default: {} + description: ObjectStorageSpec defines the desired state of ObjectStorage + properties: + compositeDeletePolicy: + default: Background + enum: + - Background + - Foreground + type: string + compositionRef: + properties: + name: + type: string + required: + - name + type: object + compositionRevisionRef: + properties: + name: + type: string + required: + - name + type: object + compositionRevisionSelector: + properties: + matchLabels: + additionalProperties: + type: string + type: object + required: + - matchLabels + type: object + compositionSelector: + properties: + matchLabels: + additionalProperties: + type: string + type: object + required: + - matchLabels + type: object + compositionUpdatePolicy: + enum: + - Automatic + - Manual + type: string + publishConnectionDetailsTo: + properties: + configRef: + default: + name: default + properties: + name: + type: string + type: object + metadata: + properties: + annotations: + additionalProperties: + type: string + type: object + labels: + additionalProperties: + type: string + type: object + type: + type: string + type: object + name: + type: string + required: + - name + type: object + resourceConfig: + description: ResourceConfig defines general properties of this + AWS resource. + properties: + deletionPolicy: + description: Defaults to Delete + enum: + - Delete + - Orphan + type: string + name: + description: Set the name of this resource in AWS to the value + provided by this field. + type: string + providerConfigName: + type: string + region: + type: string + tags: + items: + properties: + key: + type: string + value: + type: string + required: + - key + - value + type: object + type: array + required: + - providerConfigName + - region + - tags + type: object + resourceRef: + properties: + apiVersion: + type: string + kind: + type: string + name: + type: string + required: + - apiVersion + - kind + - name + type: object + writeConnectionSecretToRef: + properties: + name: + type: string + required: + - name + type: object + required: + - resourceConfig + type: object + status: + description: ObjectStorageStatus defines the observed state of ObjectStorage + properties: + bucketArn: + type: string + bucketName: + type: string + type: object + type: object + served: true + storage: true + status: + acceptedNames: + kind: "" + plural: "" + conditions: null + storedVersions: null + dependencies: + - apiVersion: pkg.crossplane.io/v1 + kind: Function + metadata: + name: function-patch-and-transform + spec: + package: xpkg.upbound.io/crossplane-contrib/function-patch-and-transform:v0.1.4 + - apiVersion: apiextensions.crossplane.io/v1 + kind: CompositeResourceDefinition + metadata: + name: xobjectstorages.awsblueprints.io + spec: + claimNames: + kind: ObjectStorage + plural: objectstorages + connectionSecretKeys: + - region + - bucket-name + - s3-put-policy + group: awsblueprints.io + names: + kind: XObjectStorage + plural: xobjectstorages + versions: + - name: v1alpha1 + referenceable: true + schema: + openAPIV3Schema: + properties: + spec: + description: ObjectStorageSpec defines the desired state of ObjectStorage + properties: + resourceConfig: + description: ResourceConfig defines general properties of this + AWS resource. + properties: + deletionPolicy: + description: Defaults to Delete + enum: + - Delete + - Orphan + type: string + name: + description: Set the name of this resource in AWS to the value + provided by this field. + type: string + providerConfigName: + type: string + region: + type: string + tags: + items: + properties: + key: + type: string + value: + type: string + required: + - key + - value + type: object + type: array + required: + - providerConfigName + - region + - tags + type: object + required: + - resourceConfig + type: object + status: + description: ObjectStorageStatus defines the observed state of ObjectStorage + properties: + bucketArn: + type: string + bucketName: + type: string + type: object + type: object + served: true + destinationSelectors: + - matchLabels: + crossplane: enabled + workflows: + config: {} + promise: {} + resource: + configure: + - apiVersion: platform.kratix.io/v1alpha1 + kind: Pipeline + metadata: + name: instance-configure + spec: + containers: + - env: + - name: XRD_GROUP + value: awsblueprints.io + - name: XRD_VERSION + value: v1alpha1 + - name: XRD_KIND + value: ObjectStorage + image: ghcr.io/syntasso/kratix-cli/from-api-to-crossplane-claim:v0.2.2 + name: from-api-to-crossplane-claim +status: + workflows: 0 + workflowsFailed: 0 + workflowsSucceeded: 0 diff --git a/test/assets/crossplane/function.yaml b/test/assets/crossplane/function.yaml new file mode 100644 index 00000000..d7f5ec93 --- /dev/null +++ b/test/assets/crossplane/function.yaml @@ -0,0 +1,6 @@ +apiVersion: pkg.crossplane.io/v1 +kind: Function +metadata: + name: function-patch-and-transform +spec: + package: xpkg.upbound.io/crossplane-contrib/function-patch-and-transform:v0.1.4 diff --git a/test/init_crossplane_promise_test.go b/test/init_crossplane_promise_test.go index 09dde909..40a70b19 100644 --- a/test/init_crossplane_promise_test.go +++ b/test/init_crossplane_promise_test.go @@ -133,6 +133,29 @@ var _ = Describe("InitCrossplanePromise", func() { }) }) + Describe("with --functions", func() { + BeforeEach(func() { + r.flags["--functions"] = "assets/crossplane/function.yaml" + session = r.run(initPromiseCmd...) + generatedFiles = getFiles(workingDir) + }) + + It("generates the expected files", func() { + files := []string{"promise.yaml", "example-resource.yaml", "README.md"} + Expect(generatedFiles).To(ConsistOf(files)) + expectFilesEqual(workingDir, "assets/crossplane/expected-output-with-functions", []string{"promise.yaml"}) + expectFilesEqual(workingDir, "assets/crossplane/expected-output", []string{"example-resource.yaml"}) + Expect(cat(filepath.Join(workingDir, "README.md"))).To(SatisfyAll( + ContainSubstring("kratix init crossplane-promise s3buckets"), + ContainSubstring("--functions assets/crossplane/function.yaml"), + ContainSubstring("--group syntasso.io --kind S3Bucket"), + )) + Expect(session.Out).To(SatisfyAll( + gbytes.Say(`Promise generated successfully.`), + )) + }) + }) + Describe("with --skip-dependencies", func() { BeforeEach(func() { r.flags["--skip-dependencies"] = "" From e3f10b3bfc4e89615eac46f8df63b7a6afe79649 Mon Sep 17 00:00:00 2001 From: Abby Bangser Date: Mon, 27 Apr 2026 22:17:37 +0100 Subject: [PATCH 4/6] feat: alert to missing defaults when transforming crossplane xrd to kratix api --- cmd/init_crossplane_promise.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/cmd/init_crossplane_promise.go b/cmd/init_crossplane_promise.go index b22c4c32..7e15339c 100644 --- a/cmd/init_crossplane_promise.go +++ b/cmd/init_crossplane_promise.go @@ -128,6 +128,7 @@ func InitCrossplanePromise(cmd *cobra.Command, args []string) error { }) exampleResource := generateExampleResource(crd) + warnFieldsWithoutDefaults(crd) flags := fmt.Sprintf("--xrd %s", xrdPath) if functions != "" { flags = fmt.Sprintf("%s --functions %s", flags, functions) @@ -175,6 +176,15 @@ func generateExampleResource(crd *apiextensionsv1.CustomResourceDefinition) *uns } } +func warnFieldsWithoutDefaults(crd *apiextensionsv1.CustomResourceDefinition) { + crdSpec := crd.Spec.Versions[0].Schema.OpenAPIV3Schema.Properties["spec"] + for _, field := range crdSpec.Required { + if crdSpec.Properties[field].Default == nil { + fmt.Printf("warning: required field %q has no default value; a default is required for top level required fields in a CRD\nYou will need to add a default to make the Promise API valid.\n", field) + } + } +} + func generateDependenciesFromFile(filepath string) ([]v1alpha1.Dependency, error) { contents, err := os.ReadFile(filepath) if err != nil { From ccfcbaa6fe7b471ffd24115c556856b565e22632 Mon Sep 17 00:00:00 2001 From: Abby Bangser Date: Mon, 27 Apr 2026 23:32:27 +0100 Subject: [PATCH 5/6] fix: remove hardcoded crossplane claim fields from generated CRD The mandatoryAdditionalClaimFields map injected Crossplane-internal plumbing fields (compositionRef, compositeDeletePolicy, etc.) into every generated Kratix API CRD spec, which are not user-facing API fields and should not appear in the Promise API. Co-Authored-By: Claude Sonnet 4.6 --- cmd/crossplane.go | 98 ------------------- cmd/init_crossplane_promise.go | 5 - .../promise.yaml | 90 ----------------- .../promise.yaml | 91 ----------------- .../promise.yaml | 90 ----------------- .../promise.yaml | 91 ----------------- .../promise.yaml | 90 ----------------- .../expected-output-with-split/api.yaml | 90 ----------------- .../crossplane/expected-output/promise.yaml | 90 ----------------- 9 files changed, 735 deletions(-) delete mode 100644 cmd/crossplane.go diff --git a/cmd/crossplane.go b/cmd/crossplane.go deleted file mode 100644 index 7ed05e1f..00000000 --- a/cmd/crossplane.go +++ /dev/null @@ -1,98 +0,0 @@ -package cmd - -import ( - apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" -) - -var mandatoryAdditionalClaimFields = map[string]apiextensionsv1.JSONSchemaProps{ - "compositeDeletePolicy": { - Type: "string", - Enum: []apiextensionsv1.JSON{{Raw: []byte(`"Background"`)}, {Raw: []byte(`"Foreground"`)}}, - Default: &apiextensionsv1.JSON{Raw: []byte(`"Background"`)}, - }, - "compositionRef": { - Type: "object", - Properties: map[string]apiextensionsv1.JSONSchemaProps{ - "name": {Type: "string"}, - }, - Required: []string{"name"}, - }, - "compositionRevisionRef": { - Type: "object", - Properties: map[string]apiextensionsv1.JSONSchemaProps{ - "name": {Type: "string"}, - }, - Required: []string{"name"}, - }, - "compositionRevisionSelector": { - Type: "object", - Properties: map[string]apiextensionsv1.JSONSchemaProps{ - "matchLabels": { - Type: "object", - AdditionalProperties: &apiextensionsv1.JSONSchemaPropsOrBool{Schema: &apiextensionsv1.JSONSchemaProps{Type: "string"}}, - }, - }, - Required: []string{"matchLabels"}, - }, - "compositionSelector": { - Type: "object", - Properties: map[string]apiextensionsv1.JSONSchemaProps{ - "matchLabels": { - Type: "object", - AdditionalProperties: &apiextensionsv1.JSONSchemaPropsOrBool{Schema: &apiextensionsv1.JSONSchemaProps{Type: "string"}}, - }, - }, - Required: []string{"matchLabels"}, - }, - "compositionUpdatePolicy": { - Type: "string", - Enum: []apiextensionsv1.JSON{ - {Raw: []byte(`"Automatic"`)}, - {Raw: []byte(`"Manual"`)}, - }, - }, - "publishConnectionDetailsTo": { - Type: "object", - Properties: map[string]apiextensionsv1.JSONSchemaProps{ - "configRef": { - Type: "object", - Properties: map[string]apiextensionsv1.JSONSchemaProps{ - "name": {Type: "string"}, - }, - Default: &apiextensionsv1.JSON{Raw: []byte(`{"name": "default"}`)}, - }, - "metadata": { - Type: "object", - Properties: map[string]apiextensionsv1.JSONSchemaProps{ - "annotations": { - Type: "object", - AdditionalProperties: &apiextensionsv1.JSONSchemaPropsOrBool{Schema: &apiextensionsv1.JSONSchemaProps{Type: "string"}}, - }, - "labels": { - Type: "object", - AdditionalProperties: &apiextensionsv1.JSONSchemaPropsOrBool{Schema: &apiextensionsv1.JSONSchemaProps{Type: "string"}}, - }, - "type": {Type: "string"}, - }, - }, - "name": {Type: "string"}, - }, - Required: []string{"name"}, - }, - "resourceRef": { - Type: "object", - Properties: map[string]apiextensionsv1.JSONSchemaProps{ - "apiVersion": {Type: "string"}, - "kind": {Type: "string"}, - "name": {Type: "string"}, - }, - Required: []string{"apiVersion", "kind", "name"}, - }, - "writeConnectionSecretToRef": { - Type: "object", - Properties: map[string]apiextensionsv1.JSONSchemaProps{ - "name": {Type: "string"}, - }, - Required: []string{"name"}, - }, -} diff --git a/cmd/init_crossplane_promise.go b/cmd/init_crossplane_promise.go index 7e15339c..8d3b5e5b 100644 --- a/cmd/init_crossplane_promise.go +++ b/cmd/init_crossplane_promise.go @@ -4,7 +4,6 @@ import ( "bytes" "fmt" "log" - "maps" "os" "strings" @@ -232,11 +231,7 @@ func generateCRDFromXRD(version *xrdv1.CompositeResourceDefinitionVersion) (*api } specProp := schema.Properties["spec"] specProp.Default = &apiextensionsv1.JSON{Raw: []byte(`{}`)} - if specProp.Properties == nil { - specProp.Properties = make(map[string]apiextensionsv1.JSONSchemaProps) - } schema.Properties["spec"] = specProp - maps.Copy(schema.Properties["spec"].Properties, mandatoryAdditionalClaimFields) crd.Spec.Versions = []apiextensionsv1.CustomResourceDefinitionVersion{ { diff --git a/test/assets/crossplane/expected-output-with-compositions/promise.yaml b/test/assets/crossplane/expected-output-with-compositions/promise.yaml index 79745950..60eb1bc1 100644 --- a/test/assets/crossplane/expected-output-with-compositions/promise.yaml +++ b/test/assets/crossplane/expected-output-with-compositions/promise.yaml @@ -28,76 +28,6 @@ spec: default: {} description: ObjectStorageSpec defines the desired state of ObjectStorage properties: - compositeDeletePolicy: - default: Background - enum: - - Background - - Foreground - type: string - compositionRef: - properties: - name: - type: string - required: - - name - type: object - compositionRevisionRef: - properties: - name: - type: string - required: - - name - type: object - compositionRevisionSelector: - properties: - matchLabels: - additionalProperties: - type: string - type: object - required: - - matchLabels - type: object - compositionSelector: - properties: - matchLabels: - additionalProperties: - type: string - type: object - required: - - matchLabels - type: object - compositionUpdatePolicy: - enum: - - Automatic - - Manual - type: string - publishConnectionDetailsTo: - properties: - configRef: - default: - name: default - properties: - name: - type: string - type: object - metadata: - properties: - annotations: - additionalProperties: - type: string - type: object - labels: - additionalProperties: - type: string - type: object - type: - type: string - type: object - name: - type: string - required: - - name - type: object resourceConfig: description: ResourceConfig defines general properties of this AWS resource. @@ -133,26 +63,6 @@ spec: - region - tags type: object - resourceRef: - properties: - apiVersion: - type: string - kind: - type: string - name: - type: string - required: - - apiVersion - - kind - - name - type: object - writeConnectionSecretToRef: - properties: - name: - type: string - required: - - name - type: object required: - resourceConfig type: object diff --git a/test/assets/crossplane/expected-output-with-empty-openAPIV3Schema/promise.yaml b/test/assets/crossplane/expected-output-with-empty-openAPIV3Schema/promise.yaml index 0cadd72b..6bd4eb86 100644 --- a/test/assets/crossplane/expected-output-with-empty-openAPIV3Schema/promise.yaml +++ b/test/assets/crossplane/expected-output-with-empty-openAPIV3Schema/promise.yaml @@ -26,97 +26,6 @@ spec: properties: spec: default: {} - properties: - compositeDeletePolicy: - default: Background - enum: - - Background - - Foreground - type: string - compositionRef: - properties: - name: - type: string - required: - - name - type: object - compositionRevisionRef: - properties: - name: - type: string - required: - - name - type: object - compositionRevisionSelector: - properties: - matchLabels: - additionalProperties: - type: string - type: object - required: - - matchLabels - type: object - compositionSelector: - properties: - matchLabels: - additionalProperties: - type: string - type: object - required: - - matchLabels - type: object - compositionUpdatePolicy: - enum: - - Automatic - - Manual - type: string - publishConnectionDetailsTo: - properties: - configRef: - default: - name: default - properties: - name: - type: string - type: object - metadata: - properties: - annotations: - additionalProperties: - type: string - type: object - labels: - additionalProperties: - type: string - type: object - type: - type: string - type: object - name: - type: string - required: - - name - type: object - resourceRef: - properties: - apiVersion: - type: string - kind: - type: string - name: - type: string - required: - - apiVersion - - kind - - name - type: object - writeConnectionSecretToRef: - properties: - name: - type: string - required: - - name - type: object type: object served: true storage: true diff --git a/test/assets/crossplane/expected-output-with-functions/promise.yaml b/test/assets/crossplane/expected-output-with-functions/promise.yaml index f16a4cd6..317611ee 100644 --- a/test/assets/crossplane/expected-output-with-functions/promise.yaml +++ b/test/assets/crossplane/expected-output-with-functions/promise.yaml @@ -28,76 +28,6 @@ spec: default: {} description: ObjectStorageSpec defines the desired state of ObjectStorage properties: - compositeDeletePolicy: - default: Background - enum: - - Background - - Foreground - type: string - compositionRef: - properties: - name: - type: string - required: - - name - type: object - compositionRevisionRef: - properties: - name: - type: string - required: - - name - type: object - compositionRevisionSelector: - properties: - matchLabels: - additionalProperties: - type: string - type: object - required: - - matchLabels - type: object - compositionSelector: - properties: - matchLabels: - additionalProperties: - type: string - type: object - required: - - matchLabels - type: object - compositionUpdatePolicy: - enum: - - Automatic - - Manual - type: string - publishConnectionDetailsTo: - properties: - configRef: - default: - name: default - properties: - name: - type: string - type: object - metadata: - properties: - annotations: - additionalProperties: - type: string - type: object - labels: - additionalProperties: - type: string - type: object - type: - type: string - type: object - name: - type: string - required: - - name - type: object resourceConfig: description: ResourceConfig defines general properties of this AWS resource. @@ -133,26 +63,6 @@ spec: - region - tags type: object - resourceRef: - properties: - apiVersion: - type: string - kind: - type: string - name: - type: string - required: - - apiVersion - - kind - - name - type: object - writeConnectionSecretToRef: - properties: - name: - type: string - required: - - name - type: object required: - resourceConfig type: object diff --git a/test/assets/crossplane/expected-output-with-no-spec-properties/promise.yaml b/test/assets/crossplane/expected-output-with-no-spec-properties/promise.yaml index 5bc4bdf2..4baa4d8f 100644 --- a/test/assets/crossplane/expected-output-with-no-spec-properties/promise.yaml +++ b/test/assets/crossplane/expected-output-with-no-spec-properties/promise.yaml @@ -26,97 +26,6 @@ spec: properties: spec: default: {} - properties: - compositeDeletePolicy: - default: Background - enum: - - Background - - Foreground - type: string - compositionRef: - properties: - name: - type: string - required: - - name - type: object - compositionRevisionRef: - properties: - name: - type: string - required: - - name - type: object - compositionRevisionSelector: - properties: - matchLabels: - additionalProperties: - type: string - type: object - required: - - matchLabels - type: object - compositionSelector: - properties: - matchLabels: - additionalProperties: - type: string - type: object - required: - - matchLabels - type: object - compositionUpdatePolicy: - enum: - - Automatic - - Manual - type: string - publishConnectionDetailsTo: - properties: - configRef: - default: - name: default - properties: - name: - type: string - type: object - metadata: - properties: - annotations: - additionalProperties: - type: string - type: object - labels: - additionalProperties: - type: string - type: object - type: - type: string - type: object - name: - type: string - required: - - name - type: object - resourceRef: - properties: - apiVersion: - type: string - kind: - type: string - name: - type: string - required: - - apiVersion - - kind - - name - type: object - writeConnectionSecretToRef: - properties: - name: - type: string - required: - - name - type: object type: object status: description: ObjectStorageStatus defines the observed state of ObjectStorage diff --git a/test/assets/crossplane/expected-output-with-skip-dependencies/promise.yaml b/test/assets/crossplane/expected-output-with-skip-dependencies/promise.yaml index e17e415d..9cbd32e3 100644 --- a/test/assets/crossplane/expected-output-with-skip-dependencies/promise.yaml +++ b/test/assets/crossplane/expected-output-with-skip-dependencies/promise.yaml @@ -28,76 +28,6 @@ spec: default: {} description: ObjectStorageSpec defines the desired state of ObjectStorage properties: - compositeDeletePolicy: - default: Background - enum: - - Background - - Foreground - type: string - compositionRef: - properties: - name: - type: string - required: - - name - type: object - compositionRevisionRef: - properties: - name: - type: string - required: - - name - type: object - compositionRevisionSelector: - properties: - matchLabels: - additionalProperties: - type: string - type: object - required: - - matchLabels - type: object - compositionSelector: - properties: - matchLabels: - additionalProperties: - type: string - type: object - required: - - matchLabels - type: object - compositionUpdatePolicy: - enum: - - Automatic - - Manual - type: string - publishConnectionDetailsTo: - properties: - configRef: - default: - name: default - properties: - name: - type: string - type: object - metadata: - properties: - annotations: - additionalProperties: - type: string - type: object - labels: - additionalProperties: - type: string - type: object - type: - type: string - type: object - name: - type: string - required: - - name - type: object resourceConfig: description: ResourceConfig defines general properties of this AWS resource. @@ -133,26 +63,6 @@ spec: - region - tags type: object - resourceRef: - properties: - apiVersion: - type: string - kind: - type: string - name: - type: string - required: - - apiVersion - - kind - - name - type: object - writeConnectionSecretToRef: - properties: - name: - type: string - required: - - name - type: object required: - resourceConfig type: object diff --git a/test/assets/crossplane/expected-output-with-split/api.yaml b/test/assets/crossplane/expected-output-with-split/api.yaml index ec8e13b8..971a4af5 100644 --- a/test/assets/crossplane/expected-output-with-split/api.yaml +++ b/test/assets/crossplane/expected-output-with-split/api.yaml @@ -19,76 +19,6 @@ spec: default: {} description: ObjectStorageSpec defines the desired state of ObjectStorage properties: - compositeDeletePolicy: - default: Background - enum: - - Background - - Foreground - type: string - compositionRef: - properties: - name: - type: string - required: - - name - type: object - compositionRevisionRef: - properties: - name: - type: string - required: - - name - type: object - compositionRevisionSelector: - properties: - matchLabels: - additionalProperties: - type: string - type: object - required: - - matchLabels - type: object - compositionSelector: - properties: - matchLabels: - additionalProperties: - type: string - type: object - required: - - matchLabels - type: object - compositionUpdatePolicy: - enum: - - Automatic - - Manual - type: string - publishConnectionDetailsTo: - properties: - configRef: - default: - name: default - properties: - name: - type: string - type: object - metadata: - properties: - annotations: - additionalProperties: - type: string - type: object - labels: - additionalProperties: - type: string - type: object - type: - type: string - type: object - name: - type: string - required: - - name - type: object resourceConfig: description: ResourceConfig defines general properties of this AWS resource. @@ -124,26 +54,6 @@ spec: - region - tags type: object - resourceRef: - properties: - apiVersion: - type: string - kind: - type: string - name: - type: string - required: - - apiVersion - - kind - - name - type: object - writeConnectionSecretToRef: - properties: - name: - type: string - required: - - name - type: object required: - resourceConfig type: object diff --git a/test/assets/crossplane/expected-output/promise.yaml b/test/assets/crossplane/expected-output/promise.yaml index 069a65e0..bbd36f50 100644 --- a/test/assets/crossplane/expected-output/promise.yaml +++ b/test/assets/crossplane/expected-output/promise.yaml @@ -28,76 +28,6 @@ spec: default: {} description: ObjectStorageSpec defines the desired state of ObjectStorage properties: - compositeDeletePolicy: - default: Background - enum: - - Background - - Foreground - type: string - compositionRef: - properties: - name: - type: string - required: - - name - type: object - compositionRevisionRef: - properties: - name: - type: string - required: - - name - type: object - compositionRevisionSelector: - properties: - matchLabels: - additionalProperties: - type: string - type: object - required: - - matchLabels - type: object - compositionSelector: - properties: - matchLabels: - additionalProperties: - type: string - type: object - required: - - matchLabels - type: object - compositionUpdatePolicy: - enum: - - Automatic - - Manual - type: string - publishConnectionDetailsTo: - properties: - configRef: - default: - name: default - properties: - name: - type: string - type: object - metadata: - properties: - annotations: - additionalProperties: - type: string - type: object - labels: - additionalProperties: - type: string - type: object - type: - type: string - type: object - name: - type: string - required: - - name - type: object resourceConfig: description: ResourceConfig defines general properties of this AWS resource. @@ -133,26 +63,6 @@ spec: - region - tags type: object - resourceRef: - properties: - apiVersion: - type: string - kind: - type: string - name: - type: string - required: - - apiVersion - - kind - - name - type: object - writeConnectionSecretToRef: - properties: - name: - type: string - required: - - name - type: object required: - resourceConfig type: object From 9c0668d443419c16bdbb69845b44779018bca07c Mon Sep 17 00:00:00 2001 From: Abby Bangser Date: Mon, 27 Apr 2026 23:33:15 +0100 Subject: [PATCH 6/6] fix: set spec default only when required fields can be satisfied Previously the generated CRD always set spec.default to {}, which is invalid when the spec has required fields that lack their own defaults. buildSpecDefault now inspects required fields: if all have defaults, it returns a default object containing those values; if any required field has no default, it returns nil so no spec-level default is set. Co-Authored-By: Claude Sonnet 4.6 --- cmd/init_crossplane_promise.go | 28 +++++- cmd/init_crossplane_promise_unit_test.go | 89 +++++++++++++++++++ .../promise.yaml | 1 - .../promise.yaml | 1 - .../promise.yaml | 1 - .../expected-output-with-split/api.yaml | 1 - .../crossplane/expected-output/promise.yaml | 1 - 7 files changed, 116 insertions(+), 6 deletions(-) create mode 100644 cmd/init_crossplane_promise_unit_test.go diff --git a/cmd/init_crossplane_promise.go b/cmd/init_crossplane_promise.go index 8d3b5e5b..de36305c 100644 --- a/cmd/init_crossplane_promise.go +++ b/cmd/init_crossplane_promise.go @@ -2,6 +2,7 @@ package cmd import ( "bytes" + "encoding/json" "fmt" "log" "os" @@ -230,7 +231,9 @@ func generateCRDFromXRD(version *xrdv1.CompositeResourceDefinitionVersion) (*api schema.Properties = make(map[string]apiextensionsv1.JSONSchemaProps) } specProp := schema.Properties["spec"] - specProp.Default = &apiextensionsv1.JSON{Raw: []byte(`{}`)} + if d := buildSpecDefault(specProp); d != nil { + specProp.Default = d + } schema.Properties["spec"] = specProp crd.Spec.Versions = []apiextensionsv1.CustomResourceDefinitionVersion{ @@ -249,6 +252,29 @@ func generateCRDFromXRD(version *xrdv1.CompositeResourceDefinitionVersion) (*api return crd, nil } +// buildSpecDefault returns a JSON default for the spec field that satisfies its required +// constraints. Required fields are included using their own default values. If any required +// field has no default, nil is returned and no spec-level default is set. +func buildSpecDefault(specProp apiextensionsv1.JSONSchemaProps) *apiextensionsv1.JSON { + defaultMap := map[string]any{} + for _, field := range specProp.Required { + prop, ok := specProp.Properties[field] + if !ok || prop.Default == nil { + return nil + } + var val any + if err := json.Unmarshal(prop.Default.Raw, &val); err != nil { + return nil + } + defaultMap[field] = val + } + raw, err := json.Marshal(defaultMap) + if err != nil { + return nil + } + return &apiextensionsv1.JSON{Raw: raw} +} + func getXRD(path string) (*xrdv1.CompositeResourceDefinition, error) { xrd := &xrdv1.CompositeResourceDefinition{} contents, err := os.ReadFile(path) diff --git a/cmd/init_crossplane_promise_unit_test.go b/cmd/init_crossplane_promise_unit_test.go new file mode 100644 index 00000000..e28bd14f --- /dev/null +++ b/cmd/init_crossplane_promise_unit_test.go @@ -0,0 +1,89 @@ +package cmd + +import ( + "encoding/json" + "testing" + + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" +) + +func TestBuildSpecDefault(t *testing.T) { + tests := []struct { + name string + specProp apiextensionsv1.JSONSchemaProps + wantDefault map[string]any // nil means no default should be set + }{ + { + name: "no required fields and no defaults", + specProp: apiextensionsv1.JSONSchemaProps{ + Type: "object", + Properties: map[string]apiextensionsv1.JSONSchemaProps{ + "image": {Type: "string"}, + }, + }, + wantDefault: map[string]any{}, + }, + { + name: "defaults but no required fields", + specProp: apiextensionsv1.JSONSchemaProps{ + Type: "object", + Properties: map[string]apiextensionsv1.JSONSchemaProps{ + "image": { + Type: "string", + Default: &apiextensionsv1.JSON{Raw: []byte(`"nginx"`)}, + }, + }, + }, + wantDefault: map[string]any{}, + }, + { + name: "required fields that all have defaults", + specProp: apiextensionsv1.JSONSchemaProps{ + Type: "object", + Properties: map[string]apiextensionsv1.JSONSchemaProps{ + "image": { + Type: "string", + Default: &apiextensionsv1.JSON{Raw: []byte(`"nginx"`)}, + }, + }, + Required: []string{"image"}, + }, + wantDefault: map[string]any{"image": "nginx"}, + }, + { + name: "required fields that do not have defaults", + specProp: apiextensionsv1.JSONSchemaProps{ + Type: "object", + Properties: map[string]apiextensionsv1.JSONSchemaProps{ + "resourceConfig": {Type: "object"}, + }, + Required: []string{"resourceConfig"}, + }, + wantDefault: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := buildSpecDefault(tt.specProp) + if tt.wantDefault == nil { + if got != nil { + t.Fatalf("expected no spec.default, got %s", string(got.Raw)) + } + return + } + if got == nil { + t.Fatal("expected spec.default to be set, got nil") + } + var gotMap map[string]any + if err := json.Unmarshal(got.Raw, &gotMap); err != nil { + t.Fatalf("failed to unmarshal result: %v", err) + } + gotJSON, _ := json.Marshal(gotMap) + wantJSON, _ := json.Marshal(tt.wantDefault) + if string(gotJSON) != string(wantJSON) { + t.Fatalf("expected spec.default %s, got %s", wantJSON, gotJSON) + } + }) + } +} diff --git a/test/assets/crossplane/expected-output-with-compositions/promise.yaml b/test/assets/crossplane/expected-output-with-compositions/promise.yaml index 60eb1bc1..db5ed7c5 100644 --- a/test/assets/crossplane/expected-output-with-compositions/promise.yaml +++ b/test/assets/crossplane/expected-output-with-compositions/promise.yaml @@ -25,7 +25,6 @@ spec: openAPIV3Schema: properties: spec: - default: {} description: ObjectStorageSpec defines the desired state of ObjectStorage properties: resourceConfig: diff --git a/test/assets/crossplane/expected-output-with-functions/promise.yaml b/test/assets/crossplane/expected-output-with-functions/promise.yaml index 317611ee..ed604f05 100644 --- a/test/assets/crossplane/expected-output-with-functions/promise.yaml +++ b/test/assets/crossplane/expected-output-with-functions/promise.yaml @@ -25,7 +25,6 @@ spec: openAPIV3Schema: properties: spec: - default: {} description: ObjectStorageSpec defines the desired state of ObjectStorage properties: resourceConfig: diff --git a/test/assets/crossplane/expected-output-with-skip-dependencies/promise.yaml b/test/assets/crossplane/expected-output-with-skip-dependencies/promise.yaml index 9cbd32e3..d66f9ef0 100644 --- a/test/assets/crossplane/expected-output-with-skip-dependencies/promise.yaml +++ b/test/assets/crossplane/expected-output-with-skip-dependencies/promise.yaml @@ -25,7 +25,6 @@ spec: openAPIV3Schema: properties: spec: - default: {} description: ObjectStorageSpec defines the desired state of ObjectStorage properties: resourceConfig: diff --git a/test/assets/crossplane/expected-output-with-split/api.yaml b/test/assets/crossplane/expected-output-with-split/api.yaml index 971a4af5..066d5931 100644 --- a/test/assets/crossplane/expected-output-with-split/api.yaml +++ b/test/assets/crossplane/expected-output-with-split/api.yaml @@ -16,7 +16,6 @@ spec: openAPIV3Schema: properties: spec: - default: {} description: ObjectStorageSpec defines the desired state of ObjectStorage properties: resourceConfig: diff --git a/test/assets/crossplane/expected-output/promise.yaml b/test/assets/crossplane/expected-output/promise.yaml index bbd36f50..e3a8cb63 100644 --- a/test/assets/crossplane/expected-output/promise.yaml +++ b/test/assets/crossplane/expected-output/promise.yaml @@ -25,7 +25,6 @@ spec: openAPIV3Schema: properties: spec: - default: {} description: ObjectStorageSpec defines the desired state of ObjectStorage properties: resourceConfig: