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
11 changes: 3 additions & 8 deletions cmd/crossplane/render/xr/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ import (
"github.com/crossplane/crossplane-runtime/v2/pkg/errors"
"github.com/crossplane/crossplane-runtime/v2/pkg/logging"
"github.com/crossplane/crossplane-runtime/v2/pkg/resource/unstructured/composed"
"github.com/crossplane/crossplane-runtime/v2/pkg/xcrd"

apiextensionsv1 "github.com/crossplane/crossplane/apis/v2/apiextensions/v1"
pkgv1 "github.com/crossplane/crossplane/apis/v2/pkg/v1"
Expand All @@ -52,6 +51,7 @@ import (
"github.com/crossplane/cli/v2/internal/schemas/runner"
"github.com/crossplane/cli/v2/internal/terminal"
clixpkg "github.com/crossplane/cli/v2/internal/xpkg"
xrpkg "github.com/crossplane/cli/v2/pkg/xr"

_ "embed"
)
Expand Down Expand Up @@ -169,13 +169,8 @@ func (c *Cmd) Run(k *kong.Context, log logging.Logger, sp terminal.SpinnerPrinte
return errors.Wrapf(err, "cannot load XRD from %q", c.XRD)
}

crd, err := xcrd.ForCompositeResource(xrd)
if err != nil {
return errors.Wrapf(err, "cannot derive composite CRD from XRD %q", xrd.GetName())
}

if err := render.DefaultValues(xr.UnstructuredContent(), xr.GetAPIVersion(), *crd); err != nil {
return errors.Wrapf(err, "cannot default values for XR %q", xr.GetName())
if err := xrpkg.ApplyXRDDefaults(xr.GetUnstructured(), xrd); err != nil {
return errors.Wrapf(err, "cannot apply XRD defaults to XR %q", xr.GetName())
}
}

Expand Down
43 changes: 0 additions & 43 deletions cmd/crossplane/render/xrd.go

This file was deleted.

26 changes: 26 additions & 0 deletions cmd/crossplane/xr/help/patch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
The `xr patch` command applies XR-level patches to a Composite Resource (XR).

It reads the XR from a file (or stdin), applies the requested patches, and
writes the result to stdout or to a file. Pass at least one patching flag;
today the only one is `--xrd`, which applies default values from an XRD's
`openAPIV3Schema` to the XR. Future releases add more patching flags.

## Examples

Apply default values from an XRD to an XR:

```shell
crossplane xr patch xr.yaml --xrd xrd.yaml
```

Patch an XR from stdin:

```shell
cat xr.yaml | crossplane xr patch - --xrd xrd.yaml
```

Write the patched XR to a file:

```shell
crossplane xr patch xr.yaml --xrd xrd.yaml -o patched.yaml
```
105 changes: 105 additions & 0 deletions cmd/crossplane/xr/patch.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/*
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 xr

import (
"github.com/alecthomas/kong"
"github.com/spf13/afero"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"sigs.k8s.io/yaml"

"github.com/crossplane/crossplane-runtime/v2/pkg/errors"

commonIO "github.com/crossplane/cli/v2/cmd/crossplane/convert/io"
"github.com/crossplane/cli/v2/cmd/crossplane/render"
xrpkg "github.com/crossplane/cli/v2/pkg/xr"

_ "embed"
)

//go:embed help/patch.md
var patchHelp string

type patchCmd struct {
// Arguments.
InputFile string `arg:"" default:"-" help:"The XR YAML file to patch, or '-' for stdin." optional:"" predictor:"file" type:"path"`

// Output flags.
OutputFile string `help:"The file to write the patched XR YAML to. Defaults to stdout." placeholder:"PATH" predictor:"file" short:"o" type:"path"`

// Patching flags.
XRD string `help:"A YAML file specifying the CompositeResourceDefinition (XRD) that provides schema defaults for the XR." name:"xrd" placeholder:"PATH" predictor:"file" type:"path"`

fs afero.Fs
}

func (c *patchCmd) Help() string {
return patchHelp
}

// AfterApply implements kong.AfterApply.
func (c *patchCmd) AfterApply() error {
c.fs = afero.NewOsFs()
return nil
}

// Run runs the patch command.
func (c *patchCmd) Run(k *kong.Context) error {
if c.XRD == "" {
return errors.New("no patching flag provided: at least one of --xrd must be set")
}

xrData, err := commonIO.Read(c.fs, c.InputFile)
if err != nil {
return err
}

xr := &unstructured.Unstructured{}
if err := yaml.Unmarshal(xrData, xr); err != nil {
return errors.Wrap(err, "cannot unmarshal XR")
}

xrd, err := render.LoadXRD(c.fs, c.XRD)
if err != nil {
return errors.Wrapf(err, "cannot load XRD from %q", c.XRD)
}

if err := xrpkg.ApplyXRDDefaults(xr, xrd); err != nil {
return errors.Wrapf(err, "cannot apply XRD defaults to XR %q", xr.GetName())
}

b, err := yaml.Marshal(xr)
if err != nil {
return errors.Wrap(err, "cannot marshal patched XR")
}

data := append([]byte("---\n"), b...)

if c.OutputFile != "" {
if err := afero.WriteFile(c.fs, c.OutputFile, data, 0o644); err != nil {
return errors.Wrapf(err, "cannot write output file %q", c.OutputFile)
}

return nil
}

if _, err := k.Stdout.Write(data); err != nil {
return errors.Wrap(err, "cannot write output")
}

return nil
}
1 change: 1 addition & 0 deletions cmd/crossplane/xr/xr.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,5 @@ package xr
// Cmd contains XR subcommands.
type Cmd struct {
Generate generateCmd `cmd:"" help:"Generate a Composite Resource (XR) from a Claim."`
Patch patchCmd `cmd:"" help:"Patch a Composite Resource (XR)."`
}
79 changes: 79 additions & 0 deletions pkg/xr/xrd.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/*
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 xr provides library functions for working with XRs.
package xr

import (
"k8s.io/apiextensions-apiserver/pkg/apis/apiextensions"
extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
schema "k8s.io/apiextensions-apiserver/pkg/apiserver/schema"
structuraldefaulting "k8s.io/apiextensions-apiserver/pkg/apiserver/schema/defaulting"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"

"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"
)

// ApplyXRDDefaults applies default values from an XRD's openAPIV3Schema to an XR. The XR is mutated in place.
// This is the canonical XRD-defaulting entry point for the cli; downstream
// commands and tools (e.g. `crossplane render xr --xrd`) call into this
// function rather than re-implementing the schema-defaulting routine.
func ApplyXRDDefaults(xr *unstructured.Unstructured, xrdef *apiextensionsv1.CompositeResourceDefinition) error {
crd, err := xcrd.ForCompositeResource(xrdef)
if err != nil {
return errors.Wrapf(err, "cannot derive CRD from XRD %q", xrdef.GetName())
}

return ApplyCRDDefaults(xr.UnstructuredContent(), xr.GetAPIVersion(), *crd)
}

// ApplyCRDDefaults sets default values on the XR based on the CRD schema.
// Callers starting from an XRD should prefer ApplyXRDDefaults; this is the
// lower-level routine for callers that already have a CRD in hand.
func ApplyCRDDefaults(xr map[string]any, apiVersion string, crd extv1.CustomResourceDefinition) error {
var (
k apiextensions.JSONSchemaProps
version *extv1.CustomResourceDefinitionVersion
)

for _, vr := range crd.Spec.Versions {
checkAPIVersion := crd.Spec.Group + "/" + vr.Name
if checkAPIVersion == apiVersion {
version = &vr
break
}
}

if version == nil {
return errors.Errorf("the specified API version '%s' does not exist in the XRD", apiVersion)
}

if err := extv1.Convert_v1_JSONSchemaProps_To_apiextensions_JSONSchemaProps(version.Schema.OpenAPIV3Schema, &k, nil); err != nil {
Comment on lines +63 to +67

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Guard against a nil version.Schema before dereferencing?

Since DefaultValues is exported as the "lower-level routine for callers that already have a CRD in hand," a CRD version without a Schema would panic at version.Schema.OpenAPIV3Schema on Line 69. The ApplyXRDDefaults path via xcrd.ForCompositeResource always populates it, so this is purely defensive for direct callers — but a small nil check would turn a panic into a friendly error.

🛡️ Suggested guard
 	if version == nil {
 		return errors.Errorf("the specified API version '%s' does not exist in the XRD", apiVersion)
 	}
+
+	if version.Schema == nil || version.Schema.OpenAPIV3Schema == nil {
+		return errors.Errorf("the specified API version '%s' has no schema in the XRD", apiVersion)
+	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if version == nil {
return errors.Errorf("the specified API version '%s' does not exist in the XRD", apiVersion)
}
if err := extv1.Convert_v1_JSONSchemaProps_To_apiextensions_JSONSchemaProps(version.Schema.OpenAPIV3Schema, &k, nil); err != nil {
if version == nil {
return errors.Errorf("the specified API version '%s' does not exist in the XRD", apiVersion)
}
if version.Schema == nil || version.Schema.OpenAPIV3Schema == nil {
return errors.Errorf("the specified API version '%s' has no schema in the XRD", apiVersion)
}
if err := extv1.Convert_v1_JSONSchemaProps_To_apiextensions_JSONSchemaProps(version.Schema.OpenAPIV3Schema, &k, nil); err != nil {
🤖 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/xr/xrd.go` around lines 65 - 69, The code dereferences
version.Schema.OpenAPIV3Schema before checking for a nil Schema; update
DefaultValues (the routine using version) to validate that version.Schema is
non-nil and return a clear error if it is nil instead of panicking.
Specifically, before calling
extv1.Convert_v1_JSONSchemaProps_To_apiextensions_JSONSchemaProps with
version.Schema.OpenAPIV3Schema, add a guard that checks if version.Schema == nil
and return an errors.Errorf (or wrap) stating the CRD version lacks a Schema;
keep references to Convert_v1_JSONSchemaProps_To_apiextensions_JSONSchemaProps,
version.Schema.OpenAPIV3Schema, and the
DefaultValues/ApplyXRDDefaults/xcrd.ForCompositeResource call sites in mind so
callers that already populate Schema remain unchanged.

return err
}

crdWithDefaults, err := schema.NewStructural(&k)
if err != nil {
return err
}

structuraldefaulting.Default(xr, crdWithDefaults)

return nil
}
26 changes: 21 additions & 5 deletions cmd/crossplane/render/xrd_test.go → pkg/xr/xrd_test.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,20 @@
package render
/*
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 xr

import (
"testing"
Expand All @@ -7,7 +23,7 @@ import (
extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
)

func TestDefaultValues(t *testing.T) {
func TestApplyCRDDefaults(t *testing.T) {
type args struct {
xr map[string]any
crd extv1.CustomResourceDefinition
Expand Down Expand Up @@ -238,13 +254,13 @@ func TestDefaultValues(t *testing.T) {

for name, tc := range cases {
t.Run(name, func(t *testing.T) {
err := DefaultValues(tc.args.xr, tc.args.apiVersion, tc.args.crd)
err := ApplyCRDDefaults(tc.args.xr, tc.args.apiVersion, tc.args.crd)
if (err != nil) != tc.wantErr {
t.Errorf("DefaultValues() error = %v, wantErr %v", err, tc.wantErr)
t.Errorf("ApplyCRDDefaults() error = %v, wantErr %v", err, tc.wantErr)
}

if diff := cmp.Diff(tc.want, tc.args.xr); diff != "" {
t.Errorf("DefaultValues() mismatch (-want +got):\n%s", diff)
t.Errorf("ApplyCRDDefaults() mismatch (-want +got):\n%s", diff)
}
})
}
Expand Down
Loading