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"