Skip to content
Merged
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
78 changes: 78 additions & 0 deletions cmd/diff/client/crossplane/composition_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ type CompositionClient interface {

// GetComposition gets a composition by name
GetComposition(ctx context.Context, name string) (*apiextensionsv1.Composition, error)

// FindXRsUsingComposition finds all XRs that use the specified composition
FindXRsUsingComposition(ctx context.Context, compositionName string, namespace string) ([]*un.Unstructured, error)
}

// DefaultCompositionClient implements CompositionClient.
Expand Down Expand Up @@ -424,3 +427,78 @@ func (c *DefaultCompositionClient) findByTypeReference(ctx context.Context, _ *u

return compatibleCompositions[0], nil
}

// FindXRsUsingComposition finds all XRs that use the specified composition.
func (c *DefaultCompositionClient) FindXRsUsingComposition(ctx context.Context, compositionName string, namespace string) ([]*un.Unstructured, error) {
c.logger.Debug("Finding XRs using composition",
"compositionName", compositionName,
"namespace", namespace)

// First, get the composition to understand what XR type it targets
comp, err := c.GetComposition(ctx, compositionName)
if err != nil {
return nil, errors.Wrapf(err, "cannot get composition %s", compositionName)
}

// Get the XR type this composition targets
xrAPIVersion := comp.Spec.CompositeTypeRef.APIVersion
xrKind := comp.Spec.CompositeTypeRef.Kind

// Parse the GVK
gv, err := schema.ParseGroupVersion(xrAPIVersion)
if err != nil {
return nil, errors.Wrapf(err, "cannot parse API version %s", xrAPIVersion)
}

xrGVK := schema.GroupVersionKind{
Group: gv.Group,
Version: gv.Version,
Kind: xrKind,
}

c.logger.Debug("Composition targets XR type",
"gvk", xrGVK.String())

// List all resources of this XR type in the specified namespace
xrs, err := c.resourceClient.ListResources(ctx, xrGVK, namespace)
if err != nil {
return nil, errors.Wrapf(err, "cannot list XRs of type %s in namespace %s", xrGVK.String(), namespace)
}

c.logger.Debug("Found XRs of target type", "count", len(xrs))

// Filter XRs that use this specific composition
var matchingXRs []*un.Unstructured

for _, xr := range xrs {
if c.xrUsesComposition(xr, compositionName) {
matchingXRs = append(matchingXRs, xr)
}
}

c.logger.Debug("Found XRs using composition",
"compositionName", compositionName,
"count", len(matchingXRs))

return matchingXRs, nil
}

// xrUsesComposition checks if an XR uses the specified composition.
func (c *DefaultCompositionClient) xrUsesComposition(xr *un.Unstructured, compositionName string) bool {
// Check direct composition reference in spec.compositionRef.name or spec.crossplane.compositionRef.name
apiVersion := xr.GetAPIVersion()

// Try both v1 and v2 paths
paths := [][]string{
makeCrossplaneRefPath(apiVersion, "compositionRef", "name"),
{"spec", "compositionRef", "name"}, // fallback for v1
}

for _, path := range paths {
if refName, found, _ := un.NestedString(xr.Object, path...); found && refName == compositionName {
return true
}
}

return false
}
107 changes: 107 additions & 0 deletions cmd/diff/cmd_utils.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/*
Copyright 2025 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 main contains shared command utilities.
package main

import (
"context"
"time"

"github.com/alecthomas/kong"
dp "github.com/crossplane-contrib/crossplane-diff/cmd/diff/diffprocessor"
"k8s.io/client-go/rest"

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

"github.com/crossplane/crossplane/v2/cmd/crank/render"
)

// CommonCmdFields contains common fields shared by both XR and Comp commands.
type CommonCmdFields struct {
// Configuration options
NoColor bool `help:"Disable colorized output." name:"no-color"`
Compact bool `help:"Show compact diffs with minimal context." name:"compact"`
Timeout time.Duration `default:"1m" help:"How long to run before timing out."`
QPS float32 `default:"0" help:"Maximum QPS to the API server."`
Burst int `default:"0" help:"Maximum burst for throttle."`
}

// initializeSharedDependencies handles the common initialization logic for both commands.
func initializeSharedDependencies(ctx *kong.Context, log logging.Logger, config *rest.Config, fields CommonCmdFields) (*AppContext, error) {
config = initRestConfig(config, log, fields)

appCtx, err := NewAppContext(config, log)
if err != nil {
return nil, errors.Wrap(err, "cannot create app context")
}

ctx.Bind(appCtx)

return appCtx, nil
}

// initRestConfig configures REST client rate limits for both commands.
func initRestConfig(config *rest.Config, logger logging.Logger, fields CommonCmdFields) *rest.Config {
// Set default QPS and Burst if they are not set in the config
// or override with values from options if provided
originalQPS := config.QPS
originalBurst := config.Burst

if fields.QPS > 0 {
config.QPS = fields.QPS
} else if config.QPS == 0 {
config.QPS = 20
}

if fields.Burst > 0 {
config.Burst = fields.Burst
} else if config.Burst == 0 {
config.Burst = 30
}

logger.Debug("Configured REST client rate limits",
"original_qps", originalQPS,
"original_burst", originalBurst,
"options_qps", fields.QPS,
"options_burst", fields.Burst,
"final_qps", config.QPS,
"final_burst", config.Burst)

return config
}

// initializeAppContext initializes the application context with timeout and error handling.
func initializeAppContext(timeout time.Duration, appCtx *AppContext, log logging.Logger) (context.Context, context.CancelFunc, error) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
if err := appCtx.Initialize(ctx, log); err != nil {
cancel()
return nil, nil, errors.Wrap(err, "cannot initialize client")
}

return ctx, cancel, nil
}

// defaultProcessorOptions returns the standard default options used by both XR and composition processors.
// Call sites can append additional options or override these defaults as needed.
func defaultProcessorOptions() []dp.ProcessorOption {
return []dp.ProcessorOption{
dp.WithColorize(true),
dp.WithCompact(false),
dp.WithRenderFunc(render.Render),
}
}
131 changes: 131 additions & 0 deletions cmd/diff/comp.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/*
Copyright 2025 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 main contains the composition diff command.
package main

import (
"github.com/alecthomas/kong"
dp "github.com/crossplane-contrib/crossplane-diff/cmd/diff/diffprocessor"
"k8s.io/client-go/rest"

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

ld "github.com/crossplane/crossplane/v2/cmd/crank/common/load"
)

// CompDiffProcessor is imported from the diffprocessor package

// CompCmd represents the composition diff command.
type CompCmd struct {
// Embed common fields
CommonCmdFields

Files []string `arg:"" help:"YAML files containing updated Composition(s)." optional:""`

// Configuration options
Namespace string `default:"" help:"Namespace to find XRs (empty = all namespaces)." name:"namespace" short:"n"`
}

// Help returns help instructions for the composition diff command.
func (c *CompCmd) Help() string {
return `
This command shows the impact of composition changes on existing XRs in the cluster.

It finds all XRs that use the specified composition(s) and shows what would change
if they were rendered with the updated composition(s) from the file(s).

Examples:
# Show impact of updated composition on all XRs using it
crossplane-diff comp updated-composition.yaml

# Show impact of multiple composition changes
crossplane-diff comp comp1.yaml comp2.yaml comp3.yaml

# Show impact only on XRs in a specific namespace
crossplane-diff comp updated-composition.yaml -n production

# Show compact diffs with minimal context
crossplane-diff comp updated-composition.yaml --compact
`
}

// AfterApply implements kong's AfterApply method to bind our dependencies.
func (c *CompCmd) AfterApply(ctx *kong.Context, log logging.Logger, config *rest.Config) error {
return c.initializeDependencies(ctx, log, config)
}

func (c *CompCmd) initializeDependencies(ctx *kong.Context, log logging.Logger, config *rest.Config) error {
appCtx, err := initializeSharedDependencies(ctx, log, config, c.CommonCmdFields)
if err != nil {
return err
}

proc := makeDefaultCompProc(c, appCtx, log)

loader, err := ld.NewCompositeLoader(c.Files)
if err != nil {
return errors.Wrap(err, "cannot create composition loader")
}

ctx.BindTo(proc, (*dp.CompDiffProcessor)(nil))
ctx.BindTo(loader, (*ld.Loader)(nil))

return nil
}

func makeDefaultCompProc(c *CompCmd, ctx *AppContext, log logging.Logger) dp.CompDiffProcessor {
// Both processors share the same options since they're part of the same command
opts := defaultProcessorOptions()
opts = append(opts,
dp.WithNamespace(c.Namespace),
dp.WithLogger(log),
dp.WithColorize(!c.NoColor), // Override default if NoColor is set
dp.WithCompact(c.Compact), // Override default if Compact is set
)

// Create XR processor first (peer processor)
xrProc := dp.NewDiffProcessor(ctx.K8sClients, ctx.XpClients, opts...)

// Inject it into composition processor
return dp.NewCompDiffProcessor(xrProc, ctx.XpClients.Composition, opts...)
}

// Run executes the composition diff command.
func (c *CompCmd) Run(k *kong.Context, log logging.Logger, appCtx *AppContext, proc dp.CompDiffProcessor, loader ld.Loader) error {
ctx, cancel, err := initializeAppContext(c.Timeout, appCtx, log)
if err != nil {
return err
}
defer cancel()

err = proc.Initialize(ctx)
if err != nil {
return errors.Wrap(err, "cannot initialize composition diff processor")
}

compositions, err := loader.Load()
if err != nil {
return errors.Wrap(err, "cannot load compositions")
}

if err := proc.DiffComposition(ctx, k.Stdout, compositions, c.Namespace); err != nil {
return errors.Wrap(err, "unable to process composition diff")
}

return nil
}
Loading
Loading