diff --git a/cmd/crossplane/render/xr/cmd.go b/cmd/crossplane/render/xr/cmd.go index 5f4a317..0009e5e 100644 --- a/cmd/crossplane/render/xr/cmd.go +++ b/cmd/crossplane/render/xr/cmd.go @@ -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" @@ -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,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()) } } 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..bb2b003 --- /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. 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 +``` diff --git a/cmd/crossplane/xr/patch.go b/cmd/crossplane/xr/patch.go new file mode 100644 index 0000000..7b26051 --- /dev/null +++ b/cmd/crossplane/xr/patch.go @@ -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 +} diff --git a/cmd/crossplane/xr/xr.go b/cmd/crossplane/xr/xr.go index 1ba984f..03786b5 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)."` } diff --git a/pkg/xr/xrd.go b/pkg/xr/xrd.go new file mode 100644 index 0000000..64c6a40 --- /dev/null +++ b/pkg/xr/xrd.go @@ -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 { + 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/pkg/xr/xrd_test.go similarity index 88% rename from cmd/crossplane/render/xrd_test.go rename to pkg/xr/xrd_test.go index c12cb41..4158fd4 100644 --- a/cmd/crossplane/render/xrd_test.go +++ b/pkg/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" @@ -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 @@ -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) } }) }