Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions cmd/crossplane/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import (
"github.com/crossplane/cli/v2/cmd/crossplane/resource"
"github.com/crossplane/cli/v2/cmd/crossplane/version"
"github.com/crossplane/cli/v2/cmd/crossplane/xpkg"
"github.com/crossplane/cli/v2/cmd/crossplane/xrd"
"github.com/crossplane/cli/v2/internal/config"
"github.com/crossplane/cli/v2/internal/maturity"
)
Expand Down Expand Up @@ -69,6 +70,7 @@ type cli struct {
Resource resource.Cmd `cmd:"" help:"Work with Crossplane resources." maturity:"beta"`
Version version.Cmd `cmd:"" help:"Print the client and server version information for the current context."`
XPKG xpkg.Cmd `cmd:"" help:"Work with Crossplane packages."`
XRD xrd.Cmd `cmd:"" help:"Work with Crossplane Composite Resource Definitions (XRDs)." maturity:"beta"`

// Hidden top-level alias for render, since it's GA but has moved.
Render renderxr.Cmd `cmd:"" help:"Render Crossplane compositions locally using functions." hidden:""`
Expand Down
202 changes: 202 additions & 0 deletions cmd/crossplane/xrd/convert.go
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())
}
Comment on lines +85 to +97
Copy link
Copy Markdown

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
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/crossplane/xrd/convert.go` around lines 85 - 97, The file-read and GVK
validation errors are currently opaque to end users; update the Read error
handling (the commonIO.Read call) to wrap the error with a user-facing message
that includes the attempted input (c.InputFile) and actionable next steps (e.g.,
check file path, permissions, or that the file exists) and update the GVK
mismatch error (the xrd.GroupVersionKind() check) to state the expected kind
(apiextensionsv1.CompositeResourceDefinitionGroupVersionKind), what was found,
and a suggested remediation such as "ensure you provided an XRD YAML or run
`kubectl get xrd -o yaml` to inspect." Ensure these changes use the existing
error wrapping approach (errors.Wrap / errors.Errorf) so the underlying error is
preserved.


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"
}
127 changes: 127 additions & 0 deletions cmd/crossplane/xrd/convert_test.go
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"}`)},
},
}},
},
}
}
23 changes: 23 additions & 0 deletions cmd/crossplane/xrd/xrd.go
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."`
}
Loading