-
Notifications
You must be signed in to change notification settings - Fork 3
feat(xrd): Add new subcommand crossplane xrd convert
#12
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
tampakrap
wants to merge
1
commit into
crossplane:main
Choose a base branch
from
tampakrap:theo/feat_xrd_convert_v2
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+354
−0
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,202 @@ | ||
| /* | ||
| Copyright 2026 The Crossplane Authors. | ||
|
|
||
| Licensed under the Apache License, Version 2.0 (the "License"); | ||
| you may not use this file except in compliance with the License. | ||
| You may obtain a copy of the License at | ||
|
|
||
| http://www.apache.org/licenses/LICENSE-2.0 | ||
|
|
||
| Unless required by applicable law or agreed to in writing, software | ||
| distributed under the License is distributed on an "AS IS" BASIS, | ||
| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| See the License for the specific language governing permissions and | ||
| limitations under the License. | ||
| */ | ||
|
|
||
| package xrd | ||
|
|
||
| import ( | ||
| "bytes" | ||
| "path/filepath" | ||
|
|
||
| "github.com/alecthomas/kong" | ||
| "github.com/spf13/afero" | ||
| extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" | ||
| "sigs.k8s.io/yaml" | ||
|
|
||
| "github.com/crossplane/crossplane-runtime/v2/pkg/errors" | ||
| "github.com/crossplane/crossplane-runtime/v2/pkg/xcrd" | ||
|
|
||
| apiextensionsv1 "github.com/crossplane/crossplane/apis/v2/apiextensions/v1" | ||
|
|
||
| commonIO "github.com/crossplane/cli/v2/cmd/crossplane/convert/io" | ||
| ) | ||
|
|
||
| type convertCmd struct { | ||
| // Arguments. | ||
| InputFile string `arg:"" default:"-" help:"The XRD YAML file to be converted. If not specified or '-', stdin will be used." optional:"" predictor:"file" type:"path"` | ||
|
|
||
| // Output flags. OutputFile and OutputDir are mutually exclusive; when | ||
| // neither is set the converted CRDs are emitted on stdout as a multi-doc | ||
| // YAML stream. | ||
| OutputFile string `help:"The file to write the generated CRD YAML to. Legacy XRDs produce a multi-doc YAML stream (XR CRD + Claim CRD)." placeholder:"PATH" predictor:"file" short:"o" type:"path" xor:"output"` | ||
| OutputDir string `help:"A directory to write the generated CRDs to. Each CRD is written to a separate file named <crd.Name>.yaml." placeholder:"DIR" predictor:"directory" type:"path" xor:"output"` | ||
|
|
||
| fs afero.Fs | ||
| } | ||
|
|
||
| func (c *convertCmd) Help() string { | ||
| return ` | ||
| Convert a CompositeResourceDefinition (XRD) into the CustomResourceDefinition(s) | ||
| that Crossplane derives from it internally. | ||
|
|
||
| Useful for inspecting the generated CRD shape, feeding it into kubectl-based | ||
| tooling that doesn't understand XRDs, or debugging composition behavior. | ||
|
|
||
| Output depends on the XRD type, detected automatically: | ||
| * Namespaced or Cluster-scoped XRD -> 1 CRD for the XR | ||
| * Legacy XRD without claimNames -> 1 CRD for the XR | ||
| * Legacy XRD with claimNames -> 2 CRDs: one for the XR and one for the Claim | ||
|
|
||
| Examples: | ||
|
|
||
| # Convert an XRD file and print the CRD(s) to stdout (multi-doc YAML for legacy XRDs). | ||
| crossplane xrd convert xrd.yaml | ||
|
|
||
| # Convert and write to a single file (multi-doc YAML for legacy XRDs). | ||
| crossplane xrd convert xrd.yaml -o crds.yaml | ||
|
|
||
| # Split per-CRD files into a directory (each named <crd.Name>.yaml). | ||
| crossplane xrd convert xrd.yaml --output-dir ./crds/ | ||
|
|
||
| # Read the XRD from stdin. | ||
| cat xrd.yaml | crossplane xrd convert - | ||
| ` | ||
| } | ||
|
|
||
| // AfterApply implements kong.AfterApply. | ||
| func (c *convertCmd) AfterApply() error { | ||
| c.fs = afero.NewOsFs() | ||
| return nil | ||
| } | ||
|
|
||
| func (c *convertCmd) Run(k *kong.Context) error { | ||
| data, err := commonIO.Read(c.fs, c.InputFile) | ||
| if err != nil { | ||
| return err | ||
| } | ||
|
|
||
| xrd := &apiextensionsv1.CompositeResourceDefinition{} | ||
| if err := yaml.Unmarshal(data, xrd); err != nil { | ||
| return errors.Wrap(err, "cannot unmarshal XRD") | ||
| } | ||
|
|
||
| if xrd.GroupVersionKind() != apiextensionsv1.CompositeResourceDefinitionGroupVersionKind { | ||
| return errors.Errorf("input is not a %s; got %s", apiextensionsv1.CompositeResourceDefinitionGroupVersionKind, xrd.GroupVersionKind()) | ||
| } | ||
|
|
||
| crds, err := toCRDs(xrd) | ||
| if err != nil { | ||
| return errors.Wrapf(err, "cannot derive CRDs from XRD %q", xrd.GetName()) | ||
| } | ||
|
|
||
| switch { | ||
| case c.OutputDir != "": | ||
| if err := c.fs.MkdirAll(c.OutputDir, 0o755); err != nil { | ||
| return errors.Wrapf(err, "cannot create output directory %q", c.OutputDir) | ||
| } | ||
|
|
||
| for _, crd := range crds { | ||
| path := filepath.Join(c.OutputDir, crd.GetName()+".yaml") | ||
| if err := c.writeFile(path, []*extv1.CustomResourceDefinition{crd}); err != nil { | ||
| return err | ||
| } | ||
| } | ||
|
|
||
| return nil | ||
|
|
||
| case c.OutputFile != "": | ||
| return c.writeFile(c.OutputFile, crds) | ||
|
|
||
| default: | ||
| data, err := marshalCRDs(crds) | ||
| if err != nil { | ||
| return err | ||
| } | ||
|
|
||
| if _, err := k.Stdout.Write(data); err != nil { | ||
| return errors.Wrap(err, "cannot write output") | ||
| } | ||
|
|
||
| return nil | ||
| } | ||
| } | ||
|
|
||
| // writeFile marshals the given CRDs to a multi-doc YAML stream and writes it to path. | ||
| func (c *convertCmd) writeFile(path string, crds []*extv1.CustomResourceDefinition) error { | ||
| data, err := marshalCRDs(crds) | ||
| if err != nil { | ||
| return err | ||
| } | ||
|
|
||
| if err := afero.WriteFile(c.fs, path, data, 0o644); err != nil { | ||
| return errors.Wrapf(err, "cannot write output file %q", path) | ||
| } | ||
|
|
||
| return nil | ||
| } | ||
|
|
||
| // marshalCRDs returns the multi-doc YAML stream for the given CRDs. | ||
| func marshalCRDs(crds []*extv1.CustomResourceDefinition) ([]byte, error) { | ||
| var buf bytes.Buffer | ||
|
|
||
| for _, crd := range crds { | ||
| b, err := yaml.Marshal(crd) | ||
| if err != nil { | ||
| return nil, errors.Wrapf(err, "cannot marshal CRD %q", crd.GetName()) | ||
| } | ||
|
|
||
| buf.WriteString("---\n") | ||
| buf.Write(b) | ||
| } | ||
|
|
||
| return buf.Bytes(), nil | ||
| } | ||
|
|
||
| // toCRDs converts a Crossplane XRD into the Kubernetes CRDs that describe | ||
| // the composite resource type, ready to be serialized. The returned CRDs | ||
| // have their TypeMeta populated so YAML/JSON marshaling produces well-formed | ||
| // `kind: CustomResourceDefinition` documents, which is something that the | ||
| // underlying xcrd helpers do not do on their own. | ||
| // | ||
| // When the XRD offers a Claim (Spec.ClaimNames set) the result is a two- | ||
| // element slice: the CRD for the XR followed by the CRD for the Claim. | ||
| // Otherwise the result is a single-element slice containing the XR CRD. | ||
| func toCRDs(xrd *apiextensionsv1.CompositeResourceDefinition) ([]*extv1.CustomResourceDefinition, error) { | ||
| xrCRD, err := xcrd.ForCompositeResource(xrd) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| setTypeMeta(xrCRD) | ||
|
|
||
| crds := []*extv1.CustomResourceDefinition{xrCRD} | ||
|
|
||
| if xrd.OffersClaim() { | ||
| claimCRD, err := xcrd.ForCompositeResourceClaim(xrd) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| setTypeMeta(claimCRD) | ||
| crds = append(crds, claimCRD) | ||
| } | ||
|
|
||
| return crds, nil | ||
| } | ||
|
|
||
| func setTypeMeta(crd *extv1.CustomResourceDefinition) { | ||
| crd.APIVersion = extv1.SchemeGroupVersion.String() | ||
| crd.Kind = "CustomResourceDefinition" | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,127 @@ | ||
| /* | ||
| Copyright 2026 The Crossplane Authors. | ||
|
|
||
| Licensed under the Apache License, Version 2.0 (the "License"); | ||
| you may not use this file except in compliance with the License. | ||
| You may obtain a copy of the License at | ||
|
|
||
| http://www.apache.org/licenses/LICENSE-2.0 | ||
|
|
||
| Unless required by applicable law or agreed to in writing, software | ||
| distributed under the License is distributed on an "AS IS" BASIS, | ||
| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| See the License for the specific language governing permissions and | ||
| limitations under the License. | ||
| */ | ||
|
|
||
| package xrd | ||
|
|
||
| import ( | ||
| "testing" | ||
|
|
||
| "github.com/google/go-cmp/cmp" | ||
| extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" | ||
| metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" | ||
| "k8s.io/apimachinery/pkg/runtime" | ||
|
|
||
| apiextensionsv1 "github.com/crossplane/crossplane/apis/v2/apiextensions/v1" | ||
| ) | ||
|
|
||
| func TestToCRDs(t *testing.T) { | ||
| claimNames := &extv1.CustomResourceDefinitionNames{ | ||
| Kind: "TestApp", | ||
| Plural: "testapps", | ||
| } | ||
|
|
||
| cases := map[string]struct { | ||
| reason string | ||
| xrd *apiextensionsv1.CompositeResourceDefinition | ||
| wantKinds []string // expected Spec.Names.Kind for each returned CRD, in order | ||
| wantScopes []extv1.ResourceScope // expected Spec.Scope for each returned CRD, in order | ||
| }{ | ||
| "Namespaced": { | ||
| reason: "A namespaced XRD without claimNames should produce one namespaced XR CRD.", | ||
| xrd: minimalXRD(apiextensionsv1.CompositeResourceScopeNamespaced, nil), | ||
| wantKinds: []string{"XTestApp"}, | ||
| wantScopes: []extv1.ResourceScope{extv1.NamespaceScoped}, | ||
| }, | ||
| "ClusterScope": { | ||
| reason: "A v2 cluster-scoped XRD should produce one cluster-scoped XR CRD.", | ||
| xrd: minimalXRD(apiextensionsv1.CompositeResourceScopeCluster, nil), | ||
| wantKinds: []string{"XTestApp"}, | ||
| wantScopes: []extv1.ResourceScope{extv1.ClusterScoped}, | ||
| }, | ||
| "LegacyWithoutClaim": { | ||
| reason: "A legacy XRD without claimNames should produce one cluster-scoped XR CRD.", | ||
| xrd: minimalXRD(apiextensionsv1.CompositeResourceScopeLegacyCluster, nil), | ||
| wantKinds: []string{"XTestApp"}, | ||
| wantScopes: []extv1.ResourceScope{extv1.ClusterScoped}, | ||
| }, | ||
| "LegacyOffersClaim": { | ||
| reason: "A legacy XRD offering a Claim should produce a cluster-scoped XR CRD and a namespaced Claim CRD, in that order.", | ||
| xrd: minimalXRD(apiextensionsv1.CompositeResourceScopeLegacyCluster, claimNames), | ||
| wantKinds: []string{"XTestApp", "TestApp"}, | ||
| wantScopes: []extv1.ResourceScope{extv1.ClusterScoped, extv1.NamespaceScoped}, | ||
| }, | ||
| } | ||
|
|
||
| for name, tc := range cases { | ||
| t.Run(name, func(t *testing.T) { | ||
| crds, err := toCRDs(tc.xrd) | ||
| if err != nil { | ||
| t.Fatalf("\n%s\ntoCRDs(): unexpected error: %v", tc.reason, err) | ||
| } | ||
|
|
||
| gotKinds := make([]string, len(crds)) | ||
| gotScopes := make([]extv1.ResourceScope, len(crds)) | ||
| for i, crd := range crds { | ||
| gotKinds[i] = crd.Spec.Names.Kind | ||
| gotScopes[i] = crd.Spec.Scope | ||
| } | ||
|
|
||
| if diff := cmp.Diff(tc.wantKinds, gotKinds); diff != "" { | ||
| t.Errorf("\n%s\ntoCRDs() kinds: -want, +got:\n%s", tc.reason, diff) | ||
| } | ||
|
|
||
| if diff := cmp.Diff(tc.wantScopes, gotScopes); diff != "" { | ||
| t.Errorf("\n%s\ntoCRDs() scopes: -want, +got:\n%s", tc.reason, diff) | ||
| } | ||
|
|
||
| for i, crd := range crds { | ||
| if crd.APIVersion != "apiextensions.k8s.io/v1" { | ||
| t.Errorf("\n%s\ncrds[%d].APIVersion = %q, want apiextensions.k8s.io/v1", tc.reason, i, crd.APIVersion) | ||
| } | ||
|
|
||
| if crd.Kind != "CustomResourceDefinition" { | ||
| t.Errorf("\n%s\ncrds[%d].Kind = %q, want CustomResourceDefinition", tc.reason, i, crd.Kind) | ||
| } | ||
| } | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| // minimalXRD returns a minimal valid XRD with the given scope and (optional) | ||
| // claim names. All other fields are populated with defaults sufficient to | ||
| // satisfy xcrd.ForCompositeResource / xcrd.ForCompositeResourceClaim. | ||
| func minimalXRD(scope apiextensionsv1.CompositeResourceScope, claimNames *extv1.CustomResourceDefinitionNames) *apiextensionsv1.CompositeResourceDefinition { | ||
| return &apiextensionsv1.CompositeResourceDefinition{ | ||
| ObjectMeta: metav1.ObjectMeta{Name: "xtestapps.example.org"}, | ||
| Spec: apiextensionsv1.CompositeResourceDefinitionSpec{ | ||
| Group: "example.org", | ||
| Names: extv1.CustomResourceDefinitionNames{ | ||
| Kind: "XTestApp", | ||
| Plural: "xtestapps", | ||
| }, | ||
| Scope: &scope, | ||
| ClaimNames: claimNames, | ||
| Versions: []apiextensionsv1.CompositeResourceDefinitionVersion{{ | ||
| Name: "v1alpha1", | ||
| Served: true, | ||
| Referenceable: true, | ||
| Schema: &apiextensionsv1.CompositeResourceValidation{ | ||
| OpenAPIV3Schema: runtime.RawExtension{Raw: []byte(`{"type":"object"}`)}, | ||
| }, | ||
| }}, | ||
| }, | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| /* | ||
| Copyright 2026 The Crossplane Authors. | ||
|
|
||
| Licensed under the Apache License, Version 2.0 (the "License"); | ||
| you may not use this file except in compliance with the License. | ||
| You may obtain a copy of the License at | ||
|
|
||
| http://www.apache.org/licenses/LICENSE-2.0 | ||
|
|
||
| Unless required by applicable law or agreed to in writing, software | ||
| distributed under the License is distributed on an "AS IS" BASIS, | ||
| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| See the License for the specific language governing permissions and | ||
| limitations under the License. | ||
| */ | ||
|
|
||
| // Package xrd contains commands for working with CompositeResourceDefinitions. | ||
| package xrd | ||
|
|
||
| // Cmd contains XRD subcommands. | ||
| type Cmd struct { | ||
| Convert convertCmd `cmd:"" help:"Convert an XRD to a Kubernetes CRD."` | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win
Add user-facing context and next-step guidance to input errors
Nice implementation overall—could we make the read/type-validation errors a bit more actionable for CLI users? Right now the read path is returned as-is, and the GVK mismatch message doesn’t tell users what to fix first.
Proposed tweak
func (c *convertCmd) Run(k *kong.Context) error { data, err := commonIO.Read(c.fs, c.InputFile) if err != nil { - return err + return errors.Wrapf(err, "cannot read XRD input %q; verify the path or pass '-' to read from stdin", c.InputFile) } @@ if xrd.GroupVersionKind() != apiextensionsv1.CompositeResourceDefinitionGroupVersionKind { - return errors.Errorf("input is not a %s; got %s", apiextensionsv1.CompositeResourceDefinitionGroupVersionKind, xrd.GroupVersionKind()) + return errors.Errorf( + "input must be %s; got %s. Ensure the YAML has apiVersion %q and kind %q", + apiextensionsv1.CompositeResourceDefinitionGroupVersionKind, + xrd.GroupVersionKind(), + apiextensionsv1.CompositeResourceDefinitionGroupVersionKind.GroupVersion().String(), + apiextensionsv1.CompositeResourceDefinitionGroupVersionKind.Kind, + ) }As per coding guidelines, "CRITICAL: Ensure all error messages are meaningful to end users, not just developers - avoid technical jargon, include context about what the user was trying to do, and suggest next steps when possible."
🤖 Prompt for AI Agents