From 6ea960a3e88de97a7ba6d0d5c491f64d4d1f3e20 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie <679297+jcogilvie@users.noreply.github.com> Date: Tue, 28 Apr 2026 16:12:59 -0400 Subject: [PATCH 01/26] refactor: plumb stdout and stderr via options (#296) * fix: factor stdout/stderr into Options insteaad of passing as args Signed-off-by: Jonathan Ogilvie * fix: factor stdout/stderr into Options insteaad of passing as args Signed-off-by: Jonathan Ogilvie * fix: Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Jonathan Ogilvie * fix: add test coverage for empty output format Signed-off-by: Jonathan Ogilvie * chore: ignore compiled Go test binaries Signed-off-by: Jonathan Ogilvie --------- Signed-off-by: Jonathan Ogilvie Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .gitignore | 3 + cmd/diff/comp.go | 14 +- cmd/diff/diff_test.go | 42 ++---- cmd/diff/diffprocessor/comp_processor.go | 19 +-- cmd/diff/diffprocessor/comp_processor_test.go | 25 ++-- cmd/diff/diffprocessor/diff_processor.go | 16 +-- cmd/diff/diffprocessor/diff_processor_test.go | 69 ++++------ cmd/diff/diffprocessor/processor_config.go | 32 ++++- .../diffprocessor/processor_config_test.go | 85 ++++++++++++ cmd/diff/diffprocessor/schema_validator.go | 39 +++++- .../diffprocessor/schema_validator_test.go | 51 ++++++- cmd/diff/renderer/comp_diff_renderer.go | 74 ++++++---- cmd/diff/renderer/comp_diff_renderer_test.go | 128 +++++++++++++++++- cmd/diff/renderer/diff_formatter.go | 15 ++ cmd/diff/renderer/diff_renderer.go | 21 +-- cmd/diff/renderer/diff_renderer_test.go | 29 ++-- cmd/diff/renderer/structured_renderer.go | 26 ++-- cmd/diff/renderer/structured_renderer_test.go | 72 +++++++++- cmd/diff/testutils/mock_builder.go | 20 +-- cmd/diff/testutils/mocks.go | 12 +- cmd/diff/xr.go | 12 +- 21 files changed, 587 insertions(+), 217 deletions(-) diff --git a/.gitignore b/.gitignore index f6a5ba24..82a5072a 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,9 @@ gitlab/ /crossplane-diff /diff +# compiled Go test binaries (from `go test -c`) +*.test + # ignore the cluster dir since it's pulled from crossplane/crossplane by earthly cluster/ diff --git a/cmd/diff/comp.go b/cmd/diff/comp.go index f5efb29e..0cacc7be 100644 --- a/cmd/diff/comp.go +++ b/cmd/diff/comp.go @@ -73,7 +73,7 @@ Examples: // AppContext is received via dependency injection - Kong resolves it through the provider chain: // ContextProvider (bound in CommonCmdFields.BeforeApply) -> provideRestConfig -> provideAppContext. func (c *CompCmd) AfterApply(ctx *kong.Context, log logging.Logger, appCtx *AppContext) error { - proc := makeDefaultCompProc(c, appCtx, log) + proc := makeDefaultCompProc(c, ctx, appCtx, log) loader, err := ld.NewCompositeLoader(c.Files) if err != nil { @@ -86,7 +86,7 @@ func (c *CompCmd) AfterApply(ctx *kong.Context, log logging.Logger, appCtx *AppC return nil } -func makeDefaultCompProc(c *CompCmd, ctx *AppContext, log logging.Logger) dp.CompDiffProcessor { +func makeDefaultCompProc(c *CompCmd, kongCtx *kong.Context, appCtx *AppContext, log logging.Logger) dp.CompDiffProcessor { // Use provided namespace or default to "default" namespace := c.Namespace if namespace == "" { @@ -99,17 +99,19 @@ func makeDefaultCompProc(c *CompCmd, ctx *AppContext, log logging.Logger) dp.Com dp.WithLogger(log), dp.WithRenderMutex(&globalRenderMutex), dp.WithIncludeManual(c.IncludeManual), + dp.WithStdout(kongCtx.Stdout), + dp.WithStderr(kongCtx.Stderr), ) // Create XR processor first (peer processor) - xrProc := dp.NewDiffProcessor(ctx.K8sClients, ctx.XpClients, opts...) + xrProc := dp.NewDiffProcessor(appCtx.K8sClients, appCtx.XpClients, opts...) // Inject it into composition processor - return dp.NewCompDiffProcessor(xrProc, ctx.XpClients.Composition, opts...) + return dp.NewCompDiffProcessor(xrProc, appCtx.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, exitCode *ExitCode) error { +func (c *CompCmd) Run(_ *kong.Context, log logging.Logger, appCtx *AppContext, proc dp.CompDiffProcessor, loader ld.Loader, exitCode *ExitCode) error { ctx, cancel, err := initializeAppContext(c.Timeout, appCtx, log) if err != nil { exitCode.Code = dp.ExitCodeToolError @@ -144,7 +146,7 @@ func (c *CompCmd) Run(k *kong.Context, log logging.Logger, appCtx *AppContext, p return errors.Wrap(err, "cannot load compositions") } - hasDiffs, err := proc.DiffComposition(ctx, k.Stdout, compositions, c.Namespace) + hasDiffs, err := proc.DiffComposition(ctx, compositions, c.Namespace) // Determine exit code based on result exitCode.Code = dp.DetermineExitCode(err, hasDiffs) diff --git a/cmd/diff/diff_test.go b/cmd/diff/diff_test.go index 8cb521fd..8d577d6a 100644 --- a/cmd/diff/diff_test.go +++ b/cmd/diff/diff_test.go @@ -19,8 +19,6 @@ package main import ( "bytes" "context" - "fmt" - "io" "os" "path/filepath" "strings" @@ -435,19 +433,7 @@ func TestDiffCommand(t *testing.T) { setupProcessor: func() dp.DiffProcessor { return tu.NewMockDiffProcessor(). WithSuccessfulInitialize(). - WithPerformDiff(func(_ context.Context, w io.Writer, _ []*un.Unstructured, _ types.CompositionProvider) (bool, error) { - // Generate a mock diff for our test - _, _ = fmt.Fprintf(w, `~ ComposedResource/test-xr-composed-resource -{ - "spec": { - "coolParam": "test-value", - "extraData": "extra-resource-data", - "replicas": 3 - } -}`) - - return true, nil - }). + WithSuccessfulPerformDiffWithChanges(). Build() }, setupLoader: func() *itu.MockLoader { @@ -478,7 +464,9 @@ spec: }, } }, - expectedOutput: "ComposedResource", // Should mention resource type + // Note: Output content is tested in integration tests with real processors. + // Mock processors don't produce output - they just return success/failure. + expectedOutput: "", notExpected: nil, expectError: false, }, @@ -526,7 +514,7 @@ spec: setupProcessor: func() dp.DiffProcessor { return tu.NewMockDiffProcessor(). WithSuccessfulInitialize(). - WithPerformDiff(func(_ context.Context, _ io.Writer, _ []*un.Unstructured, _ types.CompositionProvider) (bool, error) { + WithPerformDiff(func(_ context.Context, _ []*un.Unstructured, _ types.CompositionProvider) (bool, error) { return false, errors.New("processing error") }). Build() @@ -633,7 +621,7 @@ spec: setupProcessor: func() dp.DiffProcessor { return tu.NewMockDiffProcessor(). WithSuccessfulInitialize(). - WithPerformDiff(func(_ context.Context, _ io.Writer, _ []*un.Unstructured, _ types.CompositionProvider) (bool, error) { + WithPerformDiff(func(_ context.Context, _ []*un.Unstructured, _ types.CompositionProvider) (bool, error) { // For matching resources, we don't produce any output return false, nil }). @@ -736,19 +724,7 @@ spec: setupProcessor: func() dp.DiffProcessor { return tu.NewMockDiffProcessor(). WithSuccessfulInitialize(). - WithPerformDiff(func(_ context.Context, w io.Writer, _ []*un.Unstructured, _ types.CompositionProvider) (bool, error) { - // Generate output for a new resource - _, _ = fmt.Fprintf(w, `+++ ComposedResource/test-xr-composed-resource -{ - "spec": { - "coolParam": "test-value", - "extraData": "extra-resource-data", - "replicas": 3 - } -}`) - - return true, nil - }). + WithSuccessfulPerformDiffWithChanges(). Build() }, setupLoader: func() *itu.MockLoader { @@ -778,7 +754,9 @@ spec: }, } }, - expectedOutput: "+++ ComposedResource/test-xr-composed-resource", // Should show as new resource + // Note: Output content is tested in integration tests with real processors. + // Mock processors don't produce output - they just return success/failure. + expectedOutput: "", expectError: false, }, diff --git a/cmd/diff/diffprocessor/comp_processor.go b/cmd/diff/diffprocessor/comp_processor.go index 1e8824b7..5e1549a4 100644 --- a/cmd/diff/diffprocessor/comp_processor.go +++ b/cmd/diff/diffprocessor/comp_processor.go @@ -19,7 +19,6 @@ package diffprocessor import ( "context" "fmt" - "io" "os" xp "github.com/crossplane-contrib/crossplane-diff/cmd/diff/client/crossplane" @@ -69,7 +68,7 @@ func (r *XRDiffResult) HasError() bool { type CompDiffProcessor interface { // DiffComposition processes composition changes and shows impact on existing XRs. // Returns (hasDiffs, error) where hasDiffs indicates if any differences were detected. - DiffComposition(ctx context.Context, stdout io.Writer, compositions []*un.Unstructured, namespace string) (bool, error) + DiffComposition(ctx context.Context, compositions []*un.Unstructured, namespace string) (bool, error) Initialize(ctx context.Context) error // Cleanup releases any resources held by the processor (e.g., Docker containers). Cleanup(ctx context.Context) error @@ -104,10 +103,11 @@ func NewCompDiffProcessor(xrProc DiffProcessor, compositionClient xp.Composition config.SetDefaultFactories() // Create diff renderer first (needed by DefaultCompDiffRenderer for human-readable output) - diffRenderer := config.Factories.DiffRenderer(config.Logger, config.GetDiffOptions()) + diffOpts := config.GetDiffOptions() + diffRenderer := config.Factories.DiffRenderer(config.Logger, diffOpts) // Create comp diff renderer using factory - compDiffRenderer := config.Factories.CompDiffRenderer(config.Logger, diffRenderer, config.Colorize) + compDiffRenderer := config.Factories.CompDiffRenderer(config.Logger, diffRenderer, diffOpts) return &DefaultCompDiffProcessor{ compositionClient: compositionClient, @@ -139,7 +139,7 @@ func (p *DefaultCompDiffProcessor) Cleanup(ctx context.Context) error { // DiffComposition processes composition changes and shows impact on existing XRs. // Returns (hasDiffs, error) where hasDiffs indicates if any differences were detected. -func (p *DefaultCompDiffProcessor) DiffComposition(ctx context.Context, stdout io.Writer, compositions []*un.Unstructured, namespace string) (bool, error) { +func (p *DefaultCompDiffProcessor) DiffComposition(ctx context.Context, compositions []*un.Unstructured, namespace string) (bool, error) { p.config.Logger.Debug("Processing composition diff", "compositionCount", len(compositions), "namespace", namespace) if len(compositions) == 0 { @@ -208,16 +208,11 @@ func (p *DefaultCompDiffProcessor) DiffComposition(ctx context.Context, stdout i } // Always render output (even if all compositions failed) to ensure valid structured output - if err := p.compDiffRenderer.RenderCompDiff(stdout, output); err != nil { + // The renderer will include errors in the structured output and write them to stderr + if err := p.compDiffRenderer.RenderCompDiff(output); err != nil { return hasDiffs, errors.Wrap(err, "failed to render composition diff") } - // Emit detailed errors to stderr for human visibility alongside structured output. - // This ensures CI logs show actual error details even when using JSON/YAML output. - for _, err := range output.Errors { - _, _ = fmt.Fprintln(p.config.Stderr, err.FormatError()) - } - // Check for XR processing errors after rendering (so users see the output first). // Return an error so CI/CD pipelines get a non-zero exit code when impact analysis failed. totalXRErrors := len(output.Errors) diff --git a/cmd/diff/diffprocessor/comp_processor_test.go b/cmd/diff/diffprocessor/comp_processor_test.go index ee9576d2..973dcac6 100644 --- a/cmd/diff/diffprocessor/comp_processor_test.go +++ b/cmd/diff/diffprocessor/comp_processor_test.go @@ -20,7 +20,6 @@ import ( "bytes" "context" "fmt" - "io" "strings" "testing" @@ -254,19 +253,24 @@ func TestDefaultCompDiffProcessor_DiffComposition(t *testing.T) { // Create mock XR processor mockXRProc := &tu.MockDiffProcessor{ - PerformDiffFn: func(_ context.Context, stdout io.Writer, _ []*un.Unstructured, _ types.CompositionProvider) (bool, error) { - _, err := stdout.Write([]byte("Mock XR diff output")) - return true, err + PerformDiffFn: func(_ context.Context, _ []*un.Unstructured, _ types.CompositionProvider) (bool, error) { + return true, nil }, } // Create processor using constructor to ensure all fields are initialized logger := tu.TestLogger(t, false) + + // Create stdout buffer first so it can be set in config + var stdout bytes.Buffer + config := ProcessorConfig{ Namespace: tt.namespace, Colorize: false, Compact: false, Logger: logger, + Stdout: &stdout, // Set stdout in config so renderers can access it + Stderr: &bytes.Buffer{}, // Discard stderr for tests RenderFunc: func(_ context.Context, _ logging.Logger, in render.Inputs) (render.Outputs, error) { return render.Outputs{ CompositeResource: in.CompositeResource, @@ -274,8 +278,9 @@ func TestDefaultCompDiffProcessor_DiffComposition(t *testing.T) { }, } config.SetDefaultFactories() - diffRenderer := config.Factories.DiffRenderer(logger, config.GetDiffOptions()) - compDiffRenderer := config.Factories.CompDiffRenderer(logger, diffRenderer, config.Colorize) + diffOpts := config.GetDiffOptions() + diffRenderer := config.Factories.DiffRenderer(logger, diffOpts) + compDiffRenderer := config.Factories.CompDiffRenderer(logger, diffRenderer, diffOpts) processor := &DefaultCompDiffProcessor{ compositionClient: xpClients.Composition, @@ -284,9 +289,7 @@ func TestDefaultCompDiffProcessor_DiffComposition(t *testing.T) { compDiffRenderer: compDiffRenderer, } - var stdout bytes.Buffer - - _, err := processor.DiffComposition(ctx, &stdout, tt.compositions, tt.namespace) + _, err := processor.DiffComposition(ctx, tt.compositions, tt.namespace) if (err != nil) != tt.wantErr { t.Errorf("DiffComposition() error = %v, wantErr %v", err, tt.wantErr) @@ -696,10 +699,8 @@ func TestDefaultCompDiffProcessor_DiffComposition_StderrErrorOutput(t *testing.T WithStderr(&stderrBuf), // Use WithStderr to inject test buffer ) - var stdout bytes.Buffer - // Run the diff - should succeed but report XR errors - _, err := processor.DiffComposition(ctx, &stdout, []*un.Unstructured{ + _, err := processor.DiffComposition(ctx, []*un.Unstructured{ tu.NewComposition("test-composition"). WithCompositeTypeRef("example.org/v1", "XResource"). WithPipelineMode(). diff --git a/cmd/diff/diffprocessor/diff_processor.go b/cmd/diff/diffprocessor/diff_processor.go index a94de44b..30abe9d2 100644 --- a/cmd/diff/diffprocessor/diff_processor.go +++ b/cmd/diff/diffprocessor/diff_processor.go @@ -4,7 +4,6 @@ package diffprocessor import ( "context" "fmt" - "io" "maps" "os" "slices" @@ -54,7 +53,7 @@ type RenderFunc func(ctx context.Context, log logging.Logger, in render.Inputs) type DiffProcessor interface { // PerformDiff processes resources using a composition provider function. // Returns (hasDiffs, error) where hasDiffs indicates if any differences were detected. - PerformDiff(ctx context.Context, stdout io.Writer, resources []*un.Unstructured, compositionProvider types.CompositionProvider) (bool, error) + PerformDiff(ctx context.Context, resources []*un.Unstructured, compositionProvider types.CompositionProvider) (bool, error) // DiffSingleResource processes a single resource and returns its diffs DiffSingleResource(ctx context.Context, res *un.Unstructured, compositionProvider types.CompositionProvider) (map[string]*dt.ResourceDiff, error) @@ -180,7 +179,7 @@ func (p *DefaultDiffProcessor) initializeSchemaValidator(ctx context.Context) er // PerformDiff processes resources using a composition provider function. // Returns (hasDiffs, error) where hasDiffs indicates if any differences were detected. -func (p *DefaultDiffProcessor) PerformDiff(ctx context.Context, stdout io.Writer, resources []*un.Unstructured, compositionProvider types.CompositionProvider) (bool, error) { +func (p *DefaultDiffProcessor) PerformDiff(ctx context.Context, resources []*un.Unstructured, compositionProvider types.CompositionProvider) (bool, error) { p.config.Logger.Debug("Processing resources with composition provider", "count", len(resources)) if len(resources) == 0 { @@ -220,19 +219,13 @@ func (p *DefaultDiffProcessor) PerformDiff(ctx context.Context, stdout io.Writer } // Always render (even if only errors exist) to ensure valid structured output - // The renderer will include errors in the structured output - err := p.diffRenderer.RenderDiffs(stdout, allDiffs, outputErrors) + // The renderer will include errors in the structured output and write them to stderr + err := p.diffRenderer.RenderDiffs(allDiffs, outputErrors) if err != nil { p.config.Logger.Debug("Failed to render diffs", "error", err) errs = append(errs, errors.Wrap(err, "failed to render diffs")) } - // Emit detailed errors to stderr for human visibility alongside structured output. - // This ensures CI logs show actual error details even when using JSON/YAML output. - for _, outputErr := range outputErrors { - _, _ = fmt.Fprintln(p.config.Stderr, outputErr.FormatError()) - } - // Count only non-equal diffs as "having diffs". // The diffs map may contain DiffTypeEqual entries (e.g., XR stored for removal detection). hasDiffs := false @@ -469,6 +462,7 @@ func (p *DefaultDiffProcessor) diffSingleResourceInternal(ctx context.Context, r p.config.Logger.Debug("Error detecting removed resources - failing XR", "resource", resourceID, "error", removalErr) return nil, nil, errors.Wrap(removalErr, "cannot detect removed resources") } + if len(removedDiffs) > 0 { maps.Copy(diffs, removedDiffs) p.config.Logger.Debug("Found removed resources", "resource", resourceID, "removedCount", len(removedDiffs)) diff --git a/cmd/diff/diffprocessor/diff_processor_test.go b/cmd/diff/diffprocessor/diff_processor_test.go index b3f9706e..354845de 100644 --- a/cmd/diff/diffprocessor/diff_processor_test.go +++ b/cmd/diff/diffprocessor/diff_processor_test.go @@ -4,7 +4,6 @@ import ( "bytes" "context" "fmt" - "io" "strings" "testing" @@ -172,19 +171,10 @@ func TestDefaultDiffProcessor_PerformDiff(t *testing.T) { }, resources: []*un.Unstructured{resource1}, processorOpts: testProcessorOptions(t), - verifyOutput: func(t *testing.T, output string) { - t.Helper() - // Verify that the error message was written to stdout - // Format: ERROR: {ResourceID}: {Message} - if !strings.Contains(output, "ERROR: XR1/my-xr-1:") { - t.Errorf("Expected stdout to contain error message, got: %s", output) - } - // Also verify it contains the composition not found error - if !strings.Contains(output, "composition not found") { - t.Errorf("Expected stdout to contain 'composition not found' error detail, got: %s", output) - } - }, - want: errors.New("unable to process resource XR1/my-xr-1: cannot get composition: composition not found"), + // Note: Error output now goes to stderr (see TestDefaultDiffProcessor_PerformDiff_StderrErrorOutput) + // This test verifies the error return value, not stderr output + verifyOutput: nil, + want: errors.New("unable to process resource XR1/my-xr-1: cannot get composition: composition not found"), }, "MultipleResourceErrors": { setupMocks: func() (k8.Clients, xp.Clients) { @@ -214,23 +204,9 @@ func TestDefaultDiffProcessor_PerformDiff(t *testing.T) { }, resources: []*un.Unstructured{resource1, resource2}, processorOpts: testProcessorOptions(t), - verifyOutput: func(t *testing.T, output string) { - t.Helper() - // Verify that error messages for both resources were written to stdout - // Format: ERROR: {ResourceID}: {Message} - if !strings.Contains(output, "ERROR: XR1/my-xr-1:") { - t.Errorf("Expected stdout to contain error message for my-xr-1, got: %s", output) - } - - if !strings.Contains(output, "ERROR: XR1/my-xr-2:") { - t.Errorf("Expected stdout to contain error message for my-xr-2, got: %s", output) - } - // Both should contain the composition not found error - expectedCount := strings.Count(output, "composition not found") - if expectedCount < 2 { - t.Errorf("Expected stdout to contain 'composition not found' at least twice, found %d times in: %s", expectedCount, output) - } - }, + // Note: Error output now goes to stderr (see TestDefaultDiffProcessor_PerformDiff_StderrErrorOutput) + // This test verifies the error return value, not stderr output + verifyOutput: nil, want: errors.New("[unable to process resource XR1/my-xr-1: cannot get composition: composition not found, " + "unable to process resource XR1/my-xr-2: cannot get composition: composition not found]"), }, @@ -481,10 +457,13 @@ func TestDefaultDiffProcessor_PerformDiff(t *testing.T) { } }), // Override the diff renderer factory to produce actual output - WithDiffRendererFactory(func(logging.Logger, renderer.DiffOptions) renderer.DiffRenderer { + // The factory receives DiffOptions which contains Stdout where output should be written + WithDiffRendererFactory(func(_ logging.Logger, opts renderer.DiffOptions) renderer.DiffRenderer { return &tu.MockDiffRenderer{ - RenderDiffsFn: func(w io.Writer, _ map[string]*dt.ResourceDiff, _ []dt.OutputError) error { - // Write a simple summary to the output + RenderDiffsFn: func(_ map[string]*dt.ResourceDiff, _ []dt.OutputError) error { + // Write a simple summary to the output via opts.Stdout + w := opts.Stdout + _, err := fmt.Fprintln(w, "Changes will be applied to 2 resources:") if err != nil { return err @@ -615,17 +594,20 @@ func TestDefaultDiffProcessor_PerformDiff(t *testing.T) { // Create components for testing k8sClients, xpClients := tt.setupMocks() - // Create the diff processor - processor := NewDiffProcessor(k8sClients, xpClients, tt.processorOpts...) - - // Create a dummy writer for stdout + // Create stdout buffer and add it to processor options so renderers can access it var stdout bytes.Buffer + opts := append([]ProcessorOption{}, tt.processorOpts...) + opts = append(opts, WithStdout(&stdout)) + + // Create the diff processor + processor := NewDiffProcessor(k8sClients, xpClients, opts...) + // Create a mock composition provider that uses the same mock composition client compositionProvider := func(ctx context.Context, res *un.Unstructured) (*apiextensionsv1.Composition, error) { return xpClients.Composition.FindMatchingComposition(ctx, res) } - _, err := processor.PerformDiff(ctx, &stdout, tt.resources, compositionProvider) + _, err := processor.PerformDiff(ctx, tt.resources, compositionProvider) // Check output if verification function is provided (do this first, before error checks) if tt.verifyOutput != nil { @@ -695,16 +677,13 @@ func TestDefaultDiffProcessor_PerformDiff_StderrErrorOutput(t *testing.T) { )..., ) - // Create a dummy writer for stdout - var stdout bytes.Buffer - // Create composition provider using mock client compositionProvider := func(ctx context.Context, res *un.Unstructured) (*apiextensionsv1.Composition, error) { return xpClients.Composition.FindMatchingComposition(ctx, res) } // Run the diff - _, err := processor.PerformDiff(ctx, &stdout, []*un.Unstructured{resource}, compositionProvider) + _, err := processor.PerformDiff(ctx, []*un.Unstructured{resource}, compositionProvider) // Should return an error if err == nil { @@ -2503,8 +2482,8 @@ func TestDefaultDiffProcessor_DiffSingleResource_WithObservedResources(t *testin wantObservedInRender: true, wantObservedCount: 0, // Should pass empty list when fetch fails verifyObservedPassed: true, - wantErr: true, // Should return partial error so user knows removal detection failed - wantErrContain: "cannot get resource tree", // The resource tree error is now surfaced + wantErr: true, // Should return partial error so user knows removal detection failed + wantErrContain: "cannot get resource tree", // The resource tree error is now surfaced }, } diff --git a/cmd/diff/diffprocessor/processor_config.go b/cmd/diff/diffprocessor/processor_config.go index 570536c7..a59656f3 100644 --- a/cmd/diff/diffprocessor/processor_config.go +++ b/cmd/diff/diffprocessor/processor_config.go @@ -49,6 +49,9 @@ type ProcessorConfig struct { // FunctionCredentials holds Secret credentials to pass to Functions during rendering FunctionCredentials []corev1.Secret + // Stdout is the writer for diff output (defaults to os.Stdout) + Stdout io.Writer + // Stderr is the writer for error output (defaults to os.Stderr) Stderr io.Writer @@ -80,7 +83,7 @@ type ComponentFactories struct { DiffRenderer func(logger logging.Logger, diffOptions renderer.DiffOptions) renderer.DiffRenderer // CompDiffRenderer creates a CompDiffRenderer for composition diffs - CompDiffRenderer func(logger logging.Logger, diffRenderer renderer.DiffRenderer, colorize bool) renderer.CompDiffRenderer + CompDiffRenderer func(logger logging.Logger, diffRenderer renderer.DiffRenderer, opts renderer.DiffOptions) renderer.CompDiffRenderer // RequirementsProvider creates an ExtraResourceProvider RequirementsProvider func(res k8.ResourceClient, def xp.EnvironmentClient, renderFunc RenderFunc, logger logging.Logger) *RequirementsProvider @@ -166,6 +169,13 @@ func WithFunctionCredentials(creds []corev1.Secret) ProcessorOption { } } +// WithStdout sets the writer for diff output. +func WithStdout(w io.Writer) ProcessorOption { + return func(config *ProcessorConfig) { + config.Stdout = w + } +} + // WithStderr sets the writer for error output. func WithStderr(w io.Writer) ProcessorOption { return func(config *ProcessorConfig) { @@ -242,6 +252,18 @@ func (c *ProcessorConfig) GetDiffOptions() renderer.DiffOptions { opts.UseColors = c.Colorize opts.Compact = c.Compact opts.IgnorePaths = c.IgnorePaths + if c.OutputFormat != "" { + opts.Format = c.OutputFormat + } + + // Use config's Stdout/Stderr if set, otherwise keep defaults (os.Stdout/os.Stderr) + if c.Stdout != nil { + opts.Stdout = c.Stdout + } + + if c.Stderr != nil { + opts.Stderr = c.Stderr + } return opts } @@ -264,9 +286,7 @@ func (c *ProcessorConfig) SetDefaultFactories() { // Set the appropriate renderer factory based on output format switch c.OutputFormat { case renderer.OutputFormatJSON, renderer.OutputFormatYAML: - c.Factories.DiffRenderer = func(logger logging.Logger, _ renderer.DiffOptions) renderer.DiffRenderer { - return renderer.NewStructuredDiffRenderer(logger, c.OutputFormat) - } + c.Factories.DiffRenderer = renderer.NewStructuredDiffRenderer case renderer.OutputFormatDiff: c.Factories.DiffRenderer = renderer.NewDiffRenderer default: @@ -278,8 +298,8 @@ func (c *ProcessorConfig) SetDefaultFactories() { // Set the appropriate renderer factory based on output format switch c.OutputFormat { case renderer.OutputFormatJSON, renderer.OutputFormatYAML: - c.Factories.CompDiffRenderer = func(logger logging.Logger, _ renderer.DiffRenderer, _ bool) renderer.CompDiffRenderer { - return renderer.NewStructuredCompDiffRenderer(logger, c.OutputFormat) + c.Factories.CompDiffRenderer = func(logger logging.Logger, _ renderer.DiffRenderer, opts renderer.DiffOptions) renderer.CompDiffRenderer { + return renderer.NewStructuredCompDiffRenderer(logger, opts) } case renderer.OutputFormatDiff: fallthrough diff --git a/cmd/diff/diffprocessor/processor_config_test.go b/cmd/diff/diffprocessor/processor_config_test.go index 78160ef2..92db3c51 100644 --- a/cmd/diff/diffprocessor/processor_config_test.go +++ b/cmd/diff/diffprocessor/processor_config_test.go @@ -1,6 +1,9 @@ package diffprocessor import ( + "bytes" + "io" + "os" "testing" xp "github.com/crossplane-contrib/crossplane-diff/cmd/diff/client/crossplane" @@ -100,6 +103,66 @@ func TestDiffOptions(t *testing.T) { } } +// TestGetDiffOptions_PropagatesStdoutStderrFormat verifies that when a ProcessorConfig +// has Stdout, Stderr, and OutputFormat set, those values flow through to the returned +// DiffOptions. When Stdout/Stderr are nil, the defaults (os.Stdout/os.Stderr) must be +// preserved. +func TestGetDiffOptions_PropagatesStdoutStderrFormat(t *testing.T) { + t.Run("CustomWritersAndFormatPropagate", func(t *testing.T) { + var ( + stdout bytes.Buffer + stderr bytes.Buffer + ) + + config := ProcessorConfig{ + Colorize: true, + Stdout: &stdout, + Stderr: &stderr, + OutputFormat: renderer.OutputFormatJSON, + } + + got := config.GetDiffOptions() + + if got.Stdout != io.Writer(&stdout) { + t.Errorf("Expected Stdout to be the injected buffer, got: %v", got.Stdout) + } + + if got.Stderr != io.Writer(&stderr) { + t.Errorf("Expected Stderr to be the injected buffer, got: %v", got.Stderr) + } + + if got.Format != renderer.OutputFormatJSON { + t.Errorf("Expected Format to be %q, got %q", renderer.OutputFormatJSON, got.Format) + } + }) + + t.Run("ZeroValuesKeepDefaults", func(t *testing.T) { + config := ProcessorConfig{ + Colorize: true, + // Stdout, Stderr, and OutputFormat intentionally left at zero value + } + + got := config.GetDiffOptions() + + // When the config's Stdout/Stderr are nil, DefaultDiffOptions() defaults + // (os.Stdout/os.Stderr) should be preserved. + if got.Stdout != io.Writer(os.Stdout) { + t.Errorf("Expected default os.Stdout when config.Stdout is nil, got: %v", got.Stdout) + } + + if got.Stderr != io.Writer(os.Stderr) { + t.Errorf("Expected default os.Stderr when config.Stderr is nil, got: %v", got.Stderr) + } + + // When the config's OutputFormat is the zero value (""), DefaultDiffOptions() + // default (OutputFormatDiff) should be preserved rather than overwritten with "". + if got.Format != renderer.OutputFormatDiff { + t.Errorf("Expected default Format %q when config.OutputFormat is empty, got %q", + renderer.OutputFormatDiff, got.Format) + } + }) +} + func TestWithOptions(t *testing.T) { tests := []struct { name string @@ -163,3 +226,25 @@ func TestWithOptions(t *testing.T) { }) } } + +// TestWithStdoutStderr verifies that WithStdout and WithStderr set the +// corresponding writers on the ProcessorConfig. +func TestWithStdoutStderr(t *testing.T) { + var ( + stdout bytes.Buffer + stderr bytes.Buffer + ) + + config := ProcessorConfig{} + + WithStdout(&stdout)(&config) + WithStderr(&stderr)(&config) + + if config.Stdout != io.Writer(&stdout) { + t.Errorf("Expected config.Stdout to be the injected buffer, got: %v", config.Stdout) + } + + if config.Stderr != io.Writer(&stderr) { + t.Errorf("Expected config.Stderr to be the injected buffer, got: %v", config.Stderr) + } +} diff --git a/cmd/diff/diffprocessor/schema_validator.go b/cmd/diff/diffprocessor/schema_validator.go index 5ae476cb..d1f8bab0 100644 --- a/cmd/diff/diffprocessor/schema_validator.go +++ b/cmd/diff/diffprocessor/schema_validator.go @@ -1,8 +1,11 @@ package diffprocessor import ( + "bytes" "context" "fmt" + "io" + "strings" xp "github.com/crossplane-contrib/crossplane-diff/cmd/diff/client/crossplane" k8 "github.com/crossplane-contrib/crossplane-diff/cmd/diff/client/kubernetes" @@ -103,17 +106,22 @@ func (v *DefaultSchemaValidator) ValidateResources(ctx context.Context, xr *un.U return errors.Wrap(err, "unable to ensure CRDs") } - // Create a logger writer to capture output - loggerWriter := loggerwriter.NewLoggerWriter(v.logger) + // Create a buffer to capture validation output for error messages, + // and a MultiWriter to also send output to debug logs + var validationOutput bytes.Buffer + + multiWriter := io.MultiWriter(&validationOutput, loggerwriter.NewLoggerWriter(v.logger)) // Note: SchemaValidation applies defaults IN-PLACE to resources, so we must pass // the original resources (not sanitized copies) to get defaults applied. // We strip Crossplane-managed fields AFTER validation for cleaner error messages. v.logger.Debug("Performing schema validation", "resourceCount", len(resources)) - err = validate.SchemaValidation(ctx, resources, v.schemaClient.GetAllCRDs(), true, true, loggerWriter) + err = validate.SchemaValidation(ctx, resources, v.schemaClient.GetAllCRDs(), true, true, multiWriter) if err != nil { - return NewSchemaValidationError("", "schema validation failed", err) + // Parse and extract only the error lines from validation output + details := extractValidationErrors(validationOutput.String()) + return NewSchemaValidationError("", details, err) } // Strip Crossplane-managed fields from resources after validation @@ -245,6 +253,29 @@ func (v *DefaultSchemaValidator) ValidateScopeConstraints(ctx context.Context, r return nil } +// extractValidationErrors parses validation output and returns clean error messages. +// It extracts lines starting with [x] (validation errors) and [!] (warnings/missing schemas), +// stripping the prefixes for cleaner display. +func extractValidationErrors(output string) string { + var validationErrs []string + + for line := range strings.SplitSeq(output, "\n") { + line = strings.TrimSpace(line) + // Use CutPrefix to check for prefix and strip it in one operation + if cleaned, found := strings.CutPrefix(line, "[x]"); found { + validationErrs = append(validationErrs, strings.TrimSpace(cleaned)) + } else if cleaned, found := strings.CutPrefix(line, "[!]"); found { + validationErrs = append(validationErrs, strings.TrimSpace(cleaned)) + } + } + + if len(validationErrs) == 0 { + return "schema validation failed" + } + + return strings.Join(validationErrs, "; ") +} + // stripCrossplaneManagedFields creates a copy of the resource with Crossplane-managed fields removed // These fields are set by Crossplane controllers and may not be present in the CRD schema. func (v *DefaultSchemaValidator) stripCrossplaneManagedFields(resource *un.Unstructured) *un.Unstructured { diff --git a/cmd/diff/diffprocessor/schema_validator_test.go b/cmd/diff/diffprocessor/schema_validator_test.go index ca502d7f..d41cd13e 100644 --- a/cmd/diff/diffprocessor/schema_validator_test.go +++ b/cmd/diff/diffprocessor/schema_validator_test.go @@ -162,7 +162,7 @@ func TestDefaultSchemaValidator_ValidateResources(t *testing.T) { composed: []cpd.Unstructured{*composedResource1, *composedResource2}, preloadedCRDs: []*extv1.CustomResourceDefinition{createCRDWithStringField(xrCRD)}, expectedErr: true, - expectedErrMsg: "schema validation failed", + expectedErrMsg: "could not find CRD/XRD", }, } @@ -534,6 +534,55 @@ func TestDefaultSchemaValidator_ValidateResources_AppliesDefaults(t *testing.T) // The compositionRevisionRef remains in the original resource, which is correct behavior. } +func TestExtractValidationErrors(t *testing.T) { + tests := map[string]struct { + input string + expected string + }{ + "SingleValidationError": { + input: "[x] schema validation error example.org/v1, my-xr : spec.region: Required value\nTotal 1 resources: 0 missing schemas, 0 success cases, 1 failure cases", + expected: "schema validation error example.org/v1, my-xr : spec.region: Required value", + }, + "SingleMissingSchema": { + input: "[!] could not find CRD/XRD for: other.org/v1, Kind=SomeResource\nTotal 1 resources: 1 missing schemas, 0 success cases, 0 failure cases", + expected: "could not find CRD/XRD for: other.org/v1, Kind=SomeResource", + }, + "MultipleErrors": { + input: "[x] schema validation error example.org/v1, my-xr : spec.region: Required value\n[!] could not find CRD/XRD for: other.org/v1\nTotal 2 resources: 1 missing schemas, 0 success cases, 1 failure cases", + expected: "schema validation error example.org/v1, my-xr : spec.region: Required value; could not find CRD/XRD for: other.org/v1", + }, + "MixedWithSuccessLines": { + input: "[✓] example.org/v1, good-xr validated successfully\n[x] schema validation error example.org/v1, bad-xr : spec.field: Invalid value\nTotal 2 resources: 0 missing schemas, 1 success cases, 1 failure cases", + expected: "schema validation error example.org/v1, bad-xr : spec.field: Invalid value", + }, + "EmptyInput": { + input: "", + expected: "schema validation failed", + }, + "NoErrorsOnlySuccess": { + input: "[✓] example.org/v1, my-xr validated successfully\nTotal 1 resources: 0 missing schemas, 1 success cases, 0 failure cases", + expected: "schema validation failed", + }, + "WhitespaceHandling": { + input: " [x] error message with leading spaces \n", + expected: "error message with leading spaces", + }, + "MultipleValidationErrors": { + input: "[x] error one\n[x] error two\n[x] error three", + expected: "error one; error two; error three", + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + result := extractValidationErrors(tt.input) + if result != tt.expected { + t.Errorf("extractValidationErrors() = %q, want %q", result, tt.expected) + } + }) + } +} + func TestDefaultSchemaValidator_ValidateScopeConstraints(t *testing.T) { ctx := t.Context() diff --git a/cmd/diff/renderer/comp_diff_renderer.go b/cmd/diff/renderer/comp_diff_renderer.go index 114c6b0d..94e04732 100644 --- a/cmd/diff/renderer/comp_diff_renderer.go +++ b/cmd/diff/renderer/comp_diff_renderer.go @@ -19,7 +19,6 @@ package renderer import ( "encoding/json" "fmt" - "io" "strings" dt "github.com/crossplane-contrib/crossplane-diff/cmd/diff/renderer/types" @@ -33,28 +32,31 @@ import ( // Both human-readable and structured (JSON/YAML) renderers implement this interface. type CompDiffRenderer interface { // RenderCompDiff renders the complete composition diff output. - // This includes composition changes, affected XR list, and impact analysis. - RenderCompDiff(stdout io.Writer, output *CompDiffOutput) error + // Diff output goes to DiffOptions.Stdout, errors go to DiffOptions.Stderr. + RenderCompDiff(output *CompDiffOutput) error } // DefaultCompDiffRenderer renders composition diffs in human-readable format. type DefaultCompDiffRenderer struct { logger logging.Logger diffRenderer DiffRenderer - colorize bool + opts DiffOptions } // NewDefaultCompDiffRenderer creates a new human-readable composition diff renderer. -func NewDefaultCompDiffRenderer(logger logging.Logger, diffRenderer DiffRenderer, colorize bool) CompDiffRenderer { +func NewDefaultCompDiffRenderer(logger logging.Logger, diffRenderer DiffRenderer, opts DiffOptions) CompDiffRenderer { return &DefaultCompDiffRenderer{ logger: logger, diffRenderer: diffRenderer, - colorize: colorize, + opts: opts, } } // RenderCompDiff renders the composition diff in human-readable format. -func (r *DefaultCompDiffRenderer) RenderCompDiff(stdout io.Writer, output *CompDiffOutput) error { +// Diff output goes to r.opts.Stdout, errors go to r.opts.Stderr. +func (r *DefaultCompDiffRenderer) RenderCompDiff(output *CompDiffOutput) error { + stdout := r.opts.Stdout + for i, comp := range output.Compositions { if i > 0 { if _, err := fmt.Fprint(stdout, "\n"+strings.Repeat("=", 80)+"\n\n"); err != nil { @@ -63,7 +65,7 @@ func (r *DefaultCompDiffRenderer) RenderCompDiff(stdout io.Writer, output *CompD } // Render composition changes section - if err := r.renderCompositionChanges(stdout, &comp); err != nil { + if err := r.renderCompositionChanges(&comp); err != nil { return err } @@ -73,21 +75,30 @@ func (r *DefaultCompDiffRenderer) RenderCompDiff(stdout io.Writer, output *CompD } // Render affected XRs list with status indicators - if err := r.renderAffectedResourcesList(stdout, &comp); err != nil { + if err := r.renderAffectedResourcesList(&comp); err != nil { return err } // Render impact analysis (downstream diffs) - if err := r.renderImpactAnalysis(stdout, &comp); err != nil { + if err := r.renderImpactAnalysis(&comp); err != nil { return err } } + // Write top-level errors to stderr + for _, e := range output.Errors { + if _, err := fmt.Fprintln(r.opts.Stderr, e.FormatError()); err != nil { + return errors.Wrap(err, "failed to write error to stderr") + } + } + return nil } // renderCompositionChanges renders the composition changes section. -func (r *DefaultCompDiffRenderer) renderCompositionChanges(stdout io.Writer, comp *CompositionDiff) error { +func (r *DefaultCompDiffRenderer) renderCompositionChanges(comp *CompositionDiff) error { + stdout := r.opts.Stdout + if _, err := fmt.Fprintf(stdout, "=== Composition Changes ===\n\n"); err != nil { return errors.Wrap(err, "cannot write composition changes header") } @@ -113,7 +124,7 @@ func (r *DefaultCompDiffRenderer) renderCompositionChanges(stdout io.Writer, com fmt.Sprintf("Composition/%s", comp.Name): comp.CompositionDiff, } - if err := r.diffRenderer.RenderDiffs(stdout, diffs, nil); err != nil { + if err := r.diffRenderer.RenderDiffs(diffs, nil); err != nil { return errors.Wrap(err, "cannot render composition diff") } @@ -125,7 +136,9 @@ func (r *DefaultCompDiffRenderer) renderCompositionChanges(stdout io.Writer, com } // renderAffectedResourcesList renders the affected XRs list with status indicators. -func (r *DefaultCompDiffRenderer) renderAffectedResourcesList(stdout io.Writer, comp *CompositionDiff) error { +func (r *DefaultCompDiffRenderer) renderAffectedResourcesList(comp *CompositionDiff) error { + stdout := r.opts.Stdout + if len(comp.ImpactAnalysis) == 0 { // Check if all resources were filtered by policy if comp.AffectedResources.FilteredByPolicy > 0 { @@ -161,7 +174,9 @@ func (r *DefaultCompDiffRenderer) renderAffectedResourcesList(stdout io.Writer, } // renderImpactAnalysis renders the impact analysis section with downstream diffs. -func (r *DefaultCompDiffRenderer) renderImpactAnalysis(stdout io.Writer, comp *CompositionDiff) error { +func (r *DefaultCompDiffRenderer) renderImpactAnalysis(comp *CompositionDiff) error { + stdout := r.opts.Stdout + if _, err := fmt.Fprintf(stdout, "=== Impact Analysis ===\n\n"); err != nil { return errors.Wrap(err, "cannot write impact analysis header") } @@ -182,7 +197,7 @@ func (r *DefaultCompDiffRenderer) renderImpactAnalysis(stdout io.Writer, comp *C // Render all diffs if we found some, or show a message if empty if len(allDiffs) > 0 { - if err := r.diffRenderer.RenderDiffs(stdout, allDiffs, nil); err != nil { + if err := r.diffRenderer.RenderDiffs(allDiffs, nil); err != nil { r.logger.Debug("Failed to render diffs", "error", err) return errors.Wrap(err, "failed to render diffs") } @@ -208,7 +223,7 @@ func (r *DefaultCompDiffRenderer) buildXRStatusList(impacts []XRImpact) string { colorRed := "" colorReset := "" - if r.colorize { + if r.opts.UseColors { colorGreen = dt.ColorGreen colorYellow = dt.ColorYellow colorRed = dt.ColorRed @@ -255,19 +270,20 @@ func (r *DefaultCompDiffRenderer) buildXRStatusList(impacts []XRImpact) string { // StructuredCompDiffRenderer renders composition diffs in JSON/YAML format. type StructuredCompDiffRenderer struct { logger logging.Logger - format OutputFormat + opts DiffOptions } // NewStructuredCompDiffRenderer creates a new structured composition diff renderer. -func NewStructuredCompDiffRenderer(logger logging.Logger, format OutputFormat) CompDiffRenderer { +func NewStructuredCompDiffRenderer(logger logging.Logger, opts DiffOptions) CompDiffRenderer { return &StructuredCompDiffRenderer{ logger: logger, - format: format, + opts: opts, } } // RenderCompDiff renders the composition diff in structured format (JSON/YAML). -func (r *StructuredCompDiffRenderer) RenderCompDiff(stdout io.Writer, output *CompDiffOutput) error { +// Diff output goes to r.opts.Stdout, errors go to r.opts.Stderr. +func (r *StructuredCompDiffRenderer) RenderCompDiff(output *CompDiffOutput) error { // Convert internal representation to JSON output structure jsonOutput := r.buildStructuredCompOutput(output) @@ -276,7 +292,7 @@ func (r *StructuredCompDiffRenderer) RenderCompDiff(stdout io.Writer, output *Co err error ) - switch r.format { + switch r.opts.Format { case OutputFormatJSON: data, err = json.MarshalIndent(jsonOutput, "", " ") case OutputFormatYAML: @@ -284,16 +300,26 @@ func (r *StructuredCompDiffRenderer) RenderCompDiff(stdout io.Writer, output *Co case OutputFormatDiff: fallthrough default: - return errors.Errorf("unsupported format for structured comp diff renderer: %s", r.format) + return errors.Errorf("unsupported format for structured comp diff renderer: %s", r.opts.Format) } if err != nil { return errors.Wrap(err, "failed to marshal comp diff output") } - _, err = stdout.Write(append(data, '\n')) + _, err = r.opts.Stdout.Write(append(data, '\n')) + if err != nil { + return errors.Wrap(err, "failed to write output") + } + + // Write errors to stderr for human visibility (they're also included in the structured output) + for _, e := range output.Errors { + if _, err := fmt.Fprintln(r.opts.Stderr, e.FormatError()); err != nil { + return errors.Wrap(err, "failed to write error to stderr") + } + } - return errors.Wrap(err, "failed to write output") + return nil } // buildStructuredCompOutput converts internal CompDiffOutput to JSON-serializable structure. diff --git a/cmd/diff/renderer/comp_diff_renderer_test.go b/cmd/diff/renderer/comp_diff_renderer_test.go index c21e3156..f7c5d8a8 100644 --- a/cmd/diff/renderer/comp_diff_renderer_test.go +++ b/cmd/diff/renderer/comp_diff_renderer_test.go @@ -155,11 +155,17 @@ func TestStructuredCompDiffRenderer_RenderCompDiff(t *testing.T) { testName := string(format) + "/" + fixture.name t.Run(testName, func(t *testing.T) { logger := tu.TestLogger(t, false) - renderer := NewStructuredCompDiffRenderer(logger, format) var buf bytes.Buffer - err := renderer.RenderCompDiff(&buf, fixture.output) + opts := DefaultDiffOptions() + opts.Format = format + opts.Stdout = &buf + opts.Stderr = &bytes.Buffer{} // discard stderr + + renderer := NewStructuredCompDiffRenderer(logger, opts) + + err := renderer.RenderCompDiff(fixture.output) if err != nil { t.Fatalf("RenderCompDiff() failed: %v", err) } @@ -271,12 +277,18 @@ func TestDefaultCompDiffRenderer_RenderCompDiff(t *testing.T) { for name, tt := range tests { t.Run(name, func(t *testing.T) { logger := tu.TestLogger(t, false) - diffRenderer := NewDiffRenderer(logger, DefaultDiffOptions()) - renderer := NewDefaultCompDiffRenderer(logger, diffRenderer, tt.colorize) var buf bytes.Buffer - err := renderer.RenderCompDiff(&buf, tt.output) + opts := DefaultDiffOptions() + opts.UseColors = tt.colorize + opts.Stdout = &buf + opts.Stderr = &bytes.Buffer{} // discard stderr + + diffRenderer := NewDiffRenderer(logger, opts) + renderer := NewDefaultCompDiffRenderer(logger, diffRenderer, opts) + + err := renderer.RenderCompDiff(tt.output) if err != nil { t.Fatalf("RenderCompDiff() failed: %v", err) } @@ -286,6 +298,101 @@ func TestDefaultCompDiffRenderer_RenderCompDiff(t *testing.T) { } } +// TestDefaultCompDiffRenderer_RenderCompDiff_TopLevelErrorsToStderr verifies that +// top-level errors in CompDiffOutput.Errors are written to stderr (not stdout) +// to follow Unix conventions. +func TestDefaultCompDiffRenderer_RenderCompDiff_TopLevelErrorsToStderr(t *testing.T) { + output := &CompDiffOutput{ + Compositions: []CompositionDiff{}, + Errors: []dt.OutputError{ + {ResourceID: "xbuckets.example.org", Message: "failed to list XRs for composition"}, + {Message: "cluster connection lost"}, + }, + } + + logger := tu.TestLogger(t, false) + + var ( + stdout bytes.Buffer + stderr bytes.Buffer + ) + + opts := DefaultDiffOptions() + opts.UseColors = false + opts.Stdout = &stdout + opts.Stderr = &stderr + + diffRenderer := NewDiffRenderer(logger, opts) + renderer := NewDefaultCompDiffRenderer(logger, diffRenderer, opts) + + if err := renderer.RenderCompDiff(output); err != nil { + t.Fatalf("RenderCompDiff() failed: %v", err) + } + + stderrOut := stderr.String() + for _, e := range output.Errors { + if !strings.Contains(stderrOut, e.FormatError()) { + t.Errorf("Expected stderr to contain %q, got: %q", e.FormatError(), stderrOut) + } + + // Verify errors were NOT written to stdout + if strings.Contains(stdout.String(), e.FormatError()) { + t.Errorf("Expected stdout to NOT contain error %q, got: %q", e.FormatError(), stdout.String()) + } + } +} + +// TestStructuredCompDiffRenderer_RenderCompDiff_TopLevelErrorsToStderr verifies that +// top-level errors in CompDiffOutput.Errors are written to stderr in addition to being +// included in the structured output. +func TestStructuredCompDiffRenderer_RenderCompDiff_TopLevelErrorsToStderr(t *testing.T) { + output := &CompDiffOutput{ + Compositions: []CompositionDiff{}, + Errors: []dt.OutputError{ + {ResourceID: "xbuckets.example.org", Message: "failed to list XRs for composition"}, + {Message: "cluster connection lost"}, + }, + } + + for _, format := range []OutputFormat{OutputFormatJSON, OutputFormatYAML} { + t.Run(string(format), func(t *testing.T) { + logger := tu.TestLogger(t, false) + + var ( + stdout bytes.Buffer + stderr bytes.Buffer + ) + + opts := DefaultDiffOptions() + opts.Format = format + opts.Stdout = &stdout + opts.Stderr = &stderr + + renderer := NewStructuredCompDiffRenderer(logger, opts) + + if err := renderer.RenderCompDiff(output); err != nil { + t.Fatalf("RenderCompDiff() failed: %v", err) + } + + // Verify errors in stderr + stderrOut := stderr.String() + for _, e := range output.Errors { + if !strings.Contains(stderrOut, e.FormatError()) { + t.Errorf("Expected stderr to contain %q, got: %q", e.FormatError(), stderrOut) + } + } + + // Verify errors are ALSO in structured output (stdout) + stdoutStr := stdout.String() + for _, e := range output.Errors { + if !strings.Contains(stdoutStr, e.Message) { + t.Errorf("Expected stdout to contain error message %q, got: %q", e.Message, stdoutStr) + } + } + }) + } +} + func Test_formatXRStatusSummary(t *testing.T) { tests := map[string]struct { changed, unchanged, errors int @@ -370,10 +477,17 @@ func TestCompDiffOutput_JSONSchema(t *testing.T) { // Test via the structured renderer (JSON) logger := tu.TestLogger(t, false) - jsonRenderer := NewStructuredCompDiffRenderer(logger, OutputFormatJSON) var jsonBuf bytes.Buffer - if err := jsonRenderer.RenderCompDiff(&jsonBuf, output); err != nil { + + opts := DefaultDiffOptions() + opts.Format = OutputFormatJSON + opts.Stdout = &jsonBuf + opts.Stderr = &bytes.Buffer{} // discard stderr + + jsonRenderer := NewStructuredCompDiffRenderer(logger, opts) + + if err := jsonRenderer.RenderCompDiff(output); err != nil { t.Fatalf("Failed to render JSON: %v", err) } diff --git a/cmd/diff/renderer/diff_formatter.go b/cmd/diff/renderer/diff_formatter.go index 3b08cce9..31454808 100644 --- a/cmd/diff/renderer/diff_formatter.go +++ b/cmd/diff/renderer/diff_formatter.go @@ -4,6 +4,8 @@ package renderer import ( "context" "fmt" + "io" + "os" "strings" t "github.com/crossplane-contrib/crossplane-diff/cmd/diff/renderer/types" @@ -19,6 +21,16 @@ import ( // DiffOptions holds configuration options for the diff output. type DiffOptions struct { + // Stdout is the writer for diff output (defaults to os.Stdout) + Stdout io.Writer + + // Stderr is the writer for error output (defaults to os.Stderr) + // Errors are written here following Unix conventions (errors to stderr, output to stdout) + Stderr io.Writer + + // Format specifies the output format (diff, json, yaml) + Format OutputFormat + // UseColors determines whether to colorize the output UseColors bool @@ -49,6 +61,9 @@ type DiffOptions struct { // DefaultDiffOptions returns the default options with colors enabled. func DefaultDiffOptions() DiffOptions { return DiffOptions{ + Stdout: os.Stdout, + Stderr: os.Stderr, + Format: OutputFormatDiff, UseColors: true, AddPrefix: "+ ", DeletePrefix: "- ", diff --git a/cmd/diff/renderer/diff_renderer.go b/cmd/diff/renderer/diff_renderer.go index fa9a98be..8e1fa281 100644 --- a/cmd/diff/renderer/diff_renderer.go +++ b/cmd/diff/renderer/diff_renderer.go @@ -3,7 +3,6 @@ package renderer import ( "cmp" "fmt" - "io" "maps" "slices" "strings" @@ -16,9 +15,10 @@ import ( // DiffRenderer handles rendering diffs to output. type DiffRenderer interface { - // RenderDiffs formats and outputs diffs to the provided writer. + // RenderDiffs formats and outputs diffs. + // Diff output goes to DiffOptions.Stdout, errors go to DiffOptions.Stderr. // The errs parameter contains any resource processing errors to include in output. - RenderDiffs(stdout io.Writer, diffs map[string]*dt.ResourceDiff, errs []dt.OutputError) error + RenderDiffs(diffs map[string]*dt.ResourceDiff, errs []dt.OutputError) error } // DefaultDiffRenderer implements the DiffRenderer interface. @@ -50,15 +50,18 @@ func getKindName(d *dt.ResourceDiff) string { return fmt.Sprintf("%s/%s", d.Gvk.Kind, d.ResourceName) } -// RenderDiffs formats and prints the diffs to the provided writer. -// For human-readable output, errors are written at the end after the summary. -func (r *DefaultDiffRenderer) RenderDiffs(stdout io.Writer, diffs map[string]*dt.ResourceDiff, errs []dt.OutputError) error { +// RenderDiffs formats and prints the diffs. +// Diff output goes to r.diffOpts.Stdout, errors go to r.diffOpts.Stderr. +func (r *DefaultDiffRenderer) RenderDiffs(diffs map[string]*dt.ResourceDiff, errs []dt.OutputError) error { r.logger.Debug("Rendering diffs to output", "diffCount", len(diffs), "errorCount", len(errs), "useColors", r.diffOpts.UseColors, "compact", r.diffOpts.Compact) + stdout := r.diffOpts.Stdout + stderr := r.diffOpts.Stderr + // Sort the keys to ensure a consistent output order d := slices.AppendSeq(make([]*dt.ResourceDiff, 0, len(diffs)), maps.Values(diffs)) @@ -157,10 +160,10 @@ func (r *DefaultDiffRenderer) RenderDiffs(stdout io.Writer, diffs map[string]*dt } } - // Write errors at the end (for human-readable output) + // Write errors to stderr following Unix conventions for _, e := range errs { - if _, err := fmt.Fprintln(stdout, e.FormatError()); err != nil { - return errors.Wrap(err, "failed to write error to output") + if _, err := fmt.Fprintln(stderr, e.FormatError()); err != nil { + return errors.Wrap(err, "failed to write error to stderr") } } diff --git a/cmd/diff/renderer/diff_renderer_test.go b/cmd/diff/renderer/diff_renderer_test.go index f219ea0b..ea02f19a 100644 --- a/cmd/diff/renderer/diff_renderer_test.go +++ b/cmd/diff/renderer/diff_renderer_test.go @@ -164,14 +164,19 @@ func TestDefaultDiffRenderer_RenderDiffs(t *testing.T) { t.Run(name, func(t *testing.T) { logger := tu.TestLogger(t, false) - // Create a renderer - renderer := NewDiffRenderer(logger, tt.options) - // Create a buffer to capture output var buffer bytes.Buffer + // Set the buffer as stdout in options + opts := tt.options + opts.Stdout = &buffer + opts.Stderr = &bytes.Buffer{} // discard stderr for these tests + + // Create a renderer with options pointing to buffer + renderer := NewDiffRenderer(logger, opts) + // Call the method under test - err := renderer.RenderDiffs(&buffer, tt.diffs, nil) + err := renderer.RenderDiffs(tt.diffs, nil) if err != nil { t.Fatalf("RenderDiffs() failed with error: %v", err) } @@ -230,20 +235,26 @@ func TestDefaultDiffRenderer_RenderDiffs_WithErrors(t *testing.T) { for name, tt := range tests { t.Run(name, func(t *testing.T) { logger := tu.TestLogger(t, false) - renderer := NewDiffRenderer(logger, DefaultDiffOptions()) - var buffer bytes.Buffer + // Errors go to stderr now, so capture stderr + var stderr bytes.Buffer - err := renderer.RenderDiffs(&buffer, map[string]*dt.ResourceDiff{}, tt.errs) + opts := DefaultDiffOptions() + opts.Stdout = &bytes.Buffer{} // discard stdout + opts.Stderr = &stderr + + renderer := NewDiffRenderer(logger, opts) + + err := renderer.RenderDiffs(map[string]*dt.ResourceDiff{}, tt.errs) if err != nil { t.Fatalf("RenderDiffs() failed with error: %v", err) } - output := buffer.String() + output := stderr.String() for _, expectedMsg := range tt.expected { if !strings.Contains(output, expectedMsg) { - t.Errorf("Expected output to contain %q but it didn't\nOutput: %s", expectedMsg, output) + t.Errorf("Expected stderr to contain %q but it didn't\nStderr: %s", expectedMsg, output) } } }) diff --git a/cmd/diff/renderer/structured_renderer.go b/cmd/diff/renderer/structured_renderer.go index c945c93c..83d8c6af 100644 --- a/cmd/diff/renderer/structured_renderer.go +++ b/cmd/diff/renderer/structured_renderer.go @@ -3,7 +3,6 @@ package renderer import ( "encoding/json" "fmt" - "io" "maps" "slices" @@ -154,21 +153,21 @@ type DownstreamChanges struct { // StructuredDiffRenderer renders diffs in structured formats (JSON/YAML). type StructuredDiffRenderer struct { logger logging.Logger - format OutputFormat + opts DiffOptions } // NewStructuredDiffRenderer creates a new structured renderer with the specified format. -func NewStructuredDiffRenderer(logger logging.Logger, format OutputFormat) DiffRenderer { +func NewStructuredDiffRenderer(logger logging.Logger, opts DiffOptions) DiffRenderer { return &StructuredDiffRenderer{ logger: logger, - format: format, + opts: opts, } } // RenderDiffs renders the diffs in the configured structured format. -func (r *StructuredDiffRenderer) RenderDiffs(stdout io.Writer, diffs map[string]*dt.ResourceDiff, errs []dt.OutputError) error { +func (r *StructuredDiffRenderer) RenderDiffs(diffs map[string]*dt.ResourceDiff, errs []dt.OutputError) error { r.logger.Debug("Rendering diffs in structured format", - "format", r.format, + "format", r.opts.Format, "diffCount", len(diffs), "errorCount", len(errs)) @@ -180,30 +179,37 @@ func (r *StructuredDiffRenderer) RenderDiffs(stdout io.Writer, diffs map[string] err error ) - switch r.format { + switch r.opts.Format { case OutputFormatJSON: data, err = json.MarshalIndent(output, "", " ") case OutputFormatYAML: data, err = sigsyaml.Marshal(output) case OutputFormatDiff: - return errors.Errorf("unsupported output format for structured renderer: %s", r.format) + return errors.Errorf("unsupported output format for structured renderer: %s", r.opts.Format) } if err != nil { return errors.Wrap(err, "failed to marshal diff output") } - _, err = stdout.Write(data) + _, err = r.opts.Stdout.Write(data) if err != nil { return errors.Wrap(err, "failed to write structured output") } // Add newline for cleaner terminal output - _, err = stdout.Write([]byte("\n")) + _, err = r.opts.Stdout.Write([]byte("\n")) if err != nil { return errors.Wrap(err, "failed to write newline") } + // Write errors to stderr for human visibility (they're also included in the structured output) + for _, e := range errs { + if _, err := fmt.Fprintln(r.opts.Stderr, e.FormatError()); err != nil { + return errors.Wrap(err, "failed to write error to stderr") + } + } + return nil } diff --git a/cmd/diff/renderer/structured_renderer_test.go b/cmd/diff/renderer/structured_renderer_test.go index 7a9ead88..f6b03b21 100644 --- a/cmd/diff/renderer/structured_renderer_test.go +++ b/cmd/diff/renderer/structured_renderer_test.go @@ -3,6 +3,7 @@ package renderer import ( "bytes" "encoding/json" + "strings" "testing" dt "github.com/crossplane-contrib/crossplane-diff/cmd/diff/renderer/types" @@ -190,11 +191,17 @@ func TestStructuredDiffRenderer_RenderDiffs(t *testing.T) { testName := string(format) + "/" + fixture.name t.Run(testName, func(t *testing.T) { logger := tu.TestLogger(t, false) - renderer := NewStructuredDiffRenderer(logger, format) var buf bytes.Buffer - err := renderer.RenderDiffs(&buf, fixture.diffs, fixture.errs) + opts := DefaultDiffOptions() + opts.Format = format + opts.Stdout = &buf + opts.Stderr = &bytes.Buffer{} // discard stderr for these tests + + renderer := NewStructuredDiffRenderer(logger, opts) + + err := renderer.RenderDiffs(fixture.diffs, fixture.errs) if err != nil { t.Fatalf("RenderDiffs() failed: %v", err) } @@ -248,3 +255,64 @@ func TestStructuredDiffRenderer_RenderDiffs(t *testing.T) { } } } + +// TestStructuredDiffRenderer_RenderDiffs_ErrorsToStderr verifies that errors are +// written to stderr for human visibility in addition to being included in the +// structured output for machine parsing. +func TestStructuredDiffRenderer_RenderDiffs_ErrorsToStderr(t *testing.T) { + errs := []dt.OutputError{ + {ResourceID: "example.org/v1/XResource/my-xr", Message: "failed to render XR: missing composition"}, + {Message: "cluster connection timeout"}, + } + + for _, format := range []OutputFormat{OutputFormatJSON, OutputFormatYAML} { + t.Run(string(format), func(t *testing.T) { + logger := tu.TestLogger(t, false) + + var ( + stdout bytes.Buffer + stderr bytes.Buffer + ) + + opts := DefaultDiffOptions() + opts.Format = format + opts.Stdout = &stdout + opts.Stderr = &stderr + + renderer := NewStructuredDiffRenderer(logger, opts) + + err := renderer.RenderDiffs(map[string]*dt.ResourceDiff{}, errs) + if err != nil { + t.Fatalf("RenderDiffs() failed: %v", err) + } + + // Verify errors are in stderr + stderrOut := stderr.String() + for _, e := range errs { + if !strings.Contains(stderrOut, e.FormatError()) { + t.Errorf("Expected stderr to contain %q, got: %q", e.FormatError(), stderrOut) + } + } + + // Verify errors are ALSO in structured output (stdout) + var output StructuredDiffOutput + + switch format { + case OutputFormatJSON: + if err := json.Unmarshal(stdout.Bytes(), &output); err != nil { + t.Fatalf("Failed to parse JSON output: %v\nOutput: %s", err, stdout.String()) + } + case OutputFormatYAML: + if err := sigsyaml.Unmarshal(stdout.Bytes(), &output); err != nil { + t.Fatalf("Failed to parse YAML output: %v\nOutput: %s", err, stdout.String()) + } + case OutputFormatDiff: + t.Fatal("OutputFormatDiff should not be used with StructuredDiffRenderer") + } + + if diff := cmp.Diff(errs, output.Errors); diff != "" { + t.Errorf("Structured output errors mismatch (-want +got):\n%s", diff) + } + }) + } +} diff --git a/cmd/diff/testutils/mock_builder.go b/cmd/diff/testutils/mock_builder.go index d250e384..54e6221d 100644 --- a/cmd/diff/testutils/mock_builder.go +++ b/cmd/diff/testutils/mock_builder.go @@ -5,7 +5,6 @@ import ( "encoding/base64" "encoding/json" "fmt" - "io" "strings" "github.com/crossplane-contrib/crossplane-diff/cmd/diff/renderer/types" @@ -1249,39 +1248,28 @@ func (b *DiffProcessorBuilder) WithFailedInitialize(errMsg string) *DiffProcesso } // WithPerformDiff adds an implementation for the PerformDiff method. -func (b *DiffProcessorBuilder) WithPerformDiff(fn func(context.Context, io.Writer, []*un.Unstructured, dtypes.CompositionProvider) (bool, error)) *DiffProcessorBuilder { +func (b *DiffProcessorBuilder) WithPerformDiff(fn func(context.Context, []*un.Unstructured, dtypes.CompositionProvider) (bool, error)) *DiffProcessorBuilder { b.mock.PerformDiffFn = fn return b } // WithSuccessfulPerformDiff sets a successful PerformDiff implementation with no diffs. func (b *DiffProcessorBuilder) WithSuccessfulPerformDiff() *DiffProcessorBuilder { - return b.WithPerformDiff(func(context.Context, io.Writer, []*un.Unstructured, dtypes.CompositionProvider) (bool, error) { + return b.WithPerformDiff(func(context.Context, []*un.Unstructured, dtypes.CompositionProvider) (bool, error) { return false, nil }) } // WithSuccessfulPerformDiffWithChanges sets a successful PerformDiff implementation with diffs. func (b *DiffProcessorBuilder) WithSuccessfulPerformDiffWithChanges() *DiffProcessorBuilder { - return b.WithPerformDiff(func(context.Context, io.Writer, []*un.Unstructured, dtypes.CompositionProvider) (bool, error) { - return true, nil - }) -} - -// WithDiffOutput sets a PerformDiff implementation that writes a specific output. -func (b *DiffProcessorBuilder) WithDiffOutput(output string) *DiffProcessorBuilder { - return b.WithPerformDiff(func(_ context.Context, stdout io.Writer, _ []*un.Unstructured, _ dtypes.CompositionProvider) (bool, error) { - if stdout != nil { - _, _ = io.WriteString(stdout, output) - } - + return b.WithPerformDiff(func(context.Context, []*un.Unstructured, dtypes.CompositionProvider) (bool, error) { return true, nil }) } // WithFailedPerformDiff sets a failing PerformDiff implementation. func (b *DiffProcessorBuilder) WithFailedPerformDiff(errMsg string) *DiffProcessorBuilder { - return b.WithPerformDiff(func(context.Context, io.Writer, []*un.Unstructured, dtypes.CompositionProvider) (bool, error) { + return b.WithPerformDiff(func(context.Context, []*un.Unstructured, dtypes.CompositionProvider) (bool, error) { return false, errors.New(errMsg) }) } diff --git a/cmd/diff/testutils/mocks.go b/cmd/diff/testutils/mocks.go index 00ff03e5..aedd8386 100644 --- a/cmd/diff/testutils/mocks.go +++ b/cmd/diff/testutils/mocks.go @@ -257,7 +257,7 @@ func (m *MockResourceInterface) ApplyStatus(_ context.Context, _ string, _ *un.U type MockDiffProcessor struct { // Function fields for mocking behavior InitializeFn func(ctx context.Context) error - PerformDiffFn func(ctx context.Context, stdout io.Writer, resources []*un.Unstructured, compositionProvider types.CompositionProvider) (bool, error) + PerformDiffFn func(ctx context.Context, resources []*un.Unstructured, compositionProvider types.CompositionProvider) (bool, error) DiffSingleResourceFn func(ctx context.Context, res *un.Unstructured, compositionProvider types.CompositionProvider) (map[string]*dt.ResourceDiff, error) CleanupFn func(ctx context.Context) error } @@ -272,9 +272,9 @@ func (m *MockDiffProcessor) Initialize(ctx context.Context) error { } // PerformDiff implements the DiffProcessor.PerformDiff method. -func (m *MockDiffProcessor) PerformDiff(ctx context.Context, stdout io.Writer, resources []*un.Unstructured, compositionProvider types.CompositionProvider) (bool, error) { +func (m *MockDiffProcessor) PerformDiff(ctx context.Context, resources []*un.Unstructured, compositionProvider types.CompositionProvider) (bool, error) { if m.PerformDiffFn != nil { - return m.PerformDiffFn(ctx, stdout, resources, compositionProvider) + return m.PerformDiffFn(ctx, resources, compositionProvider) } return false, nil @@ -815,13 +815,13 @@ func (m *MockDiffCalculator) CalculateRemovedResourceDiffs(ctx context.Context, // MockDiffRenderer provides a mock implementation for DiffRenderer. type MockDiffRenderer struct { - RenderDiffsFn func(io.Writer, map[string]*dt.ResourceDiff, []dt.OutputError) error + RenderDiffsFn func(map[string]*dt.ResourceDiff, []dt.OutputError) error } // RenderDiffs implements RenderDiffs from the DiffRenderer interface. -func (m *MockDiffRenderer) RenderDiffs(w io.Writer, diffs map[string]*dt.ResourceDiff, errs []dt.OutputError) error { +func (m *MockDiffRenderer) RenderDiffs(diffs map[string]*dt.ResourceDiff, errs []dt.OutputError) error { if m.RenderDiffsFn != nil { - return m.RenderDiffsFn(w, diffs, errs) + return m.RenderDiffsFn(diffs, errs) } return nil diff --git a/cmd/diff/xr.go b/cmd/diff/xr.go index d5a48087..9b1a2607 100644 --- a/cmd/diff/xr.go +++ b/cmd/diff/xr.go @@ -74,7 +74,7 @@ Examples: // AppContext is received via dependency injection - Kong resolves it through the provider chain: // ContextProvider (bound in CommonCmdFields.BeforeApply) -> provideRestConfig -> provideAppContext. func (c *XRCmd) AfterApply(ctx *kong.Context, log logging.Logger, appCtx *AppContext) error { - proc := makeDefaultXRProc(c, appCtx, log) + proc := makeDefaultXRProc(c, ctx, appCtx, log) loader, err := makeDefaultXRLoader(c) if err != nil { @@ -87,7 +87,7 @@ func (c *XRCmd) AfterApply(ctx *kong.Context, log logging.Logger, appCtx *AppCon return nil } -func makeDefaultXRProc(c *XRCmd, ctx *AppContext, log logging.Logger) dp.DiffProcessor { +func makeDefaultXRProc(c *XRCmd, kongCtx *kong.Context, appCtx *AppContext, log logging.Logger) dp.DiffProcessor { // Use default namespace for processor options (not actually used for XR diffs) namespace := "default" @@ -96,9 +96,11 @@ func makeDefaultXRProc(c *XRCmd, ctx *AppContext, log logging.Logger) dp.DiffPro dp.WithLogger(log), dp.WithRenderMutex(&globalRenderMutex), dp.WithEventualState(c.EventualState), + dp.WithStdout(kongCtx.Stdout), + dp.WithStderr(kongCtx.Stderr), ) - return dp.NewDiffProcessor(ctx.K8sClients, ctx.XpClients, opts...) + return dp.NewDiffProcessor(appCtx.K8sClients, appCtx.XpClients, opts...) } func makeDefaultXRLoader(c *XRCmd) (ld.Loader, error) { @@ -106,7 +108,7 @@ func makeDefaultXRLoader(c *XRCmd) (ld.Loader, error) { } // Run executes the XR diff command. -func (c *XRCmd) Run(k *kong.Context, log logging.Logger, appCtx *AppContext, proc dp.DiffProcessor, loader ld.Loader, exitCode *ExitCode) error { +func (c *XRCmd) Run(_ *kong.Context, log logging.Logger, appCtx *AppContext, proc dp.DiffProcessor, loader ld.Loader, exitCode *ExitCode) error { // the rest config here is provided by a function in main.go that's only invoked for commands that request it // in their arguments. that means we won't get "can't find kubeconfig" errors for cases where the config isn't asked for. @@ -151,7 +153,7 @@ func (c *XRCmd) Run(k *kong.Context, log logging.Logger, appCtx *AppContext, pro return errors.Wrap(err, "cannot initialize diff processor") } - hasDiffs, err := proc.PerformDiff(ctx, k.Stdout, resources, appCtx.XpClients.Composition.FindMatchingComposition) + hasDiffs, err := proc.PerformDiff(ctx, resources, appCtx.XpClients.Composition.FindMatchingComposition) // Determine exit code based on result exitCode.Code = dp.DetermineExitCode(err, hasDiffs) From 710820579147450868770bb6e7e2442139b3041e Mon Sep 17 00:00:00 2001 From: Stephen Cahill <53151570+cahillsf@users.noreply.github.com> Date: Tue, 28 Apr 2026 16:50:43 -0400 Subject: [PATCH 02/26] fix: correct stdin marker in xr help text from -- to - (#293) Signed-off-by: Stephen Cahill Co-authored-by: Claude Opus 4.6 (1M context) --- cmd/diff/xr.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/diff/xr.go b/cmd/diff/xr.go index 9b1a2607..528404a3 100644 --- a/cmd/diff/xr.go +++ b/cmd/diff/xr.go @@ -53,11 +53,11 @@ Examples: crossplane-diff xr xr.yaml # Show the changes that would result from applying xr.yaml (via stdin). - cat xr.yaml | crossplane-diff xr -- + cat xr.yaml | crossplane-diff xr - # Show the changes that would result from applying multiple files. crossplane-diff xr xr1.yaml xr2.yaml - cat xr.yaml | crossplane-diff xr xr1.yaml xr2.yaml -- + cat xr.yaml | crossplane-diff xr xr1.yaml xr2.yaml - # Show the changes with no color output. crossplane-diff xr xr.yaml --no-color From 6abf0b4b149e4dfad7b1eb16cce85cb943f05bb4 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 28 Apr 2026 17:15:33 -0400 Subject: [PATCH 03/26] chore(deps): update actions/github-script action to v9 (#284) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7e4bb639..9b6e4631 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,7 +56,7 @@ jobs: - name: Fail if Files Changed if: steps.changed_files.outputs.count != 0 - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 with: script: core.setFailed('Found changed files after running earthly +generate.') From 74e81f18240b9288e1e6c993a5fb58bfebb47c22 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 28 Apr 2026 21:29:50 +0000 Subject: [PATCH 04/26] chore(deps): update actions/upload-artifact digest to 043fb46 (#286) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/build-artifacts.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-artifacts.yml b/.github/workflows/build-artifacts.yml index 8eeb6d38..6ecba93f 100644 --- a/.github/workflows/build-artifacts.yml +++ b/.github/workflows/build-artifacts.yml @@ -46,7 +46,7 @@ jobs: run: earthly --strict --remote-cache ghcr.io/crossplane-contrib/crossplane-diff/earthly-cache:build-artifacts +multiplatform-build --RELEASE_ARTIFACTS=true - name: Upload Artifacts to GitHub - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: crossplane-diff-binaries path: _output/release/* From d887389335dfad3cb793218150fc9174b17e5296 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 28 Apr 2026 21:42:34 +0000 Subject: [PATCH 05/26] chore(deps): update softprops/action-gh-release action to v2.6.2 (#287) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1cc87e97..c1879f53 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -63,7 +63,7 @@ jobs: --RELEASE_ARTIFACTS=true - name: Create Release - uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1 + uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2 with: tag_name: ${{ steps.version.outputs.VERSION }} files: _output/release/* From 32f0944f3b1bc601f506b125e34b8a678c59b9b0 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 28 Apr 2026 21:53:52 +0000 Subject: [PATCH 06/26] chore(deps): update softprops/action-gh-release action to v3 (#288) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c1879f53..77ae139f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -63,7 +63,7 @@ jobs: --RELEASE_ARTIFACTS=true - name: Create Release - uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2 + uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3 with: tag_name: ${{ steps.version.outputs.VERSION }} files: _output/release/* From 599b59bd008ad2b4ad8379087c597b9f13537b75 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 28 Apr 2026 22:05:50 +0000 Subject: [PATCH 07/26] chore(deps): update module github.com/moby/spdystream to v0.5.1 [security] (#292) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index d5b15612..cfe394c2 100644 --- a/go.mod +++ b/go.mod @@ -124,7 +124,7 @@ require ( github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect - github.com/moby/spdystream v0.5.0 // indirect + github.com/moby/spdystream v0.5.1 // indirect github.com/moby/term v0.5.2 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect diff --git a/go.sum b/go.sum index 2d29107f..cd6aa010 100644 --- a/go.sum +++ b/go.sum @@ -196,8 +196,8 @@ github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQ github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= -github.com/moby/spdystream v0.5.0 h1:7r0J1Si3QO/kjRitvSLVVFUjxMEb/YLj6S9FF62JBCU= -github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI= +github.com/moby/spdystream v0.5.1 h1:9sNYeYZUcci9R6/w7KDaFWEWeV4LStVG78Mpyq/Zm/Y= +github.com/moby/spdystream v0.5.1/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI= github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= From 043ff99c11b9f58fbf498f1cb47ec964155aa47f Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 28 Apr 2026 22:18:29 +0000 Subject: [PATCH 08/26] chore(deps): update module go.opentelemetry.io/otel to v1.41.0 [security] (#297) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- go.mod | 6 +++--- go.sum | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/go.mod b/go.mod index cfe394c2..3a2059a7 100644 --- a/go.mod +++ b/go.mod @@ -140,9 +140,9 @@ require ( github.com/spf13/pflag v1.0.10 // indirect github.com/vbatts/tar-split v0.12.2 // indirect github.com/vladimirvivien/gexe v0.4.1 // indirect - go.opentelemetry.io/otel v1.40.0 // indirect - go.opentelemetry.io/otel/metric v1.40.0 // indirect - go.opentelemetry.io/otel/trace v1.40.0 // indirect + go.opentelemetry.io/otel v1.41.0 // indirect + go.opentelemetry.io/otel/metric v1.41.0 // indirect + go.opentelemetry.io/otel/trace v1.41.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.1 // indirect golang.org/x/mod v0.32.0 // indirect diff --git a/go.sum b/go.sum index cd6aa010..0fdb57b0 100644 --- a/go.sum +++ b/go.sum @@ -298,22 +298,22 @@ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.6 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0/go.mod h1:rg+RlpR5dKwaS95IyyZqj5Wd4E13lk/msnTS0Xl9lJM= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGNANqpVFCndZvcuyKbl0g+UAVcbBcqGkG28H0Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0/go.mod h1:GQ/474YrbE4Jx8gZ4q5I4hrhUzM6UPzyrqJYV2AqPoQ= -go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= -go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= +go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c= +go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 h1:f0cb2XPmrqn4XMy9PNliTgRKJgS5WcL/u0/WRYGz4t0= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0/go.mod h1:vnakAaFckOMiMtOIhFI2MNH4FYrZzXCYxmb1LlhoGz8= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0 h1:in9O8ESIOlwJAEGTkkf34DesGRAc/Pn8qJ7k3r/42LM= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0/go.mod h1:Rp0EXBm5tfnv0WL+ARyO/PHBEaEAT8UUHQ6AGJcSq6c= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 h1:aTL7F04bJHUlztTsNGJ2l+6he8c+y/b//eR0jjjemT4= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0/go.mod h1:kldtb7jDTeol0l3ewcmd8SDvx3EmIE7lyvqbasU3QC4= -go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= -go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= +go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ= +go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps= go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= -go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= -go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= +go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0= +go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= From 33bb51ea4b396a5a71a56a286af12e04e2630ca2 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 28 Apr 2026 22:33:33 +0000 Subject: [PATCH 09/26] chore(deps): update github/codeql-action digest to 95e58e9 (#298) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9b6e4631..06bdbcb7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -115,7 +115,7 @@ jobs: run: earthly --strict --remote-cache ghcr.io/crossplane-contrib/crossplane-diff/earthly-cache:${{ github.job }} +ci-codeql - name: Upload CodeQL Results to GitHub - uses: github/codeql-action/upload-sarif@c10b8064de6f491fea524254123dbe5e09572f13 # v4 + uses: github/codeql-action/upload-sarif@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4 with: sarif_file: '_output/codeql/go.sarif' @@ -141,7 +141,7 @@ jobs: output: 'trivy-results.sarif' - name: Upload Trivy Results to GitHub - uses: github/codeql-action/upload-sarif@c10b8064de6f491fea524254123dbe5e09572f13 # v4 + uses: github/codeql-action/upload-sarif@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4 with: sarif_file: 'trivy-results.sarif' From 58fc9eaecc05977c01b5cf656b6694edf1374841 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 28 Apr 2026 18:57:20 -0400 Subject: [PATCH 10/26] fix(deps): update module github.com/crossplane/crossplane-runtime/v2 to v2.2.1 (#301) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- go.mod | 4 ++-- go.sum | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/go.mod b/go.mod index 3a2059a7..87711268 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/crossplane-contrib/crossplane-diff -go 1.25.6 +go 1.25.9 toolchain go1.26.2 @@ -8,7 +8,7 @@ require ( dario.cat/mergo v1.0.2 github.com/Masterminds/semver v1.5.0 github.com/alecthomas/kong v1.15.0 - github.com/crossplane/crossplane-runtime/v2 v2.2.0 + github.com/crossplane/crossplane-runtime/v2 v2.2.1 github.com/crossplane/crossplane/v2 v2.2.0 github.com/docker/docker v28.5.2+incompatible github.com/google/go-cmp v0.7.0 diff --git a/go.sum b/go.sum index 0fdb57b0..59c0632e 100644 --- a/go.sum +++ b/go.sum @@ -51,8 +51,8 @@ github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSV github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= -github.com/crossplane/crossplane-runtime/v2 v2.2.0 h1:jLoQm9D5buk9lBqwRtQ40ueaFotjOljJATq+24bVYI8= -github.com/crossplane/crossplane-runtime/v2 v2.2.0/go.mod h1:8I+x4w5bG4x8aO8ifF/QC8GZoNCN6v21NHzgoYPNYAQ= +github.com/crossplane/crossplane-runtime/v2 v2.2.1 h1:CJXV8+1SDXLYJx67sUO4MIuLCkKEOKxCS2zg02nBqUI= +github.com/crossplane/crossplane-runtime/v2 v2.2.1/go.mod h1:3Xq18YLf2en0BB2OZpcixTKazeX7bS3txLbQHjOR52c= github.com/crossplane/crossplane/v2 v2.2.0 h1:07heHt5SdqkstLwlrSY48sq2zf8zRlF9XRxKXwEIydE= github.com/crossplane/crossplane/v2 v2.2.0/go.mod h1:0BApeaCz6fkymaZqef/6XmoFovuycvnxfT03wyvC4iw= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= From 60e09daeef853d321ab004079e6d2caedc5c9b8c Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 28 Apr 2026 23:09:15 +0000 Subject: [PATCH 11/26] chore(deps): update mheap/require-checklist-action digest to 9c8100a (#299) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/pr.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index fc7926f7..912649b6 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -10,7 +10,7 @@ jobs: if: github.actor != 'renovate[bot]' runs-on: ubuntu-24.04 steps: - - uses: mheap/require-checklist-action@46d2ca1a0f90144bd081fd13a80b1dc581759365 # v2 + - uses: mheap/require-checklist-action@9c8100a52aa9726d4648e61aa92415f6c843c990 # v2 with: # The checklist must _exist_ and be filled out. requireChecklist: true From 0a8215ed090ac916b886dca6df205432696c877e Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 28 Apr 2026 20:29:07 -0400 Subject: [PATCH 12/26] chore(deps): update dependency github/codeql-action to v2.25.2 (#300) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- Earthfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Earthfile b/Earthfile index cbeb3ec9..bf52e7be 100644 --- a/Earthfile +++ b/Earthfile @@ -403,7 +403,7 @@ ci-artifacts: # ci-codeql-setup sets up CodeQL for the ci-codeql target. ci-codeql-setup: - ARG CODEQL_VERSION=2.25.1 + ARG CODEQL_VERSION=2.25.2 FROM curlimages/curl:8.18.0 RUN curl -fsSL https://github.com/github/codeql-action/releases/download/codeql-bundle-v${CODEQL_VERSION}/codeql-bundle-linux64.tar.gz|tar zx SAVE ARTIFACT codeql From edd42d8ed6021ef0bce588b4ebfa188720f848f6 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 00:41:31 +0000 Subject: [PATCH 13/26] fix(deps): update module github.com/crossplane/crossplane/v2 to v2.2.1 (#302) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- go.mod | 66 +++++++++++------------ go.sum | 168 ++++++++++++++++++++++++++++----------------------------- 2 files changed, 117 insertions(+), 117 deletions(-) diff --git a/go.mod b/go.mod index 87711268..5ec52837 100644 --- a/go.mod +++ b/go.mod @@ -9,15 +9,15 @@ require ( github.com/Masterminds/semver v1.5.0 github.com/alecthomas/kong v1.15.0 github.com/crossplane/crossplane-runtime/v2 v2.2.1 - github.com/crossplane/crossplane/v2 v2.2.0 + github.com/crossplane/crossplane/v2 v2.2.1 github.com/docker/docker v28.5.2+incompatible github.com/google/go-cmp v0.7.0 github.com/pkg/errors v0.9.1 github.com/sergi/go-diff v1.4.0 - k8s.io/api v0.35.0 + k8s.io/api v0.35.1 k8s.io/apiextensions-apiserver v0.35.0 - k8s.io/apimachinery v0.35.0 - k8s.io/client-go v0.35.0 + k8s.io/apimachinery v0.35.1 + k8s.io/client-go v0.35.1 k8s.io/utils v0.0.0-20260108192941-914a6e750570 sigs.k8s.io/controller-runtime v0.23.1 sigs.k8s.io/e2e-framework v0.6.0 @@ -38,17 +38,17 @@ require ( github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/go-errors/errors v1.4.2 // indirect - github.com/go-openapi/swag/cmdutils v0.25.4 // indirect - github.com/go-openapi/swag/conv v0.25.4 // indirect - github.com/go-openapi/swag/fileutils v0.25.4 // indirect - github.com/go-openapi/swag/jsonname v0.25.4 // indirect - github.com/go-openapi/swag/jsonutils v0.25.4 // indirect - github.com/go-openapi/swag/loading v0.25.4 // indirect - github.com/go-openapi/swag/mangling v0.25.4 // indirect - github.com/go-openapi/swag/netutils v0.25.4 // indirect - github.com/go-openapi/swag/stringutils v0.25.4 // indirect - github.com/go-openapi/swag/typeutils v0.25.4 // indirect - github.com/go-openapi/swag/yamlutils v0.25.4 // indirect + github.com/go-openapi/swag/cmdutils v0.25.5 // indirect + github.com/go-openapi/swag/conv v0.25.5 // indirect + github.com/go-openapi/swag/fileutils v0.25.5 // indirect + github.com/go-openapi/swag/jsonname v0.25.5 // indirect + github.com/go-openapi/swag/jsonutils v0.25.5 // indirect + github.com/go-openapi/swag/loading v0.25.5 // indirect + github.com/go-openapi/swag/mangling v0.25.5 // indirect + github.com/go-openapi/swag/netutils v0.25.5 // indirect + github.com/go-openapi/swag/stringutils v0.25.5 // indirect + github.com/go-openapi/swag/typeutils v0.25.5 // indirect + github.com/go-openapi/swag/yamlutils v0.25.5 // indirect github.com/google/btree v1.1.3 // indirect github.com/google/cel-go v0.26.1 // indirect github.com/google/gnostic-models v0.7.1 // indirect @@ -75,9 +75,9 @@ require ( go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect - golang.org/x/sync v0.19.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260114163908-3f89685c29c3 // indirect - google.golang.org/grpc v1.79.3 // indirect + golang.org/x/sync v0.20.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect + google.golang.org/grpc v1.80.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gotest.tools/v3 v3.1.0 // indirect @@ -113,9 +113,9 @@ require ( github.com/go-logr/logr v1.4.3 github.com/go-logr/stdr v1.2.2 github.com/go-logr/zapr v1.3.0 // indirect - github.com/go-openapi/jsonpointer v0.22.4 // indirect - github.com/go-openapi/jsonreference v0.21.4 // indirect - github.com/go-openapi/swag v0.25.4 // indirect + github.com/go-openapi/jsonpointer v0.22.5 // indirect + github.com/go-openapi/jsonreference v0.21.5 // indirect + github.com/go-openapi/swag v0.25.5 // indirect github.com/gobuffalo/flect v1.0.3 // indirect github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect @@ -140,21 +140,21 @@ require ( github.com/spf13/pflag v1.0.10 // indirect github.com/vbatts/tar-split v0.12.2 // indirect github.com/vladimirvivien/gexe v0.4.1 // indirect - go.opentelemetry.io/otel v1.41.0 // indirect - go.opentelemetry.io/otel/metric v1.41.0 // indirect - go.opentelemetry.io/otel/trace v1.41.0 // indirect + go.opentelemetry.io/otel v1.43.0 // indirect + go.opentelemetry.io/otel/metric v1.43.0 // indirect + go.opentelemetry.io/otel/trace v1.43.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.1 // indirect - golang.org/x/mod v0.32.0 // indirect - golang.org/x/net v0.49.0 // indirect - golang.org/x/oauth2 v0.34.0 // indirect - golang.org/x/sys v0.40.0 // indirect - golang.org/x/term v0.39.0 // indirect - golang.org/x/text v0.33.0 // indirect - golang.org/x/time v0.14.0 // indirect - golang.org/x/tools v0.41.0 // indirect + golang.org/x/mod v0.34.0 // indirect + golang.org/x/net v0.53.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/term v0.42.0 // indirect + golang.org/x/text v0.36.0 // indirect + golang.org/x/time v0.15.0 // indirect + golang.org/x/tools v0.43.0 // indirect gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum index 59c0632e..fe689869 100644 --- a/go.sum +++ b/go.sum @@ -24,8 +24,8 @@ github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYW github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= -github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk= -github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= +github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng= +github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= @@ -53,8 +53,8 @@ github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/crossplane/crossplane-runtime/v2 v2.2.1 h1:CJXV8+1SDXLYJx67sUO4MIuLCkKEOKxCS2zg02nBqUI= github.com/crossplane/crossplane-runtime/v2 v2.2.1/go.mod h1:3Xq18YLf2en0BB2OZpcixTKazeX7bS3txLbQHjOR52c= -github.com/crossplane/crossplane/v2 v2.2.0 h1:07heHt5SdqkstLwlrSY48sq2zf8zRlF9XRxKXwEIydE= -github.com/crossplane/crossplane/v2 v2.2.0/go.mod h1:0BApeaCz6fkymaZqef/6XmoFovuycvnxfT03wyvC4iw= +github.com/crossplane/crossplane/v2 v2.2.1 h1:1oN2prePpsJAi6+W/qe53AA/nHCeDQCIMDth5jetSr4= +github.com/crossplane/crossplane/v2 v2.2.1/go.mod h1:ZYkweHJ2Q/wJYheZHdtLl56mY/0tuGJWSXazyw6sVws= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -99,40 +99,40 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= -github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4= -github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80= -github.com/go-openapi/jsonreference v0.21.4 h1:24qaE2y9bx/q3uRK/qN+TDwbok1NhbSmGjjySRCHtC8= -github.com/go-openapi/jsonreference v0.21.4/go.mod h1:rIENPTjDbLpzQmQWCj5kKj3ZlmEh+EFVbz3RTUh30/4= -github.com/go-openapi/swag v0.25.4 h1:OyUPUFYDPDBMkqyxOTkqDYFnrhuhi9NR6QVUvIochMU= -github.com/go-openapi/swag v0.25.4/go.mod h1:zNfJ9WZABGHCFg2RnY0S4IOkAcVTzJ6z2Bi+Q4i6qFQ= -github.com/go-openapi/swag/cmdutils v0.25.4 h1:8rYhB5n6WawR192/BfUu2iVlxqVR9aRgGJP6WaBoW+4= -github.com/go-openapi/swag/cmdutils v0.25.4/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0= -github.com/go-openapi/swag/conv v0.25.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4= -github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU= -github.com/go-openapi/swag/fileutils v0.25.4 h1:2oI0XNW5y6UWZTC7vAxC8hmsK/tOkWXHJQH4lKjqw+Y= -github.com/go-openapi/swag/fileutils v0.25.4/go.mod h1:cdOT/PKbwcysVQ9Tpr0q20lQKH7MGhOEb6EwmHOirUk= -github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI= -github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag= -github.com/go-openapi/swag/jsonutils v0.25.4 h1:VSchfbGhD4UTf4vCdR2F4TLBdLwHyUDTd1/q4i+jGZA= -github.com/go-openapi/swag/jsonutils v0.25.4/go.mod h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4 h1:IACsSvBhiNJwlDix7wq39SS2Fh7lUOCJRmx/4SN4sVo= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4/go.mod h1:Mt0Ost9l3cUzVv4OEZG+WSeoHwjWLnarzMePNDAOBiM= -github.com/go-openapi/swag/loading v0.25.4 h1:jN4MvLj0X6yhCDduRsxDDw1aHe+ZWoLjW+9ZQWIKn2s= -github.com/go-openapi/swag/loading v0.25.4/go.mod h1:rpUM1ZiyEP9+mNLIQUdMiD7dCETXvkkC30z53i+ftTE= -github.com/go-openapi/swag/mangling v0.25.4 h1:2b9kBJk9JvPgxr36V23FxJLdwBrpijI26Bx5JH4Hp48= -github.com/go-openapi/swag/mangling v0.25.4/go.mod h1:6dxwu6QyORHpIIApsdZgb6wBk/DPU15MdyYj/ikn0Hg= -github.com/go-openapi/swag/netutils v0.25.4 h1:Gqe6K71bGRb3ZQLusdI8p/y1KLgV4M/k+/HzVSqT8H0= -github.com/go-openapi/swag/netutils v0.25.4/go.mod h1:m2W8dtdaoX7oj9rEttLyTeEFFEBvnAx9qHd5nJEBzYg= -github.com/go-openapi/swag/stringutils v0.25.4 h1:O6dU1Rd8bej4HPA3/CLPciNBBDwZj9HiEpdVsb8B5A8= -github.com/go-openapi/swag/stringutils v0.25.4/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0= -github.com/go-openapi/swag/typeutils v0.25.4 h1:1/fbZOUN472NTc39zpa+YGHn3jzHWhv42wAJSN91wRw= -github.com/go-openapi/swag/typeutils v0.25.4/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE= -github.com/go-openapi/swag/yamlutils v0.25.4 h1:6jdaeSItEUb7ioS9lFoCZ65Cne1/RZtPBZ9A56h92Sw= -github.com/go-openapi/swag/yamlutils v0.25.4/go.mod h1:MNzq1ulQu+yd8Kl7wPOut/YHAAU/H6hL91fF+E2RFwc= -github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxEodtNSI1WG1c/m5Akw4= -github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg= -github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls= -github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= +github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA= +github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0= +github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE= +github.com/go-openapi/jsonreference v0.21.5/go.mod h1:u25Bw85sX4E2jzFodh1FOKMTZLcfifd1Q+iKKOUxExw= +github.com/go-openapi/swag v0.25.5 h1:pNkwbUEeGwMtcgxDr+2GBPAk4kT+kJ+AaB+TMKAg+TU= +github.com/go-openapi/swag v0.25.5/go.mod h1:B3RT6l8q7X803JRxa2e59tHOiZlX1t8viplOcs9CwTA= +github.com/go-openapi/swag/cmdutils v0.25.5 h1:yh5hHrpgsw4NwM9KAEtaDTXILYzdXh/I8Whhx9hKj7c= +github.com/go-openapi/swag/cmdutils v0.25.5/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0= +github.com/go-openapi/swag/conv v0.25.5 h1:wAXBYEXJjoKwE5+vc9YHhpQOFj2JYBMF2DUi+tGu97g= +github.com/go-openapi/swag/conv v0.25.5/go.mod h1:CuJ1eWvh1c4ORKx7unQnFGyvBbNlRKbnRyAvDvzWA4k= +github.com/go-openapi/swag/fileutils v0.25.5 h1:B6JTdOcs2c0dBIs9HnkyTW+5gC+8NIhVBUwERkFhMWk= +github.com/go-openapi/swag/fileutils v0.25.5/go.mod h1:V3cT9UdMQIaH4WiTrUc9EPtVA4txS0TOmRURmhGF4kc= +github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo= +github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU= +github.com/go-openapi/swag/jsonutils v0.25.5 h1:XUZF8awQr75MXeC+/iaw5usY/iM7nXPDwdG3Jbl9vYo= +github.com/go-openapi/swag/jsonutils v0.25.5/go.mod h1:48FXUaz8YsDAA9s5AnaUvAmry1UcLcNVWUjY42XkrN4= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5 h1:SX6sE4FrGb4sEnnxbFL/25yZBb5Hcg1inLeErd86Y1U= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5/go.mod h1:/2KvOTrKWjVA5Xli3DZWdMCZDzz3uV/T7bXwrKWPquo= +github.com/go-openapi/swag/loading v0.25.5 h1:odQ/umlIZ1ZVRteI6ckSrvP6e2w9UTF5qgNdemJHjuU= +github.com/go-openapi/swag/loading v0.25.5/go.mod h1:I8A8RaaQ4DApxhPSWLNYWh9NvmX2YKMoB9nwvv6oW6g= +github.com/go-openapi/swag/mangling v0.25.5 h1:hyrnvbQRS7vKePQPHHDso+k6CGn5ZBs5232UqWZmJZw= +github.com/go-openapi/swag/mangling v0.25.5/go.mod h1:6hadXM/o312N/h98RwByLg088U61TPGiltQn71Iw0NY= +github.com/go-openapi/swag/netutils v0.25.5 h1:LZq2Xc2QI8+7838elRAaPCeqJnHODfSyOa7ZGfxDKlU= +github.com/go-openapi/swag/netutils v0.25.5/go.mod h1:lHbtmj4m57APG/8H7ZcMMSWzNqIQcu0RFiXrPUara14= +github.com/go-openapi/swag/stringutils v0.25.5 h1:NVkoDOA8YBgtAR/zvCx5rhJKtZF3IzXcDdwOsYzrB6M= +github.com/go-openapi/swag/stringutils v0.25.5/go.mod h1:PKK8EZdu4QJq8iezt17HM8RXnLAzY7gW0O1KKarrZII= +github.com/go-openapi/swag/typeutils v0.25.5 h1:EFJ+PCga2HfHGdo8s8VJXEVbeXRCYwzzr9u4rJk7L7E= +github.com/go-openapi/swag/typeutils v0.25.5/go.mod h1:itmFmScAYE1bSD8C4rS0W+0InZUBrB2xSPbWt6DLGuc= +github.com/go-openapi/swag/yamlutils v0.25.5 h1:kASCIS+oIeoc55j28T4o8KwlV2S4ZLPT6G0iq2SSbVQ= +github.com/go-openapi/swag/yamlutils v0.25.5/go.mod h1:Gek1/SjjfbYvM+Iq4QGwa/2lEXde9n2j4a3wI3pNuOQ= +github.com/go-openapi/testify/enable/yaml/v2 v2.4.0 h1:7SgOMTvJkM8yWrQlU8Jm18VeDPuAvB/xWrdxFJkoFag= +github.com/go-openapi/testify/enable/yaml/v2 v2.4.0/go.mod h1:14iV8jyyQlinc9StD7w1xVPW3CO3q1Gj04Jy//Kw4VM= +github.com/go-openapi/testify/v2 v2.4.0 h1:8nsPrHVCWkQ4p8h1EsRVymA2XABB4OT40gcvAu+voFM= +github.com/go-openapi/testify/v2 v2.4.0/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/gobuffalo/flect v1.0.3 h1:xeWBM2nui+qnVvNM4S3foBhCAL2XgPU+a7FdpelbTq4= @@ -165,8 +165,8 @@ github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 h1:+ngKgrYPPJr github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 h1:Ovs26xHkKqVztRpIrF/92BcuyuQ/YW4NSIpoGtfXNho= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.4 h1:kEISI/Gx67NzH3nJxAmY/dGac80kKZgZt134u7Y/k1s= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.4/go.mod h1:6Nz966r3vQYCqIzWsuEl9d7cf7mRhtDmm++sOxlnfxI= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= @@ -298,22 +298,22 @@ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.6 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0/go.mod h1:rg+RlpR5dKwaS95IyyZqj5Wd4E13lk/msnTS0Xl9lJM= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGNANqpVFCndZvcuyKbl0g+UAVcbBcqGkG28H0Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0/go.mod h1:GQ/474YrbE4Jx8gZ4q5I4hrhUzM6UPzyrqJYV2AqPoQ= -go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c= -go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 h1:f0cb2XPmrqn4XMy9PNliTgRKJgS5WcL/u0/WRYGz4t0= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0/go.mod h1:vnakAaFckOMiMtOIhFI2MNH4FYrZzXCYxmb1LlhoGz8= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0 h1:in9O8ESIOlwJAEGTkkf34DesGRAc/Pn8qJ7k3r/42LM= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0/go.mod h1:Rp0EXBm5tfnv0WL+ARyO/PHBEaEAT8UUHQ6AGJcSq6c= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 h1:aTL7F04bJHUlztTsNGJ2l+6he8c+y/b//eR0jjjemT4= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0/go.mod h1:kldtb7jDTeol0l3ewcmd8SDvx3EmIE7lyvqbasU3QC4= -go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ= -go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps= -go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= -go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= -go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= -go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= -go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0= -go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= @@ -332,19 +332,19 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU= golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= -golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= +golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= +golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= -golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= -golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= -golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -352,21 +352,21 @@ golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= -golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= +golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= -golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= -golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= -golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= -golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= -golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= +golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM= golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM= @@ -377,14 +377,14 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gomodules.xyz/jsonpatch/v2 v2.5.0 h1:JELs8RLM12qJGXU4u/TO3V25KW8GreMKl9pdkk14RM0= gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= -gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= -gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/api v0.0.0-20260114163908-3f89685c29c3 h1:X9z6obt+cWRX8XjDVOn+SZWhWe5kZHm46TThU9j+jss= -google.golang.org/genproto/googleapis/api v0.0.0-20260114163908-3f89685c29c3/go.mod h1:dd646eSK+Dk9kxVBl1nChEOhJPtMXriCcVb4x3o6J+E= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20 h1:Jr5R2J6F6qWyzINc+4AM8t5pfUz6beZpHp678GNrMbE= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= -google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= -google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA= +google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= +google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -405,18 +405,18 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.1.0 h1:rVV8Tcg/8jHUkPUorwjaMTtemIMVXfIPKiOqnhEhakk= gotest.tools/v3 v3.1.0/go.mod h1:fHy7eyTmJFO5bQbUsEGQ1v4m2J3Jz9eWL54TP2/ZuYQ= -k8s.io/api v0.35.0 h1:iBAU5LTyBI9vw3L5glmat1njFK34srdLmktWwLTprlY= -k8s.io/api v0.35.0/go.mod h1:AQ0SNTzm4ZAczM03QH42c7l3bih1TbAXYo0DkF8ktnA= +k8s.io/api v0.35.1 h1:0PO/1FhlK/EQNVK5+txc4FuhQibV25VLSdLMmGpDE/Q= +k8s.io/api v0.35.1/go.mod h1:28uR9xlXWml9eT0uaGo6y71xK86JBELShLy4wR1XtxM= k8s.io/apiextensions-apiserver v0.35.0 h1:3xHk2rTOdWXXJM+RDQZJvdx0yEOgC0FgQ1PlJatA5T4= k8s.io/apiextensions-apiserver v0.35.0/go.mod h1:E1Ahk9SADaLQ4qtzYFkwUqusXTcaV2uw3l14aqpL2LU= -k8s.io/apimachinery v0.35.0 h1:Z2L3IHvPVv/MJ7xRxHEtk6GoJElaAqDCCU0S6ncYok8= -k8s.io/apimachinery v0.35.0/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= +k8s.io/apimachinery v0.35.1 h1:yxO6gV555P1YV0SANtnTjXYfiivaTPvCTKX6w6qdDsU= +k8s.io/apimachinery v0.35.1/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= k8s.io/apiserver v0.35.0 h1:CUGo5o+7hW9GcAEF3x3usT3fX4f9r8xmgQeCBDaOgX4= k8s.io/apiserver v0.35.0/go.mod h1:QUy1U4+PrzbJaM3XGu2tQ7U9A4udRRo5cyxkFX0GEds= k8s.io/cli-runtime v0.34.1 h1:btlgAgTrYd4sk8vJTRG6zVtqBKt9ZMDeQZo2PIzbL7M= k8s.io/cli-runtime v0.34.1/go.mod h1:aVA65c+f0MZiMUPbseU/M9l1Wo2byeaGwUuQEQVVveE= -k8s.io/client-go v0.35.0 h1:IAW0ifFbfQQwQmga0UdoH0yvdqrbwMdq9vIFEhRpxBE= -k8s.io/client-go v0.35.0/go.mod h1:q2E5AAyqcbeLGPdoRB+Nxe3KYTfPce1Dnu1myQdqz9o= +k8s.io/client-go v0.35.1 h1:+eSfZHwuo/I19PaSxqumjqZ9l5XiTEKbIaJ+j1wLcLM= +k8s.io/client-go v0.35.1/go.mod h1:1p1KxDt3a0ruRfc/pG4qT/3oHmUj1AhSHEcxNSGg+OA= k8s.io/code-generator v0.35.0 h1:TvrtfKYZTm9oDF2z+veFKSCcgZE3Igv0svY+ehCmjHQ= k8s.io/code-generator v0.35.0/go.mod h1:iS1gvVf3c/T71N5DOGYO+Gt3PdJ6B9LYSvIyQ4FHzgc= k8s.io/component-base v0.35.0 h1:+yBrOhzri2S1BVqyVSvcM3PtPyx5GUxCK2tinZz1G94= From c0fed4ddb2100ddbd2eacc4f8939a565b8636fb9 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 01:06:05 +0000 Subject: [PATCH 14/26] chore(deps): update aquasecurity/trivy-action action to v0.36.0 (#303) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 06bdbcb7..792d699b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -130,7 +130,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Run Trivy vulnerability scanner in fs mode - uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # 0.35.0 + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 with: scan-type: 'fs' ignore-unfixed: true From d3b237919ab2f18a00bd2ec905e4e6bbecb0e355 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 01:19:41 +0000 Subject: [PATCH 15/26] chore(deps): update dependency kubernetes/kubernetes to v1.36.0 (#304) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- Earthfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Earthfile b/Earthfile index bf52e7be..2a038da2 100644 --- a/Earthfile +++ b/Earthfile @@ -323,7 +323,7 @@ envtest-setup: # kubectl-setup is used by other targets to setup kubectl. kubectl-setup: - ARG KUBECTL_VERSION=v1.35.3 + ARG KUBECTL_VERSION=v1.36.0 ARG NATIVEPLATFORM ARG TARGETOS ARG TARGETARCH From fcf9faa26d1d9c395f282ccd4fbb6c79924094cf Mon Sep 17 00:00:00 2001 From: Erlend Fonnes Date: Wed, 29 Apr 2026 17:55:12 +0200 Subject: [PATCH 16/26] fix(diff): assign placeholder UID to owner refs for new XRs (#294) * fix(diff): assign placeholder UID to owner refs for new XRs When diffing a new XR against a cluster where composed resources already exist (e.g. after `kubectl delete xr --cascade=orphan`), every composed resource's dry-run apply failed with: metadata.ownerReferences.uid: Invalid value: "": must not be empty The render pipeline emits empty UIDs on owner refs when the XR has no cluster UID. UpdateOwnerRefs generates placeholder UIDs but was a no-op because it was called with composite=nil. Pass the input XR as the composite parent when xrDiff.Current is nil so UpdateOwnerRefs runs. Skipped for generateName-only XRs whose synthetic display name isn't a valid label selector value. Adds a regression test that uses a dry-run apply mock mirroring the apiserver's owner-ref UID validation. Signed-off-by: Erlend Fonnes * docs: correct generateName display-name example in comment The comment showed "foo-(generated)" but the code appends "(generated)" verbatim without an extra dash, producing names like "foo(generated)". Aligns the example with the actual format. Signed-off-by: Erlend Fonnes * fix: regenerate e2e expectations Signed-off-by: Jonathan Ogilvie * test: replace mock-based UT with envtest IT for new XR + existing composed The UT in diff_calculator_test.go encoded assumptions about the Crossplane render pipeline (empty-UID ownerRefs) and the apiserver's validation of them by mocking DryRunApply to mirror apiserver behavior. Replace it with an integration test in diff_integration_test.go that exercises the real envtest apiserver and the real render pipeline end-to-end, so the bug and its fix are covered without encoding behavior of out-of-unit components. Signed-off-by: Jonathan Ogilvie --------- Signed-off-by: Erlend Fonnes Signed-off-by: Jonathan Ogilvie Co-authored-by: Jonathan Ogilvie --- cmd/diff/diff_integration_test.go | 30 +++++++++ cmd/diff/diffprocessor/diff_calculator.go | 16 ++++- .../v1-claim-nested/expect/new-claim.ansi | 4 ++ .../diff/main/v1-claim/expect/new-claim.ansi | 2 + .../v1-claim/expect/new-claim.ansi | 66 ++++++++++--------- 5 files changed, 85 insertions(+), 33 deletions(-) diff --git a/cmd/diff/diff_integration_test.go b/cmd/diff/diff_integration_test.go index cc58ad94..49fc7651 100644 --- a/cmd/diff/diff_integration_test.go +++ b/cmd/diff/diff_integration_test.go @@ -882,6 +882,36 @@ Summary: 2 modified, 2 removed`, expectedError: false, expectedExitCode: dp.ExitCodeDiffDetected, }, + // Reproduces the PR #294 scenario: a net-new XR is diffed while the + // composed resource it would manage already exists in the cluster + // (e.g. after the prior XR was deleted with --cascade=orphan, or while + // importing existing resources under Crossplane management). The render + // pipeline emits ownerReferences with an empty UID for new XRs; + // without the fallback in CalculateNonRemovalDiffs, UpdateOwnerRefs + // runs as a no-op and the real apiserver rejects the dry-run apply + // with "metadata.ownerReferences.uid: must not be empty". + "NewXRWithExistingComposedResource": { + reason: "Shows diff for new XR whose composed resource already exists in the cluster (PR #294)", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition.yaml", + "testdata/diff/resources/functions.yaml", + // Pre-existing composed resource; no backing XR in the cluster. + "testdata/diff/resources/existing-downstream-resource.yaml", + }, + inputFiles: []string{"testdata/diff/new-xr.yaml"}, + expectedStructuredOutput: tu.ExpectDiff(). + WithSummary(1, 1, 0). + WithAddedResource("XNopResource", "test-resource", "default"). + WithField("spec.coolField", "new-value"). + And(). + WithModifiedResource("XDownstreamResource", "test-resource", "default"). + WithFieldChange("spec.forProvider.configData", "existing-value", "new-value"). + And(), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, "MultipleXRs": { reason: "Validates diff for multiple XRs", outputFormat: "json", diff --git a/cmd/diff/diffprocessor/diff_calculator.go b/cmd/diff/diffprocessor/diff_calculator.go index 29acd718..30872bae 100644 --- a/cmd/diff/diffprocessor/diff_calculator.go +++ b/cmd/diff/diffprocessor/diff_calculator.go @@ -270,7 +270,21 @@ func (c *DefaultDiffCalculator) CalculateNonRemovalDiffs(ctx context.Context, xr continue } - diff, err := c.CalculateDiff(ctx, xrDiff.Current, un) + // For new XRs (xrDiff.Current is nil) fall back to the input XR as the + // composite parent so UpdateOwnerRefs can assign a placeholder UID to + // composed resources' owner references. Without this, dry-run apply on + // an existing composed resource fails with + // `metadata.ownerReferences.uid: Invalid value: "": must not be empty`, + // because the render pipeline emits empty UIDs when the XR has none. + // + // Skip for generateName-only XRs: they get a synthetic display name like + // "foo(generated)" that is invalid as a label selector value. + composite := xrDiff.Current + if composite == nil && xr.GetName() != "" && xr.GetGenerateName() == "" { + composite = xr.GetUnstructured() + } + + diff, err := c.CalculateDiff(ctx, composite, un) if err != nil { c.logger.Debug("Error calculating diff for composed resource", "resource", resourceID, "error", err) errs = append(errs, errors.Wrapf(err, "cannot calculate diff for %s", resourceID)) diff --git a/test/e2e/manifests/beta/diff/main/v1-claim-nested/expect/new-claim.ansi b/test/e2e/manifests/beta/diff/main/v1-claim-nested/expect/new-claim.ansi index f747f094..c6d3aeb8 100644 --- a/test/e2e/manifests/beta/diff/main/v1-claim-nested/expect/new-claim.ansi +++ b/test/e2e/manifests/beta/diff/main/v1-claim-nested/expect/new-claim.ansi @@ -7,6 +7,8 @@ + crossplane.io/composition-resource-name: nop-resource + generateName: test-parent-claim- + labels: ++ crossplane.io/claim-name: test-parent-claim ++ crossplane.io/claim-namespace: default + crossplane.io/composite: test-parent-claim + spec: + deletionPolicy: Delete @@ -43,6 +45,8 @@ + crossplane.io/composition-resource-name: child-xr + generateName: test-parent-claim- + labels: ++ crossplane.io/claim-name: test-parent-claim ++ crossplane.io/claim-namespace: default + crossplane.io/composite: test-parent-claim + spec: + childField: new-parent-value diff --git a/test/e2e/manifests/beta/diff/main/v1-claim/expect/new-claim.ansi b/test/e2e/manifests/beta/diff/main/v1-claim/expect/new-claim.ansi index 346f439c..6208c099 100644 --- a/test/e2e/manifests/beta/diff/main/v1-claim/expect/new-claim.ansi +++ b/test/e2e/manifests/beta/diff/main/v1-claim/expect/new-claim.ansi @@ -7,6 +7,8 @@ + crossplane.io/composition-resource-name: nop-resource + generateName: test-claim- + labels: ++ crossplane.io/claim-name: test-claim ++ crossplane.io/claim-namespace: default + crossplane.io/composite: test-claim + spec: + deletionPolicy: Delete diff --git a/test/e2e/manifests/beta/diff/release-1.20/v1-claim/expect/new-claim.ansi b/test/e2e/manifests/beta/diff/release-1.20/v1-claim/expect/new-claim.ansi index d08cf2bb..384f1b95 100644 --- a/test/e2e/manifests/beta/diff/release-1.20/v1-claim/expect/new-claim.ansi +++ b/test/e2e/manifests/beta/diff/release-1.20/v1-claim/expect/new-claim.ansi @@ -1,39 +1,41 @@ +++ NopClaim/test-claim -+ apiVersion: claim.diff.example.org/v1alpha1 -+ kind: NopClaim -+ metadata: -+ name: test-claim -+ namespace: default -+ spec: -+ compositeDeletePolicy: Background -+ compositionRef: -+ name: xnopclaims.claim.diff.example.org -+ compositionUpdatePolicy: Automatic -+ coolField: new-value ++ apiVersion: claim.diff.example.org/v1alpha1 ++ kind: NopClaim ++ metadata: ++ name: test-claim ++ namespace: default ++ spec: ++ compositeDeletePolicy: Background ++ compositionRef: ++ name: xnopclaims.claim.diff.example.org ++ compositionUpdatePolicy: Automatic ++ coolField: new-value --- +++ NopResource/test-claim-(generated) -+ apiVersion: nop.crossplane.io/v1alpha1 -+ kind: NopResource -+ metadata: -+ annotations: -+ cool-field: new-value -+ crossplane.io/composition-resource-name: nop-resource -+ generateName: test-claim- -+ labels: -+ crossplane.io/composite: test-claim -+ spec: -+ deletionPolicy: Delete -+ forProvider: -+ conditionAfter: -+ - conditionStatus: "True" -+ conditionType: Ready -+ time: 0s -+ managementPolicies: -+ - '*' -+ providerConfigRef: -+ name: default ++ apiVersion: nop.crossplane.io/v1alpha1 ++ kind: NopResource ++ metadata: ++ annotations: ++ cool-field: new-value ++ crossplane.io/composition-resource-name: nop-resource ++ generateName: test-claim- ++ labels: ++ crossplane.io/claim-name: test-claim ++ crossplane.io/claim-namespace: default ++ crossplane.io/composite: test-claim ++ spec: ++ deletionPolicy: Delete ++ forProvider: ++ conditionAfter: ++ - conditionStatus: "True" ++ conditionType: Ready ++ time: 0s ++ managementPolicies: ++ - '*' ++ providerConfigRef: ++ name: default --- -Summary: 2 added \ No newline at end of file +Summary: 2 added From 32739f63bf4018de42cacdd68cd2603f153a4d6d Mon Sep 17 00:00:00 2001 From: Jason Witkowski Date: Wed, 29 Apr 2026 16:19:42 -0400 Subject: [PATCH 17/26] feat: support ExtraResources in function-go-templating (#295) * feat: support ExtraResources in function-go-templating, fix render error handling, update golang Signed-off-by: Jason Witkowski * fix: remove prefetching; add new IT cases for extra resources in fn-gotpl as the 2nd+ element in a pipeline Signed-off-by: Jonathan Ogilvie --------- Signed-off-by: Jason Witkowski Signed-off-by: Jonathan Ogilvie Co-authored-by: Jason Witkowski Co-authored-by: Jonathan Ogilvie --- cmd/diff/diff_integration_test.go | 70 +++++++++++++++++++ cmd/diff/diff_it_utils_test.go | 5 +- cmd/diff/diffprocessor/diff_calculator.go | 4 ++ .../diffprocessor/diff_calculator_test.go | 26 +++++++ cmd/diff/diffprocessor/diff_processor.go | 2 +- cmd/diff/diffprocessor/diff_processor_test.go | 60 ++++++++++++++++ cmd/diff/diffprocessor/processor_config.go | 1 + cmd/diff/diffprocessor/resource_manager.go | 3 +- .../diffprocessor/resource_manager_test.go | 9 ++- cmd/diff/main.go | 8 ++- ...tep-fatal-after-extra-res-composition.yaml | 65 +++++++++++++++++ ...uarded-external-res-gotpl-composition.yaml | 44 ++++++++++++ go.mod | 6 +- 13 files changed, 287 insertions(+), 16 deletions(-) create mode 100644 cmd/diff/testdata/diff/resources/multistep-fatal-after-extra-res-composition.yaml create mode 100644 cmd/diff/testdata/diff/resources/unguarded-external-res-gotpl-composition.yaml diff --git a/cmd/diff/diff_integration_test.go b/cmd/diff/diff_integration_test.go index 49fc7651..dcaaa51a 100644 --- a/cmd/diff/diff_integration_test.go +++ b/cmd/diff/diff_integration_test.go @@ -560,6 +560,76 @@ Summary: 2 modified`, expectedError: false, expectedExitCode: dp.ExitCodeDiffDetected, }, + "MultiStepFatalAfterExtraResources": { + // Reproduces the PR #295 bug #1 scenario: a multi-step pipeline where + // step 1 successfully emits ExtraResources requirements and composed + // resources, and a LATER step fatals. Per crossplane/cmd/crank/render/ + // render.go, a fatal in step N still returns the accumulated + // requirements from step 0 + // + // On iteration 2 (after requirements are cached) newReqCount == 0, + // and the OLD condition + // renderErr != nil && newReqCount == 0 && len(output.Requirements) == 0 + // is false — the error falls through to checkStability which declares + // the pipeline "stable" on an Outputs with nil CompositeResource, + // causing a SIGSEGV in CalculateNonRemovalDiffs. + // + // Expected behavior (with PR #295's render-loop fix): a clean fatal + // error bubbles up instead of a segfault. The diff is NOT produced + // because the pipeline legitimately failed. + reason: "Multi-step pipeline with step-1 requirements + later-step fatal should surface the error cleanly, not SIGSEGV", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/functions.yaml", + "testdata/diff/resources/external-resource-configmap.yaml", + "testdata/diff/resources/multistep-fatal-after-extra-res-composition.yaml", + "testdata/diff/resources/existing-xr-with-external-dep.yaml", + "testdata/diff/resources/existing-downstream-with-external-dep.yaml", + }, + inputFiles: []string{"testdata/diff/modified-xr-with-external-dep.yaml"}, + expectedError: true, + expectedErrorContains: `"always-fatal" returned a fatal result`, + expectedExitCode: dp.ExitCodeToolError, + }, + "UnguardedTemplatedExtraResources": { + // Regression guard: a user template that accesses .extraResources + // directly (e.g. `index .extraResources "configmaps"`) without a + // `{{- with .extraResources }}` guard fails fatally on the first + // function invocation. function-go-templating's template data has no + // "extraResources" entry on that first call — crossplane's + // FetchingFunctionRunner only populates req.ExtraResources AFTER a + // function returns a non-fatal rsp.Requirements, and the Fatal from + // this template short-circuits before that ever happens. + // + // Verified empirically (2026-04-29) against a kind cluster with + // Crossplane + function-go-templating v0.11.0: a live controller + // produces the identical error, and the XR stays Synced=False with + // ReconcileError. So crossplane-diff must surface the same fatal + // error — not mask it with a diff — to match production behavior. + // + // This test also guards against a hypothetical "fixed" prefetch that + // pre-populated .extraResources for the template: that would make + // crossplane-diff report a successful diff for a composition that + // actually fails in-cluster, which is a false positive. If this test + // ever starts passing with an unexpected exit code or producing a + // diff, something has diverged from prod behavior. + reason: "Unguarded .extraResources access must surface a fatal error matching what a live Crossplane controller produces (verified empirically against kind cluster on 2026-04-29)", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/functions.yaml", + "testdata/diff/resources/external-resource-configmap.yaml", + "testdata/diff/resources/unguarded-external-res-gotpl-composition.yaml", + "testdata/diff/resources/existing-xr-with-external-dep.yaml", + "testdata/diff/resources/existing-downstream-with-external-dep.yaml", + }, + inputFiles: []string{"testdata/diff/modified-xr-with-external-dep.yaml"}, + expectedError: true, + expectedErrorContains: `: error calling index: index of untyped nil`, + expectedExitCode: dp.ExitCodeToolError, + }, "CrossNamespaceResourceDependencies": { reason: "Validates cross-namespace resource dependencies via fn-external-resources", outputFormat: "json", diff --git a/cmd/diff/diff_it_utils_test.go b/cmd/diff/diff_it_utils_test.go index 9c1cb13a..25c98eff 100644 --- a/cmd/diff/diff_it_utils_test.go +++ b/cmd/diff/diff_it_utils_test.go @@ -16,7 +16,6 @@ import ( un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/util/yaml" - "k8s.io/utils/ptr" "sigs.k8s.io/controller-runtime/pkg/client" "github.com/crossplane/crossplane-runtime/v2/pkg/errors" @@ -251,8 +250,8 @@ func setOwnerReference(resource, owner *un.Unstructured) { Kind: owner.GetKind(), Name: owner.GetName(), UID: owner.GetUID(), - Controller: ptr.To(true), - BlockOwnerDeletion: ptr.To(true), + Controller: new(true), + BlockOwnerDeletion: new(true), } // Set the owner reference diff --git a/cmd/diff/diffprocessor/diff_calculator.go b/cmd/diff/diffprocessor/diff_calculator.go index 30872bae..cabb5512 100644 --- a/cmd/diff/diffprocessor/diff_calculator.go +++ b/cmd/diff/diffprocessor/diff_calculator.go @@ -212,6 +212,10 @@ func (c *DefaultDiffCalculator) CalculateNonRemovalDiffs(ctx context.Context, xr renderedResources := make(map[string]bool) // Determine if this is a nested XR or root XR, and select the appropriate XR to diff + if desired.CompositeResource == nil { + return nil, nil, errors.New("render produced no composite resource (possible fatal pipeline error)") + } + renderedXR := desired.CompositeResource.GetUnstructured() var ( diff --git a/cmd/diff/diffprocessor/diff_calculator_test.go b/cmd/diff/diffprocessor/diff_calculator_test.go index 169feded..dd5b66fe 100644 --- a/cmd/diff/diffprocessor/diff_calculator_test.go +++ b/cmd/diff/diffprocessor/diff_calculator_test.go @@ -1423,3 +1423,29 @@ func TestDefaultDiffCalculator_preserveExistingResourceIdentity(t *testing.T) { }) } } + +func TestCalculateNonRemovalDiffs_NilCompositeResource(t *testing.T) { + calculator := &DefaultDiffCalculator{ + logger: tu.TestLogger(t, false), + } + + xr := cmp.New() + xr.SetAPIVersion("example.org/v1") + xr.SetKind("XMyResource") + xr.SetName("test-xr") + + _, _, err := calculator.CalculateNonRemovalDiffs( + t.Context(), + xr, + nil, + render.Outputs{CompositeResource: nil}, + ) + if err == nil { + t.Fatal("expected error when CompositeResource is nil, got nil") + } + + want := "render produced no composite resource (possible fatal pipeline error)" + if err.Error() != want { + t.Errorf("error message = %q, want %q", err.Error(), want) + } +} diff --git a/cmd/diff/diffprocessor/diff_processor.go b/cmd/diff/diffprocessor/diff_processor.go index 30abe9d2..76f9aca4 100644 --- a/cmd/diff/diffprocessor/diff_processor.go +++ b/cmd/diff/diffprocessor/diff_processor.go @@ -1120,7 +1120,7 @@ func (p *DefaultDiffProcessor) RenderToStableState( } // Check for fatal render errors (no requirements to continue with) - if renderErr != nil && newReqCount == 0 && len(output.Requirements) == 0 { + if renderErr != nil && newReqCount == 0 { return render.Outputs{}, errors.Wrap(renderErr, "cannot render resources") } diff --git a/cmd/diff/diffprocessor/diff_processor_test.go b/cmd/diff/diffprocessor/diff_processor_test.go index 354845de..eb35889c 100644 --- a/cmd/diff/diffprocessor/diff_processor_test.go +++ b/cmd/diff/diffprocessor/diff_processor_test.go @@ -1204,6 +1204,66 @@ func TestDefaultDiffProcessor_RenderToStableState(t *testing.T) { wantRenderIterations: 2, // Renders once with error but requirements, then once more successfully wantErr: false, // Should not error as the second render succeeds }, + "RenderErrorWithCachedRequirements": { + // Regression test: render fails with a fatal error and returns requirements, + // but all requirements are already cached (newReqCount==0). The error must be + // returned instead of silently dropped. Previously the condition also required + // len(output.Requirements)==0, which let errors fall through to checkStability + // and return an output with nil CompositeResource, causing a SIGSEGV. + xr: xr, + composition: composition, + functions: functions, + resourceID: "XR/test-xr", + setupResourceClient: func() *tu.MockResourceClient { + return tu.NewMockResourceClient(). + WithNamespacedResource( + schema.GroupVersionKind{Group: "", Version: "v1", Kind: "ConfigMap"}, + ). + WithGetResource(func(_ context.Context, gvk schema.GroupVersionKind, _, name string) (*un.Unstructured, error) { + if gvk.Kind == ConfigMap && name == ConfigMapName { + return configMap, nil + } + + return nil, errors.New("resource not found") + }). + Build() + }, + setupEnvironmentClient: func() *tu.MockEnvironmentClient { + return tu.NewMockEnvironmentClient(). + WithNoEnvironmentConfigs(). + Build() + }, + setupRenderFunc: func() RenderFunc { + reqs := map[string]v1.Requirements{ + "step1": { + Resources: map[string]*v1.ResourceSelector{ + "config": { + ApiVersion: "v1", + Kind: ConfigMap, + Match: &v1.ResourceSelector_MatchName{ + MatchName: ConfigMapName, + }, + }, + }, + }, + } + + iteration := 0 + + return func(_ context.Context, _ logging.Logger, _ render.Inputs) (render.Outputs, error) { + iteration++ + + // Every iteration returns the same requirements AND the same error. + // After iteration 1, the requirement is already cached so newReqCount==0. + return render.Outputs{ + Requirements: reqs, + }, errors.New("fatal template error: assignment to entry in nil map") + } + }, + wantComposedCount: 0, + wantRenderIterations: 2, // First render resolves the requirement, second sees no new requirements + wantErr: true, // Must return error, not silently swallow it + }, "RequirementsProcessingError": { xr: xr, composition: composition, diff --git a/cmd/diff/diffprocessor/processor_config.go b/cmd/diff/diffprocessor/processor_config.go index a59656f3..c75796d7 100644 --- a/cmd/diff/diffprocessor/processor_config.go +++ b/cmd/diff/diffprocessor/processor_config.go @@ -251,6 +251,7 @@ func (c *ProcessorConfig) GetDiffOptions() renderer.DiffOptions { opts := renderer.DefaultDiffOptions() opts.UseColors = c.Colorize opts.Compact = c.Compact + opts.IgnorePaths = c.IgnorePaths if c.OutputFormat != "" { opts.Format = c.OutputFormat diff --git a/cmd/diff/diffprocessor/resource_manager.go b/cmd/diff/diffprocessor/resource_manager.go index b1141906..9bd686ae 100644 --- a/cmd/diff/diffprocessor/resource_manager.go +++ b/cmd/diff/diffprocessor/resource_manager.go @@ -12,7 +12,6 @@ import ( un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/util/uuid" - "k8s.io/utils/ptr" "github.com/crossplane/crossplane-runtime/v2/pkg/errors" "github.com/crossplane/crossplane-runtime/v2/pkg/logging" @@ -419,7 +418,7 @@ func (m *DefaultResourceManager) updateOwnerRefsForClaim(claim *un.Unstructured, // Ensure Claims are never controller owners if ref.Kind == claim.GetKind() && ref.Name == claim.GetName() { if ref.Controller != nil && *ref.Controller { - ref.Controller = ptr.To(false) + ref.Controller = new(false) m.logger.Debug("Set Controller to false for claim owner reference", "refKind", ref.Kind, "refName", ref.Name) diff --git a/cmd/diff/diffprocessor/resource_manager_test.go b/cmd/diff/diffprocessor/resource_manager_test.go index f0cc5643..916650ca 100644 --- a/cmd/diff/diffprocessor/resource_manager_test.go +++ b/cmd/diff/diffprocessor/resource_manager_test.go @@ -12,7 +12,6 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/utils/ptr" "github.com/crossplane/crossplane-runtime/v2/pkg/errors" cmp "github.com/crossplane/crossplane-runtime/v2/pkg/resource/unstructured/composite" @@ -637,16 +636,16 @@ func TestDefaultResourceManager_UpdateOwnerRefs(t *testing.T) { Kind: "XTestResource", Name: "test-claim-82crv", UID: "", - Controller: ptr.To(true), - BlockOwnerDeletion: ptr.To(true), + Controller: new(true), + BlockOwnerDeletion: new(true), }, { APIVersion: "example.org/v1", Kind: testClaimKind, Name: testClaimName, UID: "", - Controller: ptr.To(true), - BlockOwnerDeletion: ptr.To(true), + Controller: new(true), + BlockOwnerDeletion: new(true), }, }) diff --git a/cmd/diff/main.go b/cmd/diff/main.go index 2ea34fd4..00be5865 100644 --- a/cmd/diff/main.go +++ b/cmd/diff/main.go @@ -25,9 +25,11 @@ import ( "github.com/alecthomas/kong" dp "github.com/crossplane-contrib/crossplane-diff/cmd/diff/diffprocessor" "github.com/crossplane-contrib/crossplane-diff/cmd/diff/versioncmd" + "github.com/go-logr/logr" corev1 "k8s.io/api/core/v1" "k8s.io/client-go/rest" "k8s.io/client-go/tools/clientcmd" + "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/log/zap" "github.com/crossplane/crossplane-runtime/v2/pkg/logging" @@ -111,7 +113,9 @@ func (c *CommonCmdFields) GetKubeContext() KubeContext { } func (v verboseFlag) BeforeApply(ctx *kong.Context) error { //nolint:unparam // BeforeApply requires this signature. - logger := logging.NewLogrLogger(zap.New(zap.UseDevMode(true))) + zapLogger := zap.New(zap.UseDevMode(true)) + log.SetLogger(zapLogger) + logger := logging.NewLogrLogger(zapLogger) ctx.BindTo(logger, (*logging.Logger)(nil)) return nil @@ -142,6 +146,8 @@ type cli struct { } func main() { + log.SetLogger(logr.Discard()) + logger := logging.NewNopLogger() exitCode := &ExitCode{Code: dp.ExitCodeSuccess} // Default to success diff --git a/cmd/diff/testdata/diff/resources/multistep-fatal-after-extra-res-composition.yaml b/cmd/diff/testdata/diff/resources/multistep-fatal-after-extra-res-composition.yaml new file mode 100644 index 00000000..55419e07 --- /dev/null +++ b/cmd/diff/testdata/diff/resources/multistep-fatal-after-extra-res-composition.yaml @@ -0,0 +1,65 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: multistep-fatal-after-extra-res.diff.example.org +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + # Step 1: guarded gotpl. Emits an ExtraResources requirement AND (once + # .extraResources is populated on the inner fetcher iteration) a composed + # resource. Stabilizes cleanly within the single render call. + - step: render-templates + functionRef: + name: function-go-templating + input: + apiVersion: gotemplating.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + --- + apiVersion: meta.gotemplating.fn.crossplane.io/v1alpha1 + kind: ExtraResources + requirements: + configmaps: + apiVersion: v1 + kind: ConfigMap + matchLabels: + app: test-app + environment: {{ .observed.composite.resource.spec.environment }} + {{- with .extraResources }} + {{ $configMaps := index . "configmaps" }} + {{- range $i, $configMap := $configMaps.items }} + --- + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ $.observed.composite.resource.metadata.name }} + namespace: {{ $.observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: + configData: {{ $.observed.composite.resource.spec.coolField }} + roleName: templated-{{ $configMap.resource.metadata.name }} + {{- end }} + {{- end }} + # Step 2: always fatals. `index .missing.path 0` — .missing is , + # indexing returns "index of untyped nil". This step does NOT emit + # requirements — so rsp.Requirements is nil for this step, but the render + # library has already accumulated step 1's requirements into its outputs. + # This produces the combination the PR's render-loop fix addresses: + # renderErr != nil AND len(output.Requirements) > 0. + - step: always-fatal + functionRef: + name: function-go-templating + input: + apiVersion: gotemplating.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + {{ index .missing.path 0 }} diff --git a/cmd/diff/testdata/diff/resources/unguarded-external-res-gotpl-composition.yaml b/cmd/diff/testdata/diff/resources/unguarded-external-res-gotpl-composition.yaml new file mode 100644 index 00000000..26561167 --- /dev/null +++ b/cmd/diff/testdata/diff/resources/unguarded-external-res-gotpl-composition.yaml @@ -0,0 +1,44 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: unguarded-templated-extra-resources.diff.example.org +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: render-templates + functionRef: + name: function-go-templating + input: + apiVersion: gotemplating.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + --- + apiVersion: meta.gotemplating.fn.crossplane.io/v1alpha1 + kind: ExtraResources + requirements: + configmaps: + apiVersion: v1 + kind: ConfigMap + matchLabels: + app: test-app + environment: {{ .observed.composite.resource.spec.environment }} + {{- $configMaps := index .extraResources "configmaps" }} + {{- range $i, $configMap := $configMaps.items }} + --- + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ $.observed.composite.resource.metadata.name }} + namespace: {{ $.observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: + configData: {{ $.observed.composite.resource.spec.coolField }} + roleName: templated-{{ $configMap.resource.metadata.name }} + {{- end }} diff --git a/go.mod b/go.mod index 5ec52837..53f31409 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,6 @@ module github.com/crossplane-contrib/crossplane-diff -go 1.25.9 - -toolchain go1.26.2 +go 1.26.2 require ( dario.cat/mergo v1.0.2 @@ -18,7 +16,6 @@ require ( k8s.io/apiextensions-apiserver v0.35.0 k8s.io/apimachinery v0.35.1 k8s.io/client-go v0.35.1 - k8s.io/utils v0.0.0-20260108192941-914a6e750570 sigs.k8s.io/controller-runtime v0.23.1 sigs.k8s.io/e2e-framework v0.6.0 sigs.k8s.io/yaml v1.6.0 @@ -86,6 +83,7 @@ require ( k8s.io/code-generator v0.35.0 // indirect k8s.io/gengo/v2 v2.0.0-20251215205346-5ee0d033ba5b // indirect k8s.io/kubectl v0.34.1 // indirect + k8s.io/utils v0.0.0-20260108192941-914a6e750570 // indirect sigs.k8s.io/controller-tools v0.20.0 // indirect sigs.k8s.io/kind v0.30.0 // indirect sigs.k8s.io/kustomize/api v0.20.1 // indirect From 078147c5b5226806f59dc7f6b8c979c848c2de4d Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie <679297+jcogilvie@users.noreply.github.com> Date: Wed, 29 Apr 2026 20:27:17 -0400 Subject: [PATCH 18/26] fix: version call to server should use kubeconfig context (#305) * fix: version call to server should use kubeconfig context Signed-off-by: Jonathan Ogilvie * fix: update readme for documenting kubecfg precedence Signed-off-by: Jonathan Ogilvie * fix: go mod update Signed-off-by: Jonathan Ogilvie * fix: adversarial review Signed-off-by: Jonathan Ogilvie --------- Signed-off-by: Jonathan Ogilvie --- .../REQUIREMENTS.md | 139 +++++++++ .serena/memories/rest_config_plumbing.md | 34 +++ .serena/project.yml | 79 ++---- README.md | 27 ++ cmd/diff/diff_test.go | 7 +- cmd/diff/kubecfg/kubecfg.go | 110 ++++++++ cmd/diff/kubecfg/kubecfg_test.go | 143 ++++++++++ cmd/diff/main.go | 65 +---- cmd/diff/versioncmd/fetch.go | 93 ++++++ cmd/diff/versioncmd/fetch_test.go | 147 ++++++++++ cmd/diff/versioncmd/version.go | 41 ++- cmd/diff/versioncmd/version_test.go | 264 +++++++++++------- go.mod | 2 +- 13 files changed, 941 insertions(+), 210 deletions(-) create mode 100644 .requirements/20260429T202533Z_version_kubeconfig_context/REQUIREMENTS.md create mode 100644 .serena/memories/rest_config_plumbing.md create mode 100644 cmd/diff/kubecfg/kubecfg.go create mode 100644 cmd/diff/kubecfg/kubecfg_test.go create mode 100644 cmd/diff/versioncmd/fetch.go create mode 100644 cmd/diff/versioncmd/fetch_test.go diff --git a/.requirements/20260429T202533Z_version_kubeconfig_context/REQUIREMENTS.md b/.requirements/20260429T202533Z_version_kubeconfig_context/REQUIREMENTS.md new file mode 100644 index 00000000..01f13131 --- /dev/null +++ b/.requirements/20260429T202533Z_version_kubeconfig_context/REQUIREMENTS.md @@ -0,0 +1,139 @@ +# Fix `version` server lookup to respect kubeconfig context + +GitHub Issue: https://github.com/crossplane-contrib/crossplane-diff/issues/285 + +## As Is + +`crossplane-diff version` fetches the server (Crossplane) version by calling `xpversion.FetchCrossplaneVersion(ctx)` from `github.com/crossplane/crossplane/v2/cmd/crank/version`. Internally that function calls `ctrl.GetConfig()` (from controller-runtime), which prefers the **in-cluster ServiceAccount config** over kubeconfig. When `crossplane-diff` runs inside a pod: + +- The command ignores the kubeconfig context set by `kubectl config use-context `. +- The command ignores the `--context` flag that the rest of the CLI supports. +- Users see `Error: unable to get crossplane version: Crossplane version or image tag not found` because the lookup targets the management cluster (no Crossplane) instead of the switched-to cluster. + +Meanwhile `crossplane-diff xr` / `crossplane-diff comp` *do* respect the kubeconfig context because they build their REST config via `provideRestConfig` in `cmd/diff/main.go`, which uses `clientcmd.NewDefaultClientConfigLoadingRules()` + configurable `ConfigOverrides{CurrentContext: ...}`. + +Additionally, the `Context KubeContext` flag is declared on `CommonCmdFields` and embedded only by `XRCmd` / `CompCmd`. `versioncmd.Cmd` is in a separate package and does not have a `--context` flag, so there is no way today for a user to force the version command to look at a specific kubeconfig context. + +## To Be + +`crossplane-diff version` builds its Kubernetes REST config using the same kubeconfig-aware loading path as `xr` and `comp`: + +- When run outside a pod: uses `~/.kube/config` (or `$KUBECONFIG`), honoring the kubeconfig's current context. +- When run inside a pod: still uses the kubeconfig's current context, **not** the in-cluster ServiceAccount — unless no kubeconfig is discoverable (then fall back to in-cluster, matching controller-runtime's current behavior so we don't regress the common case). +- Accepts a `--context` flag matching the `xr`/`comp` CLI, so users can override the context explicitly (e.g. `crossplane-diff version --context my-ctx`). +- Still supports `--client` as an existing-behavior short-circuit that never contacts a cluster. + +## Requirements + +1. **R1 — Kubeconfig context is respected for server version lookup.** + The version command must use the same REST-config resolution strategy as `xr`/`comp`: `clientcmd` loading rules with optional `CurrentContext` override, **not** `ctrl.GetConfig()`. + +2. **R2 — `--context` flag on `version` command.** + The version command must accept `--context ` identical in semantics to `xr --context` / `comp --context`. + +3. **R3 — `--client` flag preserved.** + Existing `--client` behavior is unchanged: prints only client version, never attempts any REST/kube call. + +4. **R4 — Error surface unchanged on failure path.** + When the server lookup fails, error wrapping remains `unable to get crossplane version: ` so existing scripts/docs don't break. + +5. **R5 — No regression for the common out-of-cluster case.** + Running `crossplane-diff version` on a developer laptop continues to read `~/.kube/config` and return the current context's server version. + +6. **R6 — Minimal change footprint.** + Do not introduce new abstractions or shuffle packages. Reuse existing patterns (`ContextProvider`, `provideRestConfig`) where possible; inline a small helper if reuse across the `main` → `versioncmd` boundary is awkward. + +## Acceptance Criteria + +- **AC1 (R1, R2, R5):** Given a kubeconfig with two contexts `A` and `B` where A is current, `crossplane-diff version --context B` fetches from cluster B, and bare `crossplane-diff version` fetches from cluster A. Verified via unit test that injects a kubeconfig file and stubs the deployment fetch. +- **AC2 (R1):** Inside a pod with both a mounted ServiceAccount token and an explicit `KUBECONFIG` env var pointing to a kubeconfig with a different cluster, `crossplane-diff version` targets the **kubeconfig** cluster, not the in-pod ServiceAccount cluster. Verified by unit test that sets `KUBECONFIG` and asserts the REST config host matches the kubeconfig's cluster. +- **AC3 (R3):** `crossplane-diff version --client` still returns zero and prints only `Client Version:` with no server contact. Verified by existing `TestCmd_Run_ClientOnly` test continuing to pass. +- **AC4 (R4):** When the deployment list fails, the returned error message starts with `unable to get crossplane version:`. Verified by unit test with an injected fetcher that returns an error. +- **AC5 (R2):** `crossplane-diff version --help` lists a `--context` flag with description matching `xr`/`comp`. Verified by unit test that parses help output via kong. +- **AC6 (R6):** No new Go packages introduced; `versioncmd.Cmd` changes are additive (new field + new dependency); unit test suite in `versioncmd/` keeps working with minimal edits. + +## Testing Plan + +All tests are Go unit tests in `cmd/diff/versioncmd/version_test.go` (same package) unless noted. No e2e changes required — the bug reproduces only under in-pod conditions that e2e doesn't cover today. + +### T1 — `--client` still works (regression) +Existing `TestCmd_Run_ClientOnly` must pass unchanged. + +### T2 — Server version fetch uses injected fetcher +Refactor `Cmd.Run` so the fetcher function is an injectable field with a default of the kubeconfig-aware fetcher. Test injects a fake fetcher that: +- asserts it was called (i.e., `--client=false` path reaches it), and +- returns `"v2.0.2"`. +Assert `Server Version: v2.0.2` appears in stdout and `err == nil`. + +### T3 — Server version fetch error is wrapped correctly +Inject a fetcher that returns `errors.New("boom")`. Assert `err.Error()` contains `unable to get crossplane version: boom`. + +### T4 — Kubeconfig-aware fetcher honors `--context` +Write a small helper (to live alongside `Cmd`) that takes a `*rest.Config` and returns the version. Verify via a separate unit test with a stub `*rest.Config.Host` that the helper uses the config it was handed (not `ctrl.GetConfig()`). + +### T5 — Config builder honors `--context` and `$KUBECONFIG` +A unit test writes a temporary kubeconfig with two contexts pointing at different `server:` hosts, sets `$KUBECONFIG`, calls the config builder once with `""` (no override, expect current-context host) and once with the other context name (expect other host). + +### T6 — Kong flag parsing +Unit test: run `kong.Parse` against a minimal CLI wrapping `versioncmd.Cmd`, pass `--context foo`, assert the resulting struct has `Context == "foo"`. + +## Implementation Plan + +Design decisions (confirmed with user): +- **D1:** Inject the REST config via Kong providers, sharing `provideRestConfig` with `xr`/`comp`. +- **D2:** When clientcmd cannot find a kubeconfig, fall back to `rest.InClusterConfig()` and emit a warning to stderr. +- **D3:** `--client` semantics unchanged. + +Consequence of D1: we must break the current implicit cycle where `KubeContext`/`ContextProvider` live in the `main` package while `versioncmd` needs to implement `ContextProvider`. Extract those two symbols + `provideRestConfig` into a new small package, `cmd/diff/kubecfg`, that both `main` and `versioncmd` can import. + +Smallest sequential steps. Each step runs `go test ./cmd/diff/...` relevant to the change before moving on. + +### Step 1 — Create the `cmd/diff/kubecfg` package. +- Move `KubeContext` (rename to `Context`) and `ContextProvider` (rename to `Provider`) into `cmd/diff/kubecfg/kubecfg.go`. +- Move `provideRestConfig` → exported `kubecfg.Provide(Provider) (*rest.Config, error)`. +- Add `rest.InClusterConfig` fallback when `clientcmd` returns `clientcmd.ErrEmptyConfig` (or equivalent "no config" error). On fallback, emit a one-line warning to `os.Stderr` (will later be made injectable). +- **Test T5:** write a unit test in `kubecfg/kubecfg_test.go` that writes a temp kubeconfig with two contexts, sets `$KUBECONFIG`, and asserts: + - default (empty override) uses current-context host, + - explicit override uses the other host, + - missing kubeconfig with in-cluster unavailable returns a descriptive error. +- **Test:** `go test ./cmd/diff/kubecfg/...`. + +### Step 2 — Rewire `main.go`, `CommonCmdFields`, `xr.go`, `comp.go`. +- Replace references to `KubeContext` → `kubecfg.Context`, `ContextProvider` → `kubecfg.Provider`. +- `provideRestConfig` in `main.go` becomes a thin forwarder to `kubecfg.Provide`, or is deleted and `kong.BindToProvider(kubecfg.Provide)` is used directly. +- **Test:** `cd cmd/diff && go test ./...`. + +### Step 3 — Port `FetchCrossplaneVersion` into `versioncmd` as config-accepting helper. +- New file `cmd/diff/versioncmd/fetch.go` with `FetchCrossplaneVersion(ctx context.Context, cfg *rest.Config) (string, error)`. +- Same logic as upstream (deployment list `app=crossplane`, prefer `app.kubernetes.io/version` label, fallback to image tag). +- Construct the `kubernetes.Clientset` from the passed config instead of calling `ctrl.GetConfig()`. +- **Test T4:** unit test uses `kubernetes/fake.NewSimpleClientset` — extract a tiny `deploymentLister` interface (just `List(ctx, opts) (*appsv1.DeploymentList, error)`) so the fake can be substituted. The exported `FetchCrossplaneVersion(cfg)` stays small; the testable helper is unexported. + +### Step 4 — Teach `versioncmd.Cmd` to implement `kubecfg.Provider`. +- Add `Context kubecfg.Context \`help:"Kubernetes context to use (defaults to current context)." name:"context"\`` field to `Cmd`. +- Implement `GetKubeContext() kubecfg.Context`. +- Add `BeforeApply(ctx *kong.Context)` that calls `ctx.BindTo(c, (*kubecfg.Provider)(nil))` mirroring `CommonCmdFields.BeforeApply`. +- **Test T6:** kong.Parse on `&struct{ Version versioncmd.Cmd }` with `version --context foo`; assert `Context == "foo"`. + +### Step 5 — Rework `Cmd.Run` to use injected `*rest.Config`. +- Change signature to `Run(k *kong.Context, cfg *rest.Config) error`. Kong will resolve `*rest.Config` via `kubecfg.Provide` because the provider is already bound globally in `main.go`. +- On `c.Client == true`, short-circuit **before** requesting the config (no cluster contact). This requires the config to be an optional dependency — in practice, commands' `Run` signatures pull the config lazily via `kong.BindToProvider`, but because `Run` parameter binding is eager, we need to keep the config out of the signature and instead call `kubecfg.Provide(c)` manually inside `Run` when not in client-only mode. Action: manual call, not parameter injection, to preserve `--client` behavior. +- Call `FetchCrossplaneVersion(ctx, cfg)`; wrap error as `unable to get crossplane version: %w`. +- Introduce an unexported, test-only seam `fetchFn func(ctx, cfg) (string, error)` on `Cmd` that defaults to `FetchCrossplaneVersion`. +- Drop `xpversion` import. +- **Test T2, T3:** inject a fake `fetchFn` in-package. + +### Step 6 — Update existing tests. +- `TestCmd_Run_ClientOnly` stays unchanged (T1). +- `TestCmd_Run_ServerVersion` becomes an in-package test that sets `fetchFn` to a stub returning `"v2.0.2"` and asserts output contains `Server Version: v2.0.2`. This makes it deterministic and independent of any cluster state. + +### Step 7 — Full tests. +`cd cmd/diff && go test ./...`. + +### Step 8 — Manual smoke. +`earthly +build` then: +- `./_output/bin/darwin_arm64/crossplane-diff version --help` — verify `--context` flag visible. +- `./_output/bin/darwin_arm64/crossplane-diff version --client` — verify still works with no cluster. + +### Step 9 — Serena memory. +Record a short note about the `cmd/diff/kubecfg` package and the `--context`-honoring version command so future changes to REST-config plumbing stay consistent. diff --git a/.serena/memories/rest_config_plumbing.md b/.serena/memories/rest_config_plumbing.md new file mode 100644 index 00000000..8510f254 --- /dev/null +++ b/.serena/memories/rest_config_plumbing.md @@ -0,0 +1,34 @@ +# REST Config Plumbing + +All crossplane-diff commands must build their Kubernetes REST config through +`cmd/diff/kubecfg.Provide(kubecfg.Provider)`. + +- `kubecfg.Provider` has a single method `GetKubeContext() kubecfg.Context`. +- `main.CommonCmdFields` implements it for `xr` and `comp`; `versioncmd.Cmd` + implements it for `version`. Each command binds itself via `BeforeApply` + using `ctx.BindTo(c, (*kubecfg.Provider)(nil))`. +- `kong.BindToProvider(kubecfg.Provide)` is registered once in `main.main`. + Kong resolves `*rest.Config` lazily per command. +- `kubecfg.Provide`: + 1. Uses `clientcmd.NewDefaultClientConfigLoadingRules()` + optional + `ConfigOverrides{CurrentContext: ...}` — honors `$KUBECONFIG`, + `~/.kube/config`, and `--context`. + 2. If `clientcmd.IsEmptyConfig(err)` is true (no kubeconfig at all), falls + back to `rest.InClusterConfig()` and emits a warning to stderr. + 3. Applies default QPS=20, Burst=30. + +## Why this matters (issue #285) +Do **not** call `ctrl.GetConfig()` (controller-runtime) to build a +`*rest.Config`. It prefers in-cluster first — when `crossplane-diff` runs +inside a pod (e.g. GHA runner), that makes the command ignore the user's +`--context` and `kubectl config use-context`. The `versioncmd` originally +used the upstream `cmd/crank/version.FetchCrossplaneVersion`, which has this +problem. We now vendor a copy in `versioncmd/fetch.go` that accepts an +already-built `*rest.Config`. + +## New command checklist +When adding a new subcommand that talks to a cluster: +1. Embed `main.CommonCmdFields`, OR expose a `--context` flag and implement + `kubecfg.Provider` directly, plus a `BeforeApply` that binds. +2. Accept `*rest.Config` (or `*AppContext`) via Kong injection. +3. Do not call `ctrl.GetConfig()` or any function that does. diff --git a/.serena/project.yml b/.serena/project.yml index 68fe2533..26faf354 100644 --- a/.serena/project.yml +++ b/.serena/project.yml @@ -1,17 +1,20 @@ # the name by which the project can be referenced within Serena -project_name: "cd-context" +project_name: "crossplane-diff" # list of languages for which language servers are started; choose from: -# al bash clojure cpp csharp -# csharp_omnisharp dart elixir elm erlang -# fortran fsharp go groovy haskell -# java julia kotlin lua markdown -# matlab nix pascal perl php -# php_phpactor powershell python python_jedi r -# rego ruby ruby_solargraph rust scala -# swift terraform toml typescript typescript_vts -# vue yaml zig +# al ansible bash clojure cpp +# cpp_ccls crystal csharp csharp_omnisharp dart +# elixir elm erlang fortran fsharp +# go groovy haskell haxe hlsl +# java json julia kotlin lean4 +# lua luau markdown matlab msl +# nix ocaml pascal perl php +# php_phpactor powershell python python_jedi python_ty +# r rego ruby ruby_solargraph rust +# scala solidity swift systemverilog terraform +# toml typescript typescript_vts vue yaml +# zig # (This list may be outdated. For the current list, see values of Language enum here: # https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py # For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) @@ -65,53 +68,17 @@ read_only: false # list of tool names to exclude. # This extends the existing exclusions (e.g. from the global configuration) -# -# Below is the complete list of tools for convenience. -# To make sure you have the latest list of tools, and to view their descriptions, -# execute `uv run scripts/print_tool_overview.py`. -# -# * `activate_project`: Activates a project by name. -# * `check_onboarding_performed`: Checks whether project onboarding was already performed. -# * `create_text_file`: Creates/overwrites a file in the project directory. -# * `delete_lines`: Deletes a range of lines within a file. -# * `delete_memory`: Deletes a memory from Serena's project-specific memory store. -# * `execute_shell_command`: Executes a shell command. -# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced. -# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type). -# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type). -# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes. -# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file. -# * `initial_instructions`: Gets the initial instructions for the current project. -# Should only be used in settings where the system prompt cannot be set, -# e.g. in clients you have no control over, like Claude Desktop. -# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol. -# * `insert_at_line`: Inserts content at a given line in a file. -# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol. -# * `list_dir`: Lists files and directories in the given directory (optionally with recursion). -# * `list_memories`: Lists memories in Serena's project-specific memory store. -# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building). -# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context). -# * `read_file`: Reads a file within the project directory. -# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store. -# * `remove_project`: Removes a project from the Serena configuration. -# * `replace_lines`: Replaces a range of lines within a file with new content. -# * `replace_symbol_body`: Replaces the full definition of a symbol. -# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen. -# * `search_for_pattern`: Performs a search for a pattern in the project. -# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase. -# * `switch_modes`: Activates modes by providing a list of their names -# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information. -# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task. -# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed. -# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store. +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html excluded_tools: [] # list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default). # This extends the existing inclusions (e.g. from the global configuration). +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html included_optional_tools: [] # fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools. # This cannot be combined with non-empty excluded_tools or included_optional_tools. +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html fixed_tools: [] # list of mode names to that are always to be included in the set of active modes @@ -122,11 +89,14 @@ fixed_tools: [] # Set this to a list of mode names to always include the respective modes for this project. base_modes: -# list of mode names that are to be activated by default. -# The full set of modes to be activated is base_modes + default_modes. -# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply. +# list of mode names that are to be activated by default, overriding the setting in the global configuration. +# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes. +# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply. # Otherwise, this overrides the setting from the global configuration (serena_config.yml). +# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply +# for this project. # This setting can, in turn, be overridden by CLI parameters (--mode). +# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes default_modes: # initial prompt for the project. It will always be given to the LLM upon activating the project @@ -150,3 +120,8 @@ read_only_memory_patterns: [] # Extends the list from the global configuration, merging the two lists. # Example: ["_archive/.*", "_episodes/.*"] ignored_memory_patterns: [] + +# list of mode names to be activated additionally for this project, e.g. ["query-projects"] +# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes. +# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes +added_modes: diff --git a/README.md b/README.md index 34c63f2d..924e12a4 100644 --- a/README.md +++ b/README.md @@ -287,6 +287,33 @@ The tool requires read access to: - **Kubernetes resources**: CRDs, referenced resources - **Resource hierarchies**: Owner references and relationships +## Kubernetes Configuration + +All `crossplane-diff` commands (`xr`, `comp`, `version`) resolve their target +cluster the same way, following the standard CLI convention: + +1. `$KUBECONFIG` env var, if set. +2. `~/.kube/config`, if present. +3. Otherwise, fall back to the pod's in-cluster ServiceAccount (with a + one-line warning on stderr). + +The `--context` flag overrides the kubeconfig's `current-context`. + +### Running in a pod + +A common pattern is to run `crossplane-diff` inside a pod (for example, a +GitHub Actions runner) to post PR comments using the pod's existing RBAC. +Two supported modes: + +- **Use the pod's ServiceAccount** — simplest: build an image without + `~/.kube/config`, don't set `KUBECONFIG`. The tool falls back to + in-cluster automatically. +- **Target a different cluster from inside the pod** — set up a kubeconfig + (e.g. via `kubectl config use-context `) and `crossplane-diff` will + honor it, including `--context` overrides. This matches the behavior of + `kubectl` and other Kubernetes CLIs, and is why a `~/.kube/config` that + exists in the pod always takes precedence over the pod's ServiceAccount. + ## Output Format ### Human-Readable Diff (default) diff --git a/cmd/diff/diff_test.go b/cmd/diff/diff_test.go index 8d577d6a..83962405 100644 --- a/cmd/diff/diff_test.go +++ b/cmd/diff/diff_test.go @@ -29,6 +29,7 @@ import ( xp "github.com/crossplane-contrib/crossplane-diff/cmd/diff/client/crossplane" k8 "github.com/crossplane-contrib/crossplane-diff/cmd/diff/client/kubernetes" dp "github.com/crossplane-contrib/crossplane-diff/cmd/diff/diffprocessor" + "github.com/crossplane-contrib/crossplane-diff/cmd/diff/kubecfg" tu "github.com/crossplane-contrib/crossplane-diff/cmd/diff/testutils" "github.com/crossplane-contrib/crossplane-diff/cmd/diff/types" "github.com/google/go-cmp/cmp" @@ -1051,7 +1052,7 @@ users: } // Call the function with empty context (use default) - config, err := provideRestConfig(&testContextProvider{context: ""}) + config, err := kubecfg.Provide(&testContextProvider{context: ""}) // Check error expectations if tc.expectError && err == nil { @@ -1163,8 +1164,8 @@ users: // Set KUBECONFIG environment variable t.Setenv("KUBECONFIG", kubeconfigPath) - // Call provideRestConfig with the context override - config, err := provideRestConfig(&testContextProvider{context: KubeContext(tc.contextOverride)}) + // Call kubecfg.Provide with the context override + config, err := kubecfg.Provide(&testContextProvider{context: KubeContext(tc.contextOverride)}) // Check error expectations if tc.expectError && err == nil { diff --git a/cmd/diff/kubecfg/kubecfg.go b/cmd/diff/kubecfg/kubecfg.go new file mode 100644 index 00000000..b2beb733 --- /dev/null +++ b/cmd/diff/kubecfg/kubecfg.go @@ -0,0 +1,110 @@ +/* +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 kubecfg builds *rest.Config instances using kubeconfig loading +// rules, honoring an optional kubeconfig context override. It is the shared +// REST config entry point for crossplane-diff commands so that every command +// consults the same kubeconfig the user's kubectl context points at, rather +// than preferring an in-cluster ServiceAccount when run inside a pod. +package kubecfg + +import ( + "fmt" + "os" + + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" +) + +// Context is a kubeconfig context name. Kong binds flag values through this +// type so providers can distinguish it from other plain strings when resolved. +type Context string + +// Provider supplies the kubeconfig context the caller wants to use. +// Commands implement this by exposing a --context flag. +type Provider interface { + GetKubeContext() Context +} + +// Provide builds a *rest.Config using the provider's context. +// +// Resolution order: +// 1. Standard clientcmd loading rules ($KUBECONFIG, then $HOME/.kube/config). +// 2. If the provider supplies a non-empty context, it overrides the +// kubeconfig's current-context. +// 3. If no kubeconfig is available at all, fall back to the in-cluster +// ServiceAccount config and emit a warning to stderr. +// +// This differs from controller-runtime's GetConfig, which prefers in-cluster +// first — that behavior causes `crossplane-diff` running inside a pod to +// ignore the user's kubeconfig context. +func Provide(p Provider) (*rest.Config, error) { + return provide(p, rest.InClusterConfig, func(msg string) { + fmt.Fprintln(os.Stderr, "warning: "+msg) + }) +} + +// provide is the testable core of Provide. It takes the in-cluster config +// loader and a warning sink as seams. +func provide(p Provider, inCluster func() (*rest.Config, error), warn func(msg string)) (*rest.Config, error) { + loadingRules := clientcmd.NewDefaultClientConfigLoadingRules() + + overrides := &clientcmd.ConfigOverrides{} + if kc := p.GetKubeContext(); kc != "" { + overrides.CurrentContext = string(kc) + } + + kubeConfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, overrides) + + cfg, err := kubeConfig.ClientConfig() + if err != nil { + // IsEmptyConfig is true in two distinct scenarios: (a) no kubeconfig + // was found on disk, and (b) a kubeconfig was found but has no + // current-context set. In scenario (b), falling back to in-cluster + // may silently target a different cluster than the user's kubeconfig + // would suggest. TODO: distinguish these cases and emit a clearer + // error for (b) that points the user at --context. + if clientcmd.IsEmptyConfig(err) { + icc, iccErr := inCluster() + if iccErr == nil { + warn("no kubeconfig found, falling back to in-cluster config") + applyDefaults(icc) + + return icc, nil + } + // Return the original empty-config error — it's more actionable + // to a user who thinks they provided a kubeconfig than the + // in-cluster "token not found" error. + return nil, err + } + + return nil, err + } + + applyDefaults(cfg) + + return cfg, nil +} + +func applyDefaults(cfg *rest.Config) { + if cfg.QPS == 0 { + cfg.QPS = 20 + } + + if cfg.Burst == 0 { + cfg.Burst = 30 + } +} diff --git a/cmd/diff/kubecfg/kubecfg_test.go b/cmd/diff/kubecfg/kubecfg_test.go new file mode 100644 index 00000000..11e814db --- /dev/null +++ b/cmd/diff/kubecfg/kubecfg_test.go @@ -0,0 +1,143 @@ +/* +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 +*/ + +package kubecfg + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" +) + +const twoContextKubeconfig = `apiVersion: v1 +kind: Config +current-context: ctx-a +clusters: +- name: cluster-a + cluster: + server: https://a.example.com +- name: cluster-b + cluster: + server: https://b.example.com +contexts: +- name: ctx-a + context: + cluster: cluster-a + user: user-a +- name: ctx-b + context: + cluster: cluster-b + user: user-b +users: +- name: user-a + user: {} +- name: user-b + user: {} +` + +type staticProvider struct{ ctx Context } + +func (s staticProvider) GetKubeContext() Context { return s.ctx } + +func writeTempKubeconfig(t *testing.T) string { + t.Helper() + dir := t.TempDir() + + path := filepath.Join(dir, "kubeconfig") + if err := os.WriteFile(path, []byte(twoContextKubeconfig), 0o600); err != nil { + t.Fatalf("write kubeconfig: %v", err) + } + + t.Setenv("KUBECONFIG", path) + + return path +} + +func TestProvide_DefaultContext(t *testing.T) { + writeTempKubeconfig(t) + + cfg, err := Provide(staticProvider{ctx: ""}) + if err != nil { + t.Fatalf("Provide: %v", err) + } + + if cfg.Host != "https://a.example.com" { + t.Errorf("Host = %q, want https://a.example.com (current context)", cfg.Host) + } + + if cfg.QPS != 20 { + t.Errorf("QPS = %v, want 20", cfg.QPS) + } + + if cfg.Burst != 30 { + t.Errorf("Burst = %v, want 30", cfg.Burst) + } +} + +func TestProvide_ContextOverride(t *testing.T) { + writeTempKubeconfig(t) + + cfg, err := Provide(staticProvider{ctx: "ctx-b"}) + if err != nil { + t.Fatalf("Provide: %v", err) + } + + if cfg.Host != "https://b.example.com" { + t.Errorf("Host = %q, want https://b.example.com (overridden context)", cfg.Host) + } +} + +func TestProvide_EmptyConfigFallbackInCluster(t *testing.T) { + // Point KUBECONFIG at a nonexistent file so clientcmd returns ErrEmptyConfig. + t.Setenv("KUBECONFIG", filepath.Join(t.TempDir(), "does-not-exist")) + t.Setenv("HOME", t.TempDir()) + + var warned string + + stubCluster := &rest.Config{Host: "https://in-cluster.example.com"} + + cfg, err := provide(staticProvider{ctx: ""}, func() (*rest.Config, error) { + return stubCluster, nil + }, func(msg string) { warned = msg }) + if err != nil { + t.Fatalf("provide: %v", err) + } + + if cfg.Host != "https://in-cluster.example.com" { + t.Errorf("Host = %q, want in-cluster host", cfg.Host) + } + + if !strings.Contains(warned, "in-cluster") { + t.Errorf("warning = %q, expected it to mention in-cluster", warned) + } +} + +func TestProvide_EmptyConfigAndInClusterFailsReturnsOriginal(t *testing.T) { + t.Setenv("KUBECONFIG", filepath.Join(t.TempDir(), "does-not-exist")) + t.Setenv("HOME", t.TempDir()) + + inClusterErr := errors.New("no service account token") + + _, err := provide(staticProvider{ctx: ""}, func() (*rest.Config, error) { + return nil, inClusterErr + }, func(string) {}) + if err == nil { + t.Fatal("expected error, got nil") + } + // The error we surface should be the clientcmd empty-config error, not the in-cluster error. + if !clientcmd.IsEmptyConfig(err) { + t.Errorf("expected empty-config error to be preserved, got: %v", err) + } +} diff --git a/cmd/diff/main.go b/cmd/diff/main.go index 00be5865..4a72913e 100644 --- a/cmd/diff/main.go +++ b/cmd/diff/main.go @@ -24,11 +24,11 @@ import ( "github.com/alecthomas/kong" dp "github.com/crossplane-contrib/crossplane-diff/cmd/diff/diffprocessor" + "github.com/crossplane-contrib/crossplane-diff/cmd/diff/kubecfg" "github.com/crossplane-contrib/crossplane-diff/cmd/diff/versioncmd" "github.com/go-logr/logr" corev1 "k8s.io/api/core/v1" "k8s.io/client-go/rest" - "k8s.io/client-go/tools/clientcmd" "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/log/zap" @@ -37,19 +37,17 @@ import ( var _ = kong.Must(&cli{}) -type ( - verboseFlag bool - // KubeContext represents the Kubernetes context name from the kubeconfig. - KubeContext string -) +type verboseFlag bool -// ContextProvider is an interface for accessing the Kubernetes context configuration. -// Commands embed CommonCmdFields which implements this interface. By binding a pointer -// to the command struct via this interface in BeforeApply, providers can access the -// context value after flag parsing completes (when providers are actually resolved). -type ContextProvider interface { - GetKubeContext() KubeContext -} +// KubeContext is an alias for kubecfg.Context, used as the flag type on +// CommonCmdFields.Context so Kong's tag-driven decoding resolves to the same +// type kubecfg.Provide expects. +type KubeContext = kubecfg.Context + +// ContextProvider is an alias for kubecfg.Provider, used within this package +// so CommonCmdFields can implement the provider interface without importing +// the kubecfg package at every call site. +type ContextProvider = kubecfg.Provider // ExitCode tracks the exit code to return after command execution. // Commands set this based on their results (diffs found, validation errors, etc.). @@ -159,9 +157,9 @@ func main() { kong.BindTo(logger, (*logging.Logger)(nil)), kong.Bind(exitCode), // Bind exit code state // Providers are resolved lazily when dependencies are needed. - // provideRestConfig depends on ContextProvider (bound in CommonCmdFields.BeforeApply) + // kubecfg.Provide depends on kubecfg.Provider (bound in CommonCmdFields.BeforeApply) // provideAppContext depends on *rest.Config and logging.Logger - kong.BindToProvider(provideRestConfig), + kong.BindToProvider(kubecfg.Provide), kong.BindToProvider(provideAppContext), kong.ConfigureHelp(kong.HelpOptions{ FlagsLast: true, @@ -186,43 +184,6 @@ func main() { os.Exit(exitCode.Code) } -// provideRestConfig creates a Kubernetes REST config using the context from ContextProvider. -// This provider is resolved lazily when dependencies need it (in AfterApply or Run), -// at which point flag parsing is complete and GetKubeContext() returns the correct value. -func provideRestConfig(cp ContextProvider) (*rest.Config, error) { - kubeContext := cp.GetKubeContext() - - // Use the standard client-go loading rules: - // 1. If KUBECONFIG env var is set, use that - // 2. Otherwise, use ~/.kube/config - // 3. Respects current context from the kubeconfig (or uses specified context) - loadingRules := clientcmd.NewDefaultClientConfigLoadingRules() - configOverrides := &clientcmd.ConfigOverrides{} - - // If a specific context is requested, override the current context - if kubeContext != "" { - configOverrides.CurrentContext = string(kubeContext) - } - - kubeConfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, configOverrides) - - config, err := kubeConfig.ClientConfig() - if err != nil { - return nil, err - } - - // Set default QPS and Burst if not already set - if config.QPS == 0 { - config.QPS = 20 - } - - if config.Burst == 0 { - config.Burst = 30 - } - - return config, nil -} - // cachedAppContext stores the singleton AppContext instance. // Kong providers are called each time a dependency is requested, but we need // the same AppContext instance throughout the command lifecycle so that diff --git a/cmd/diff/versioncmd/fetch.go b/cmd/diff/versioncmd/fetch.go new file mode 100644 index 00000000..aa8da719 --- /dev/null +++ b/cmd/diff/versioncmd/fetch.go @@ -0,0 +1,93 @@ +/* +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 versioncmd + +import ( + "context" + "strings" + + "github.com/google/go-containerregistry/pkg/name" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" +) + +const ( + errCreateK8sClientset = "could not create the clientset for Kubernetes" + errFetchCrossplaneDeployment = "could not fetch deployments" +) + +// FetchCrossplaneVersion returns the Crossplane server version using the +// provided REST config. It mirrors the upstream +// github.com/crossplane/crossplane/v2/cmd/crank/version.FetchCrossplaneVersion +// but accepts a pre-built *rest.Config instead of calling ctrl.GetConfig, so +// callers can honor the user's kubeconfig context (e.g. --context) even when +// running inside a Kubernetes pod. +// +// TODO: Remove this fork once an upstream variant that accepts *rest.Config is +// available. Tracked in https://github.com/crossplane-contrib/crossplane-diff/issues/285. +func FetchCrossplaneVersion(ctx context.Context, cfg *rest.Config) (string, error) { + clientset, err := kubernetes.NewForConfig(cfg) + if err != nil { + return "", errors.Wrap(err, errCreateK8sClientset) + } + + return fetchCrossplaneVersion(ctx, clientset) +} + +// fetchCrossplaneVersion is the testable core. It takes a generic +// kubernetes.Interface so fake clientsets can drive unit tests. +func fetchCrossplaneVersion(ctx context.Context, clientset kubernetes.Interface) (string, error) { + deployments, err := clientset.AppsV1().Deployments("").List(ctx, metav1.ListOptions{ + LabelSelector: "app=crossplane", + }) + if err != nil { + return "", errors.Wrap(err, errFetchCrossplaneDeployment) + } + + for _, deployment := range deployments.Items { + if v, ok := deployment.Labels["app.kubernetes.io/version"]; ok { + if !strings.HasPrefix(v, "v") { + v = "v" + v + } + + return v, nil + } + + if len(deployment.Spec.Template.Spec.Containers) > 0 { + imageRef := deployment.Spec.Template.Spec.Containers[0].Image + + ref, err := name.ParseReference(imageRef) + if err != nil { + return "", errors.Wrap(err, "error parsing image reference") + } + + if tagged, ok := ref.(name.Tag); ok { + imageTag := tagged.TagStr() + if !strings.HasPrefix(imageTag, "v") { + imageTag = "v" + imageTag + } + + return imageTag, nil + } + } + } + + return "", errors.New("crossplane version or image tag not found") +} diff --git a/cmd/diff/versioncmd/fetch_test.go b/cmd/diff/versioncmd/fetch_test.go new file mode 100644 index 00000000..6cc6e4be --- /dev/null +++ b/cmd/diff/versioncmd/fetch_test.go @@ -0,0 +1,147 @@ +/* +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 +*/ + +package versioncmd + +import ( + "context" + "errors" + "maps" + "strings" + "testing" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/kubernetes/fake" + clienttesting "k8s.io/client-go/testing" +) + +func deployment(name, imageTag string, labels map[string]string) *appsv1.Deployment { + d := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: "crossplane-system", + Labels: map[string]string{"app": "crossplane"}, + }, + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "crossplane", + Image: "crossplane/crossplane:" + imageTag, + }, + }, + }, + }, + }, + } + maps.Copy(d.Labels, labels) + + return d +} + +func TestFetchCrossplaneVersion_VersionLabel(t *testing.T) { + tests := map[string]struct { + labelValue string + want string + }{ + "PlainVersionGetsVPrefix": { + labelValue: "2.0.2", + want: "v2.0.2", + }, + "VPrefixedVersionUnchanged": { + labelValue: "v2.0.2", + want: "v2.0.2", + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + clientset := fake.NewClientset( + deployment("crossplane", "v2.0.0", map[string]string{ + "app.kubernetes.io/version": tc.labelValue, + }), + ) + + got, err := fetchCrossplaneVersion(context.Background(), clientset) + if err != nil { + t.Fatalf("fetchCrossplaneVersion: %v", err) + } + + if got != tc.want { + t.Errorf("got %q, want %q", got, tc.want) + } + }) + } +} + +func TestFetchCrossplaneVersion_FallBackToImageTag(t *testing.T) { + // No version label — must use container image tag. + clientset := fake.NewClientset( + deployment("crossplane", "v2.1.0", nil), + ) + + got, err := fetchCrossplaneVersion(context.Background(), clientset) + if err != nil { + t.Fatalf("fetchCrossplaneVersion: %v", err) + } + + if got != "v2.1.0" { + t.Errorf("got %q, want v2.1.0", got) + } +} + +func TestFetchCrossplaneVersion_ImageTagWithoutVPrefixGetsV(t *testing.T) { + clientset := fake.NewClientset( + deployment("crossplane", "2.1.0", nil), + ) + + got, err := fetchCrossplaneVersion(context.Background(), clientset) + if err != nil { + t.Fatalf("fetchCrossplaneVersion: %v", err) + } + + if got != "v2.1.0" { + t.Errorf("got %q, want v2.1.0", got) + } +} + +func TestFetchCrossplaneVersion_NoDeployments(t *testing.T) { + clientset := fake.NewClientset() + + _, err := fetchCrossplaneVersion(context.Background(), clientset) + if err == nil { + t.Fatal("expected error, got nil") + } + + if !strings.Contains(err.Error(), "crossplane version or image tag not found") { + t.Errorf("error = %v, want the specific no-deployments message", err) + } +} + +func TestFetchCrossplaneVersion_ListError(t *testing.T) { + clientset := fake.NewClientset() + clientset.PrependReactor("list", "deployments", func(_ clienttesting.Action) (bool, runtime.Object, error) { + return true, nil, apierrors.NewForbidden( + appsv1.Resource("deployments"), "", errors.New("nope")) + }) + + _, err := fetchCrossplaneVersion(context.Background(), clientset) + if err == nil { + t.Fatal("expected error, got nil") + } + + if !strings.Contains(err.Error(), "could not fetch deployments") { + t.Errorf("error = %v, want wrapped 'could not fetch deployments'", err) + } +} diff --git a/cmd/diff/versioncmd/version.go b/cmd/diff/versioncmd/version.go index d82fd748..8489cba1 100644 --- a/cmd/diff/versioncmd/version.go +++ b/cmd/diff/versioncmd/version.go @@ -23,19 +23,38 @@ import ( "time" "github.com/alecthomas/kong" + "github.com/crossplane-contrib/crossplane-diff/cmd/diff/kubecfg" "github.com/crossplane-contrib/crossplane-diff/internal/versioninfo" "github.com/pkg/errors" - - xpversion "github.com/crossplane/crossplane/v2/cmd/crank/version" + "k8s.io/client-go/rest" ) const ( errGetCrossplaneVersion = "unable to get crossplane version" ) +// fetchFunc fetches the Crossplane server version for a given REST config. +// Exposed as a type so tests can substitute a stub on the Cmd struct. +type fetchFunc func(ctx context.Context, cfg *rest.Config) (string, error) + // Cmd represents the version command. type Cmd struct { - Client bool `env:"" help:"If true, shows client version only (no server required)."` + Client bool `env:"" help:"If true, shows client version only (no server required)."` + Context kubecfg.Context `help:"Kubernetes context to use (defaults to current context)." name:"context"` + + fetch fetchFunc `kong:"-"` // test seam; nil means use FetchCrossplaneVersion. +} + +// GetKubeContext implements kubecfg.Provider so the shared REST config provider +// (bound in main via kong.BindToProvider) can resolve a *rest.Config that +// honors the user's kubeconfig context. +func (c *Cmd) GetKubeContext() kubecfg.Context { return c.Context } + +// BeforeApply binds the Cmd pointer as the kubecfg.Provider so that providers +// resolved later (in Run) see the parsed --context value. +func (c *Cmd) BeforeApply(ctx *kong.Context) error { + ctx.BindTo(c, (*kubecfg.Provider)(nil)) + return nil } // Run runs the version command. @@ -49,8 +68,20 @@ func (c *Cmd) Run(k *kong.Context) error { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - // Reuse the upstream FetchCrossplaneVersion to get the server version - vxp, err := xpversion.FetchCrossplaneVersion(ctx) + // Resolve the REST config lazily (after flag parsing) via the shared + // kubecfg provider — honors --context and $KUBECONFIG, falls back to + // in-cluster only when no kubeconfig is available. + cfg, err := kubecfg.Provide(c) + if err != nil { + return errors.Wrap(err, errGetCrossplaneVersion) + } + + fetch := c.fetch + if fetch == nil { + fetch = FetchCrossplaneVersion + } + + vxp, err := fetch(ctx, cfg) if err != nil { return errors.Wrap(err, errGetCrossplaneVersion) } diff --git a/cmd/diff/versioncmd/version_test.go b/cmd/diff/versioncmd/version_test.go index cfc49cc7..1aceed4a 100644 --- a/cmd/diff/versioncmd/version_test.go +++ b/cmd/diff/versioncmd/version_test.go @@ -18,121 +18,191 @@ package versioncmd import ( "bytes" + "context" + "errors" + "os" + "path/filepath" "strings" "testing" "github.com/alecthomas/kong" + "k8s.io/client-go/rest" ) +// newKongCtx returns a kong.Context wired with an in-memory stdout buffer so +// tests can assert command output without touching the real stdout. +func newKongCtx(buf *bytes.Buffer) *kong.Context { + return &kong.Context{ + Kong: &kong.Kong{Stdout: buf}, + } +} + func TestCmd_Run_ClientOnly(t *testing.T) { - tests := []struct { - name string - client bool - wantSubstring string - }{ - { - name: "ClientVersionOnly", - client: true, - wantSubstring: "Client Version:", + var buf bytes.Buffer + + cmd := &Cmd{ + Client: true, + // Stub fetcher that would fail the test if called — --client must short-circuit. + fetch: func(context.Context, *rest.Config) (string, error) { + t.Fatal("fetcher should not be called when --client is set") + return "", nil }, } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Create a buffer to capture output - var buf bytes.Buffer - - // Create a Kong instance with our buffer as stdout - k := &kong.Kong{ - Stdout: &buf, - } - - // Create a kong.Context with the Kong instance - ctx := &kong.Context{ - Kong: k, - } - - // Create the command - cmd := &Cmd{ - Client: tt.client, - } - - // Run the command - err := cmd.Run(ctx) - if err != nil { - t.Errorf("Run() error = %v, want nil", err) - return - } - - // Check that output contains the expected substring - output := buf.String() - if !strings.Contains(output, tt.wantSubstring) { - t.Errorf("Run() output = %q, want to contain %q", output, tt.wantSubstring) - } - - // Verify it only outputs client version (no server version) - if tt.client && strings.Contains(output, "Server Version:") { - t.Errorf("Run() output = %q, should not contain 'Server Version:' when client=true", output) - } - }) + if err := cmd.Run(newKongCtx(&buf)); err != nil { + t.Fatalf("Run() error = %v, want nil", err) + } + + out := buf.String() + if !strings.Contains(out, "Client Version:") { + t.Errorf("output %q missing 'Client Version:'", out) + } + + if strings.Contains(out, "Server Version:") { + t.Errorf("output %q should not contain 'Server Version:' when --client is set", out) } } func TestCmd_Run_ServerVersion(t *testing.T) { - // This test verifies that when Client=false, the command attempts to fetch - // the server version. We can't easily test the full flow without a running - // cluster, so we just verify the command executes without panicking and - // returns an error (since we don't have a cluster in unit tests). - t.Run("ServerVersionAttempt", func(t *testing.T) { - var buf bytes.Buffer - - k := &kong.Kong{ - Stdout: &buf, - } - - ctx := &kong.Context{ - Kong: k, - } - - cmd := &Cmd{ - Client: false, // This will attempt to fetch server version - } - - // We expect this to error since we don't have a cluster - err := cmd.Run(ctx) - - // Verify client version was still printed - output := buf.String() - if !strings.Contains(output, "Client Version:") { - t.Errorf("Run() output = %q, want to contain 'Client Version:'", output) - } - - // We expect an error trying to connect to the server - if err == nil { - // If there's no error, that means either: - // 1. We have a cluster available (unlikely in unit tests) - // 2. The server version fetch succeeded - // Either way, we should check that server version was printed - if !strings.Contains(output, "Server Version:") && err == nil { - t.Errorf("Run() with Client=false succeeded but no Server Version in output") - } - } - }) + var buf bytes.Buffer + + called := false + cmd := &Cmd{ + fetch: func(_ context.Context, _ *rest.Config) (string, error) { + called = true + return "v2.0.2", nil + }, + } + + // Point KUBECONFIG at a temp (empty) file to short-circuit real kubeconfig + // loading. The stub above means we don't actually care what config is + // returned, as long as the flow gets there. + withTempKubeconfig(t) + + if err := cmd.Run(newKongCtx(&buf)); err != nil { + t.Fatalf("Run() error = %v, want nil", err) + } + + out := buf.String() + + if !called { + t.Error("fetcher was not called") + } + + if !strings.Contains(out, "Client Version:") { + t.Errorf("output %q missing 'Client Version:'", out) + } + + if !strings.Contains(out, "Server Version: v2.0.2") { + t.Errorf("output %q missing 'Server Version: v2.0.2'", out) + } } -func TestCmd_Structure(t *testing.T) { - // Verify the Cmd struct has the expected fields - cmd := &Cmd{} +func TestCmd_Run_ServerFetchErrorIsWrapped(t *testing.T) { + var buf bytes.Buffer - // Test that we can set Client to true - cmd.Client = true - if !cmd.Client { - t.Error("Cmd.Client field not working") + cmd := &Cmd{ + fetch: func(_ context.Context, _ *rest.Config) (string, error) { + return "", errors.New("boom") + }, } - // Test that we can set Client to false - cmd.Client = false - if cmd.Client { - t.Error("Cmd.Client field not working") + withTempKubeconfig(t) + + err := cmd.Run(newKongCtx(&buf)) + if err == nil { + t.Fatal("expected error, got nil") + } + + if !strings.Contains(err.Error(), errGetCrossplaneVersion) { + t.Errorf("error %q missing wrapper %q", err, errGetCrossplaneVersion) } + + if !strings.Contains(err.Error(), "boom") { + t.Errorf("error %q missing underlying cause 'boom'", err) + } +} + +func TestCmd_Run_ServerVersionEmptyDoesNotPrint(t *testing.T) { + // If the fetcher returns an empty string without error, we should not + // print a bogus "Server Version: " line. + var buf bytes.Buffer + + cmd := &Cmd{ + fetch: func(context.Context, *rest.Config) (string, error) { return "", nil }, + } + + withTempKubeconfig(t) + + if err := cmd.Run(newKongCtx(&buf)); err != nil { + t.Fatalf("Run() error = %v, want nil", err) + } + + if strings.Contains(buf.String(), "Server Version:") { + t.Errorf("output %q unexpectedly contains 'Server Version:'", buf.String()) + } +} + +func TestCmd_ContextFlag_Parses(t *testing.T) { + var cli struct { + Version Cmd `cmd:""` + } + + parser, err := kong.New(&cli) + if err != nil { + t.Fatalf("kong.New: %v", err) + } + + if _, err := parser.Parse([]string{"version", "--context", "foo", "--client"}); err != nil { + t.Fatalf("Parse: %v", err) + } + + if cli.Version.Context != "foo" { + t.Errorf("Context = %q, want %q", cli.Version.Context, "foo") + } + + if !cli.Version.Client { + t.Error("expected --client to parse as true") + } +} + +func TestCmd_GetKubeContext(t *testing.T) { + cmd := &Cmd{Context: "staging"} + if got := cmd.GetKubeContext(); got != "staging" { + t.Errorf("GetKubeContext() = %q, want %q", got, "staging") + } +} + +// withTempKubeconfig points $KUBECONFIG at an empty temp dir entry so +// kubecfg.Provide returns a resolvable (albeit dummy) config when tests run +// without caring about what the resulting REST config points at. It isolates +// the test from the developer's real ~/.kube/config. +func withTempKubeconfig(t *testing.T) { + t.Helper() + + const yaml = `apiVersion: v1 +kind: Config +current-context: default +clusters: +- name: default + cluster: + server: https://127.0.0.1:0 +contexts: +- name: default + context: + cluster: default + user: default +users: +- name: default + user: {} +` + + dir := t.TempDir() + + path := filepath.Join(dir, "kubeconfig") + if err := os.WriteFile(path, []byte(yaml), 0o600); err != nil { + t.Fatalf("write kubeconfig: %v", err) + } + + t.Setenv("KUBECONFIG", path) } diff --git a/go.mod b/go.mod index 53f31409..664785be 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/crossplane/crossplane/v2 v2.2.1 github.com/docker/docker v28.5.2+incompatible github.com/google/go-cmp v0.7.0 + github.com/google/go-containerregistry v0.20.7 github.com/pkg/errors v0.9.1 github.com/sergi/go-diff v1.4.0 k8s.io/api v0.35.1 @@ -49,7 +50,6 @@ require ( github.com/google/btree v1.1.3 // indirect github.com/google/cel-go v0.26.1 // indirect github.com/google/gnostic-models v0.7.1 // indirect - github.com/google/go-containerregistry v0.20.7 // indirect github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 // indirect github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect From 654f902401c8900446d003a06cb927aa30e087ff Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 30 Apr 2026 00:39:38 +0000 Subject: [PATCH 19/26] chore(deps): update dependency helm/helm to v4.1.4 (#283) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- Earthfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Earthfile b/Earthfile index 2a038da2..a6b39f0b 100644 --- a/Earthfile +++ b/Earthfile @@ -368,7 +368,7 @@ helm-docs-setup: # helm-setup is used by other targets to setup helm. helm-setup: - ARG HELM_VERSION=v4.1.3 + ARG HELM_VERSION=v4.1.4 ARG NATIVEPLATFORM ARG TARGETOS ARG TARGETARCH From 1645d1334e0ac60ec445bbbb733ad1bae546ce23 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 30 Apr 2026 00:56:39 +0000 Subject: [PATCH 20/26] fix(deps): update module github.com/google/go-containerregistry to v0.21.5 (#306) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- go.mod | 16 +++++++++------- go.sum | 30 ++++++++++++++---------------- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/go.mod b/go.mod index 664785be..f5d8408e 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,7 @@ require ( github.com/crossplane/crossplane/v2 v2.2.1 github.com/docker/docker v28.5.2+incompatible github.com/google/go-cmp v0.7.0 - github.com/google/go-containerregistry v0.20.7 + github.com/google/go-containerregistry v0.21.5 github.com/pkg/errors v0.9.1 github.com/sergi/go-diff v1.4.0 k8s.io/api v0.35.1 @@ -30,6 +30,7 @@ require ( github.com/chai2010/gettext-go v1.0.2 // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect github.com/distribution/reference v0.6.0 // indirect github.com/docker/go-connections v0.6.0 // indirect github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f // indirect @@ -55,7 +56,9 @@ require ( github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect github.com/mitchellh/go-wordwrap v1.0.1 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/sys/atomicwriter v0.1.0 // indirect github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect + github.com/morikuni/aec v1.1.0 // indirect github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect github.com/nxadm/tail v1.4.11 // indirect github.com/peterbourgon/diskv v2.0.1+incompatible // indirect @@ -97,10 +100,9 @@ require ( github.com/Microsoft/go-winio v0.6.2 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/containerd/stargz-snapshotter/estargz v0.18.1 // indirect + github.com/containerd/stargz-snapshotter/estargz v0.18.2 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/docker/cli v29.2.0+incompatible // indirect - github.com/docker/distribution v2.8.3+incompatible // indirect + github.com/docker/cli v29.4.0+incompatible // indirect github.com/docker/docker-credential-helpers v0.9.4 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/emicklei/go-restful/v3 v3.13.0 // indirect @@ -118,7 +120,7 @@ require ( github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/compress v1.18.2 // indirect + github.com/klauspost/compress v1.18.5 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect @@ -143,14 +145,14 @@ require ( go.opentelemetry.io/otel/trace v1.43.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.1 // indirect - golang.org/x/mod v0.34.0 // indirect + golang.org/x/mod v0.35.0 // indirect golang.org/x/net v0.53.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sys v0.43.0 // indirect golang.org/x/term v0.42.0 // indirect golang.org/x/text v0.36.0 // indirect golang.org/x/time v0.15.0 // indirect - golang.org/x/tools v0.43.0 // indirect + golang.org/x/tools v0.44.0 // indirect gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect gopkg.in/inf.v0 v0.9.1 // indirect diff --git a/go.sum b/go.sum index fe689869..f6e02922 100644 --- a/go.sum +++ b/go.sum @@ -42,8 +42,8 @@ github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151X github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= -github.com/containerd/stargz-snapshotter/estargz v0.18.1 h1:cy2/lpgBXDA3cDKSyEfNOFMA/c10O1axL69EU7iirO8= -github.com/containerd/stargz-snapshotter/estargz v0.18.1/go.mod h1:ALIEqa7B6oVDsrF37GkGN20SuvG/pIMm7FwP7ZmRb0Q= +github.com/containerd/stargz-snapshotter/estargz v0.18.2 h1:yXkZFYIzz3eoLwlTUZKz2iQ4MrckBxJjkmD16ynUTrw= +github.com/containerd/stargz-snapshotter/estargz v0.18.2/go.mod h1:XyVU5tcJ3PRpkA9XS2T5us6Eg35yM0214Y+wvrZTBrY= github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4= github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03VsM8rvUec= github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= @@ -61,10 +61,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= -github.com/docker/cli v29.2.0+incompatible h1:9oBd9+YM7rxjZLfyMGxjraKBKE4/nVyvVfN4qNl9XRM= -github.com/docker/cli v29.2.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= -github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk= -github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= +github.com/docker/cli v29.4.0+incompatible h1:+IjXULMetlvWJiuSI0Nbor36lcJ5BTcVpUmB21KBoVM= +github.com/docker/cli v29.4.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/docker-credential-helpers v0.9.4 h1:76ItO69/AP/V4yT9V4uuuItG0B1N8hvt0T0c0NN/DzI= @@ -150,8 +148,8 @@ github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7O github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/go-containerregistry v0.20.7 h1:24VGNpS0IwrOZ2ms2P1QE3Xa5X9p4phx0aUgzYzHW6I= -github.com/google/go-containerregistry v0.20.7/go.mod h1:Lx5LCZQjLH1QBaMPeGwsME9biPeo1lPx6lbGj/UmzgM= +github.com/google/go-containerregistry v0.21.5 h1:KTJG9Pn/jC0VdZR6ctV3/jcN+q6/Iqlx0sTVz3ywZlM= +github.com/google/go-containerregistry v0.21.5/go.mod h1:ySvMuiWg+dOsRW0Hw8GYwfMwBlNRTmpYBFJPlkco5zU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -173,8 +171,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= -github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= +github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -212,8 +210,8 @@ github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFd github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 h1:n6/2gBQ3RWajuToeY6ZtZTIKv2v7ThUy5KKusIT0yc0= github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00/go.mod h1:Pm3mSP3c5uWn86xMLZ5Sa7JB9GsEZySvHYXCTK4E9q4= -github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= -github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/morikuni/aec v1.1.0 h1:vBBl0pUnvi/Je71dsRrhMBtreIqNMYErSAbEeb8jrXQ= +github.com/morikuni/aec v1.1.0/go.mod h1:xDRgiq/iw5l+zkao76YTKzKttOp2cwPEne25HDkJnBw= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus= @@ -332,8 +330,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU= golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= -golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= @@ -365,8 +363,8 @@ golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= -golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= -golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM= golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM= From 20cf059201eb4dd497396d4f892b01af736eb499 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 4 May 2026 11:45:53 -0400 Subject: [PATCH 21/26] chore(deps): update dependency github/codeql-action to v2.25.3 (#307) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- Earthfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Earthfile b/Earthfile index a6b39f0b..0f4e2664 100644 --- a/Earthfile +++ b/Earthfile @@ -403,7 +403,7 @@ ci-artifacts: # ci-codeql-setup sets up CodeQL for the ci-codeql target. ci-codeql-setup: - ARG CODEQL_VERSION=2.25.2 + ARG CODEQL_VERSION=2.25.3 FROM curlimages/curl:8.18.0 RUN curl -fsSL https://github.com/github/codeql-action/releases/download/codeql-bundle-v${CODEQL_VERSION}/codeql-bundle-linux64.tar.gz|tar zx SAVE ARTIFACT codeql From 5040798549dff7112787d320d41f2ea7f37480e1 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 4 May 2026 11:59:09 -0400 Subject: [PATCH 22/26] chore(deps): update github/codeql-action digest to e46ed2c (#308) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 792d699b..4b020ee0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -115,7 +115,7 @@ jobs: run: earthly --strict --remote-cache ghcr.io/crossplane-contrib/crossplane-diff/earthly-cache:${{ github.job }} +ci-codeql - name: Upload CodeQL Results to GitHub - uses: github/codeql-action/upload-sarif@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4 + uses: github/codeql-action/upload-sarif@e46ed2cbd01164d986452f91f178727624ae40d7 # v4 with: sarif_file: '_output/codeql/go.sarif' @@ -141,7 +141,7 @@ jobs: output: 'trivy-results.sarif' - name: Upload Trivy Results to GitHub - uses: github/codeql-action/upload-sarif@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4 + uses: github/codeql-action/upload-sarif@e46ed2cbd01164d986452f91f178727624ae40d7 # v4 with: sarif_file: 'trivy-results.sarif' From 119962dd5b5e2d9259066855e4894a35920612f8 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 4 May 2026 18:16:32 -0400 Subject: [PATCH 23/26] chore(deps): update amazon/aws-cli docker tag to v2.34.41 (#310) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- Earthfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Earthfile b/Earthfile index 0f4e2664..24c4bda6 100644 --- a/Earthfile +++ b/Earthfile @@ -461,7 +461,7 @@ ci-push-build-artifacts: ARG ARTIFACTS_DIR=_output ARG BUCKET_RELEASES=crossplane.releases ARG AWS_DEFAULT_REGION - FROM amazon/aws-cli:2.34.25 + FROM amazon/aws-cli:2.34.42 COPY --dir ${ARTIFACTS_DIR} artifacts RUN --push --secret=AWS_ACCESS_KEY_ID --secret=AWS_SECRET_ACCESS_KEY aws s3 sync --delete --only-show-errors artifacts s3://${BUCKET_RELEASES}/build/${BUILD_DIR}/${CROSSPLANE_VERSION} @@ -477,7 +477,7 @@ ci-promote-build-artifacts: ARG BUCKET_CHARTS=crossplane.charts ARG PRERELEASE=false ARG AWS_DEFAULT_REGION - FROM amazon/aws-cli:2.34.25 + FROM amazon/aws-cli:2.34.42 RUN --secret=AWS_ACCESS_KEY_ID --secret=AWS_SECRET_ACCESS_KEY aws s3 sync --only-show-errors s3://${BUCKET_RELEASES}/build/${BUILD_DIR}/${CROSSPLANE_VERSION}/charts repo RUN --push --secret=AWS_ACCESS_KEY_ID --secret=AWS_SECRET_ACCESS_KEY aws s3 sync --delete --only-show-errors s3://${BUCKET_RELEASES}/build/${BUILD_DIR}/${CROSSPLANE_VERSION} s3://${BUCKET_RELEASES}/${CHANNEL}/${CROSSPLANE_VERSION} IF [ "${PRERELEASE}" = "false" ] From c18961634477380d67b8a4c8d833f6b97978ab80 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 4 May 2026 18:55:55 -0400 Subject: [PATCH 24/26] chore(deps): update e2e-manifests (#311) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- test/e2e/manifests/beta/diff/main/_setup/functions.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/e2e/manifests/beta/diff/main/_setup/functions.yaml b/test/e2e/manifests/beta/diff/main/_setup/functions.yaml index 100e5d1e..8d95327d 100644 --- a/test/e2e/manifests/beta/diff/main/_setup/functions.yaml +++ b/test/e2e/manifests/beta/diff/main/_setup/functions.yaml @@ -3,7 +3,7 @@ kind: Function metadata: name: function-patch-and-transform spec: - package: xpkg.upbound.io/crossplane-contrib/function-patch-and-transform:v0.10.3 + package: xpkg.upbound.io/crossplane-contrib/function-patch-and-transform:v0.10.4 --- apiVersion: pkg.crossplane.io/v1beta1 kind: Function @@ -17,4 +17,4 @@ kind: Function metadata: name: function-auto-ready spec: - package: xpkg.upbound.io/crossplane-contrib/function-auto-ready:v0.6.3 + package: xpkg.upbound.io/crossplane-contrib/function-auto-ready:v0.6.4 From 48998d44d2184eb78d1dbd0a77da58b2000e9c64 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 5 May 2026 10:47:52 -0400 Subject: [PATCH 25/26] chore(deps): update curlimages/curl docker tag to v8.20.0 (#312) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- Earthfile | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Earthfile b/Earthfile index 24c4bda6..a0ec1c0e 100644 --- a/Earthfile +++ b/Earthfile @@ -327,7 +327,7 @@ kubectl-setup: ARG NATIVEPLATFORM ARG TARGETOS ARG TARGETARCH - FROM --platform=${NATIVEPLATFORM} curlimages/curl:8.18.0 + FROM --platform=${NATIVEPLATFORM} curlimages/curl:8.20.0 RUN curl -fsSL https://dl.k8s.io/${KUBECTL_VERSION}/kubernetes-client-${TARGETOS}-${TARGETARCH}.tar.gz|tar zx SAVE ARTIFACT kubernetes/client/bin/kubectl @@ -337,7 +337,7 @@ kind-setup: ARG NATIVEPLATFORM ARG TARGETOS ARG TARGETARCH - FROM --platform=${NATIVEPLATFORM} curlimages/curl:8.18.0 + FROM --platform=${NATIVEPLATFORM} curlimages/curl:8.20.0 RUN curl -fsSLo kind https://github.com/kubernetes-sigs/kind/releases/download/${KIND_VERSION}/kind-${TARGETOS}-${TARGETARCH}&&chmod +x kind SAVE ARTIFACT kind @@ -347,7 +347,7 @@ gotestsum-setup: ARG NATIVEPLATFORM ARG TARGETOS ARG TARGETARCH - FROM --platform=${NATIVEPLATFORM} curlimages/curl:8.18.0 + FROM --platform=${NATIVEPLATFORM} curlimages/curl:8.20.0 RUN curl -fsSL https://github.com/gotestyourself/gotestsum/releases/download/v${GOTESTSUM_VERSION}/gotestsum_${GOTESTSUM_VERSION}_${TARGETOS}_${TARGETARCH}.tar.gz|tar zx>gotestsum SAVE ARTIFACT gotestsum @@ -357,7 +357,7 @@ helm-docs-setup: ARG NATIVEPLATFORM ARG TARGETOS ARG TARGETARCH - FROM --platform=${NATIVEPLATFORM} curlimages/curl:8.18.0 + FROM --platform=${NATIVEPLATFORM} curlimages/curl:8.20.0 IF [ "${TARGETARCH}" = "amd64" ] LET ARCH=x86_64 ELSE @@ -372,7 +372,7 @@ helm-setup: ARG NATIVEPLATFORM ARG TARGETOS ARG TARGETARCH - FROM --platform=${NATIVEPLATFORM} curlimages/curl:8.18.0 + FROM --platform=${NATIVEPLATFORM} curlimages/curl:8.20.0 RUN curl -fsSL https://get.helm.sh/helm-${HELM_VERSION}-${TARGETOS}-${TARGETARCH}.tar.gz|tar zx --strip-components=1 SAVE ARTIFACT helm @@ -404,7 +404,7 @@ ci-artifacts: # ci-codeql-setup sets up CodeQL for the ci-codeql target. ci-codeql-setup: ARG CODEQL_VERSION=2.25.3 - FROM curlimages/curl:8.18.0 + FROM curlimages/curl:8.20.0 RUN curl -fsSL https://github.com/github/codeql-action/releases/download/codeql-bundle-v${CODEQL_VERSION}/codeql-bundle-linux64.tar.gz|tar zx SAVE ARTIFACT codeql From cc93bdac3b8a5a1337584891aa682c92c082bc50 Mon Sep 17 00:00:00 2001 From: Stephen Cahill Date: Fri, 24 Apr 2026 16:23:44 -0400 Subject: [PATCH 26/26] fix(function-provider): sanitize colon in container name for tag+digest package refs When a function package ref has both a tag and a digest (e.g., func:v0.6.1-0@sha256:751a...), the SHA256 branch of generateContainerName split on @sha256: but left the colon from the tag in the function name, producing an invalid Docker container name. Signed-off-by: Stephen Cahill --- cmd/diff/diffprocessor/function_provider.go | 4 ++-- cmd/diff/diffprocessor/function_provider_test.go | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/cmd/diff/diffprocessor/function_provider.go b/cmd/diff/diffprocessor/function_provider.go index 488850f2..9b8a5809 100644 --- a/cmd/diff/diffprocessor/function_provider.go +++ b/cmd/diff/diffprocessor/function_provider.go @@ -275,8 +275,8 @@ func generateContainerName(pkg, instanceID string) string { // Handle SHA256 digest references: name@sha256:digest // Extract just the function name and a truncated digest if before, after, ok := strings.Cut(nameAndVersion, "@sha256:"); ok { - funcName := before - digest := after // Skip "@sha256:" + funcName := strings.ReplaceAll(before, ":", "-") + digest := after // Use first 12 chars of digest (like Docker short image IDs) if len(digest) > 12 { diff --git a/cmd/diff/diffprocessor/function_provider_test.go b/cmd/diff/diffprocessor/function_provider_test.go index 7b17bb99..6df98267 100644 --- a/cmd/diff/diffprocessor/function_provider_test.go +++ b/cmd/diff/diffprocessor/function_provider_test.go @@ -545,6 +545,10 @@ func TestGenerateContainerName(t *testing.T) { pkg: "xpkg.io/test/function@sha256:abc123", want: "function-abc123-comp-test1234", }, + "TagAndDigest": { + pkg: "registry.io/path/function-auto-ready:v0.6.1-0@sha256:751a4afb65f1abcdef1234567890abcdef1234567890abcdef1234567890abcd", + want: "function-auto-ready-v0.6.1-0-751a4afb65f1-comp-test1234", + }, } for name, tt := range tests {