From cf24daf93afad52d810393fe839def0ebd67dcc2 Mon Sep 17 00:00:00 2001 From: Theo Chatzimichos Date: Mon, 1 Jun 2026 14:00:38 +0200 Subject: [PATCH 1/3] feat(xr): Introduce `xr patch --xrd` New subcommand that does the same as `render --xrd` but without running render. Useful when we want to patch our XR with its default values from the XRD. It is using the same code as `render --xrd`, but that codeis moved under `xr/xrd.go` and `render --xrd` imports/uses it. Signed-off-by: Theo Chatzimichos --- cmd/crossplane/render/xr/cmd.go | 11 +-- cmd/crossplane/render/xrd.go | 43 --------- cmd/crossplane/xr/help/patch.md | 26 ++++++ cmd/crossplane/xr/patch.go | 104 ++++++++++++++++++++++ cmd/crossplane/xr/xr.go | 1 + cmd/crossplane/xr/xrd.go | 81 +++++++++++++++++ cmd/crossplane/{render => xr}/xrd_test.go | 18 +++- 7 files changed, 232 insertions(+), 52 deletions(-) delete mode 100644 cmd/crossplane/render/xrd.go create mode 100644 cmd/crossplane/xr/help/patch.md create mode 100644 cmd/crossplane/xr/patch.go create mode 100644 cmd/crossplane/xr/xrd.go rename cmd/crossplane/{render => xr}/xrd_test.go (91%) diff --git a/cmd/crossplane/render/xr/cmd.go b/cmd/crossplane/render/xr/cmd.go index 5f4a317..64e8703 100644 --- a/cmd/crossplane/render/xr/cmd.go +++ b/cmd/crossplane/render/xr/cmd.go @@ -35,13 +35,13 @@ 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" "github.com/crossplane/cli/v2/cmd/crossplane/render" "github.com/crossplane/cli/v2/cmd/crossplane/render/contextfn" + xrcmd "github.com/crossplane/cli/v2/cmd/crossplane/xr" "github.com/crossplane/cli/v2/internal/async" "github.com/crossplane/cli/v2/internal/dependency" "github.com/crossplane/cli/v2/internal/project" @@ -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 := xrcmd.ApplyXRDDefaults(xr.GetUnstructured(), xrd); err != nil { + return errors.Wrapf(err, "cannot apply XRD defaults to XR %q", xr.GetName()) } } diff --git a/cmd/crossplane/render/xrd.go b/cmd/crossplane/render/xrd.go deleted file mode 100644 index 8d8955c..0000000 --- a/cmd/crossplane/render/xrd.go +++ /dev/null @@ -1,43 +0,0 @@ -package render - -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" - - "github.com/crossplane/crossplane-runtime/v2/pkg/errors" -) - -// DefaultValues sets default values on the XR based on the CRD schema. -func DefaultValues(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 { - return err - } - - crdWithDefaults, err := schema.NewStructural(&k) - if err != nil { - return err - } - - structuraldefaulting.Default(xr, crdWithDefaults) - - return nil -} diff --git a/cmd/crossplane/xr/help/patch.md b/cmd/crossplane/xr/help/patch.md new file mode 100644 index 0000000..a0f8534 --- /dev/null +++ b/cmd/crossplane/xr/help/patch.md @@ -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. At least one patching flag must be +set; today the only one is `--xrd`, which applies default values from an XRD's +`openAPIV3Schema` to the XR. More patching flags will be added in the future. + +## 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 +``` diff --git a/cmd/crossplane/xr/patch.go b/cmd/crossplane/xr/patch.go new file mode 100644 index 0000000..a98416a --- /dev/null +++ b/cmd/crossplane/xr/patch.go @@ -0,0 +1,104 @@ +/* +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" + + _ "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) whose schema defaults are applied to 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 := 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 +} diff --git a/cmd/crossplane/xr/xr.go b/cmd/crossplane/xr/xr.go index 1ba984f..0bec82c 100644 --- a/cmd/crossplane/xr/xr.go +++ b/cmd/crossplane/xr/xr.go @@ -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) with additional configurations."` } diff --git a/cmd/crossplane/xr/xrd.go b/cmd/crossplane/xr/xrd.go new file mode 100644 index 0000000..c33314f --- /dev/null +++ b/cmd/crossplane/xr/xrd.go @@ -0,0 +1,81 @@ +/* +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 ( + "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 DefaultValues(xr.UnstructuredContent(), xr.GetAPIVersion(), *crd) +} + +// DefaultValues 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 DefaultValues(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 { + return err + } + + crdWithDefaults, err := schema.NewStructural(&k) + if err != nil { + return err + } + + structuraldefaulting.Default(xr, crdWithDefaults) + + return nil +} diff --git a/cmd/crossplane/render/xrd_test.go b/cmd/crossplane/xr/xrd_test.go similarity index 91% rename from cmd/crossplane/render/xrd_test.go rename to cmd/crossplane/xr/xrd_test.go index c12cb41..a0e46c1 100644 --- a/cmd/crossplane/render/xrd_test.go +++ b/cmd/crossplane/xr/xrd_test.go @@ -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" From 46ba7f15145760cff65ec4ae9c19b95aff664964 Mon Sep 17 00:00:00 2001 From: Theo Chatzimichos Date: Mon, 8 Jun 2026 10:41:17 +0200 Subject: [PATCH 2/3] fix vale errors/warnings Signed-off-by: Theo Chatzimichos --- cmd/crossplane/xr/help/patch.md | 6 +++--- cmd/crossplane/xr/patch.go | 2 +- cmd/crossplane/xr/xr.go | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/cmd/crossplane/xr/help/patch.md b/cmd/crossplane/xr/help/patch.md index a0f8534..bb2b003 100644 --- a/cmd/crossplane/xr/help/patch.md +++ b/cmd/crossplane/xr/help/patch.md @@ -1,9 +1,9 @@ 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. At least one patching flag must be -set; today the only one is `--xrd`, which applies default values from an XRD's -`openAPIV3Schema` to the XR. More patching flags will be added in the future. +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 diff --git a/cmd/crossplane/xr/patch.go b/cmd/crossplane/xr/patch.go index a98416a..32c7094 100644 --- a/cmd/crossplane/xr/patch.go +++ b/cmd/crossplane/xr/patch.go @@ -41,7 +41,7 @@ type patchCmd struct { 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) whose schema defaults are applied to the XR." name:"xrd" placeholder:"PATH" predictor:"file" type:"path"` + 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 } diff --git a/cmd/crossplane/xr/xr.go b/cmd/crossplane/xr/xr.go index 0bec82c..03786b5 100644 --- a/cmd/crossplane/xr/xr.go +++ b/cmd/crossplane/xr/xr.go @@ -20,5 +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) with additional configurations."` + Patch patchCmd `cmd:"" help:"Patch a Composite Resource (XR)."` } From f10f6f610dec90f16aa87aca82f9e4660c6bf36f Mon Sep 17 00:00:00 2001 From: Theo Chatzimichos Date: Mon, 8 Jun 2026 11:15:45 +0200 Subject: [PATCH 3/3] rename ApplyCRDDefaults and move both to pkg/xr Signed-off-by: Theo Chatzimichos --- cmd/crossplane/render/xr/cmd.go | 4 ++-- cmd/crossplane/xr/patch.go | 3 ++- {cmd/crossplane => pkg}/xr/xrd.go | 12 +++++------- {cmd/crossplane => pkg}/xr/xrd_test.go | 8 ++++---- 4 files changed, 13 insertions(+), 14 deletions(-) rename {cmd/crossplane => pkg}/xr/xrd.go (87%) rename {cmd/crossplane => pkg}/xr/xrd_test.go (96%) diff --git a/cmd/crossplane/render/xr/cmd.go b/cmd/crossplane/render/xr/cmd.go index 64e8703..0009e5e 100644 --- a/cmd/crossplane/render/xr/cmd.go +++ b/cmd/crossplane/render/xr/cmd.go @@ -41,7 +41,6 @@ import ( "github.com/crossplane/cli/v2/cmd/crossplane/render" "github.com/crossplane/cli/v2/cmd/crossplane/render/contextfn" - xrcmd "github.com/crossplane/cli/v2/cmd/crossplane/xr" "github.com/crossplane/cli/v2/internal/async" "github.com/crossplane/cli/v2/internal/dependency" "github.com/crossplane/cli/v2/internal/project" @@ -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" ) @@ -169,7 +169,7 @@ func (c *Cmd) Run(k *kong.Context, log logging.Logger, sp terminal.SpinnerPrinte return errors.Wrapf(err, "cannot load XRD from %q", c.XRD) } - if err := xrcmd.ApplyXRDDefaults(xr.GetUnstructured(), xrd); err != nil { + if err := xrpkg.ApplyXRDDefaults(xr.GetUnstructured(), xrd); err != nil { return errors.Wrapf(err, "cannot apply XRD defaults to XR %q", xr.GetName()) } } diff --git a/cmd/crossplane/xr/patch.go b/cmd/crossplane/xr/patch.go index 32c7094..7b26051 100644 --- a/cmd/crossplane/xr/patch.go +++ b/cmd/crossplane/xr/patch.go @@ -26,6 +26,7 @@ import ( 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" ) @@ -77,7 +78,7 @@ func (c *patchCmd) Run(k *kong.Context) error { return errors.Wrapf(err, "cannot load XRD from %q", c.XRD) } - if err := ApplyXRDDefaults(xr, xrd); err != nil { + if err := xrpkg.ApplyXRDDefaults(xr, xrd); err != nil { return errors.Wrapf(err, "cannot apply XRD defaults to XR %q", xr.GetName()) } diff --git a/cmd/crossplane/xr/xrd.go b/pkg/xr/xrd.go similarity index 87% rename from cmd/crossplane/xr/xrd.go rename to pkg/xr/xrd.go index c33314f..64c6a40 100644 --- a/cmd/crossplane/xr/xrd.go +++ b/pkg/xr/xrd.go @@ -14,6 +14,7 @@ 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 ( @@ -29,9 +30,7 @@ import ( 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. -// +// 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. @@ -41,14 +40,13 @@ func ApplyXRDDefaults(xr *unstructured.Unstructured, xrdef *apiextensionsv1.Comp return errors.Wrapf(err, "cannot derive CRD from XRD %q", xrdef.GetName()) } - return DefaultValues(xr.UnstructuredContent(), xr.GetAPIVersion(), *crd) + return ApplyCRDDefaults(xr.UnstructuredContent(), xr.GetAPIVersion(), *crd) } -// DefaultValues sets default values on the XR based on the CRD schema. -// +// 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 DefaultValues(xr map[string]any, apiVersion string, crd extv1.CustomResourceDefinition) error { +func ApplyCRDDefaults(xr map[string]any, apiVersion string, crd extv1.CustomResourceDefinition) error { var ( k apiextensions.JSONSchemaProps version *extv1.CustomResourceDefinitionVersion diff --git a/cmd/crossplane/xr/xrd_test.go b/pkg/xr/xrd_test.go similarity index 96% rename from cmd/crossplane/xr/xrd_test.go rename to pkg/xr/xrd_test.go index a0e46c1..4158fd4 100644 --- a/cmd/crossplane/xr/xrd_test.go +++ b/pkg/xr/xrd_test.go @@ -23,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 @@ -254,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) } }) }