diff --git a/cmd/crossplane/main.go b/cmd/crossplane/main.go index ae8d2d6..894bbae 100644 --- a/cmd/crossplane/main.go +++ b/cmd/crossplane/main.go @@ -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" ) @@ -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:""` diff --git a/cmd/crossplane/xrd/convert.go b/cmd/crossplane/xrd/convert.go new file mode 100644 index 0000000..397be6d --- /dev/null +++ b/cmd/crossplane/xrd/convert.go @@ -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 .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 .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" +} diff --git a/cmd/crossplane/xrd/convert_test.go b/cmd/crossplane/xrd/convert_test.go new file mode 100644 index 0000000..166081b --- /dev/null +++ b/cmd/crossplane/xrd/convert_test.go @@ -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"}`)}, + }, + }}, + }, + } +} diff --git a/cmd/crossplane/xrd/xrd.go b/cmd/crossplane/xrd/xrd.go new file mode 100644 index 0000000..f092d3d --- /dev/null +++ b/cmd/crossplane/xrd/xrd.go @@ -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."` +}