diff --git a/README.md b/README.md index 924e12a..f98d288 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,11 @@ Flags: --function-credentials=PATH Path to YAML file or directory containing Secret resources to pass as function credentials. Overrides auto-fetched credentials from cluster. + --function-registry-override=STRING + Override the registry for all function images + (e.g., 'my-company.registry.io'). Useful when + pulling functions from a mirror or private + registry. --eventual-state Show eventual state after all reconciliation cycles complete. Useful with function-sequencer which hides later stage resources until earlier stages become Ready. @@ -186,6 +191,11 @@ Flags: --function-credentials=PATH Path to YAML file or directory containing Secret resources to pass as function credentials. Overrides auto-fetched credentials from cluster. + --function-registry-override=STRING + Override the registry for all function images + (e.g., 'my-company.registry.io'). Useful when + pulling functions from a mirror or private + registry. ``` **Note**: The `diff` subcommand is deprecated. Use `xr` instead. diff --git a/cmd/diff/cmd_utils.go b/cmd/diff/cmd_utils.go index b2524f6..702c1cf 100644 --- a/cmd/diff/cmd_utils.go +++ b/cmd/diff/cmd_utils.go @@ -90,6 +90,10 @@ func defaultProcessorOptions(fields CommonCmdFields, namespace string) []dp.Proc opts = append(opts, dp.WithFunctionCredentials(fields.FunctionCredentials.Secrets)) } + if fields.FunctionRegistryOverride != "" { + opts = append(opts, dp.WithFunctionRegistryOverride(fields.FunctionRegistryOverride)) + } + return opts } diff --git a/cmd/diff/diffprocessor/diff_processor.go b/cmd/diff/diffprocessor/diff_processor.go index 76f9aca..de61e54 100644 --- a/cmd/diff/diffprocessor/diff_processor.go +++ b/cmd/diff/diffprocessor/diff_processor.go @@ -115,7 +115,11 @@ func NewDiffProcessor(k8cs k8.Clients, xpcs xp.Clients, opts ...ProcessorOption) requirementsProvider := config.Factories.RequirementsProvider(k8cs.Resource, xpcs.Environment, config.RenderFunc, config.Logger) diffCalculator := config.Factories.DiffCalculator(k8cs.Apply, xpcs.ResourceTree, resourceManager, config.Logger, diffOpts) diffRenderer := config.Factories.DiffRenderer(config.Logger, diffOpts) + functionProvider := config.Factories.FunctionProvider(xpcs.Function, config.Logger) + if config.FunctionRegistryOverride != "" { + functionProvider = NewRegistryOverrideFunctionProvider(functionProvider, config.FunctionRegistryOverride, config.Logger) + } processor := &DefaultDiffProcessor{ compClient: xpcs.Composition, diff --git a/cmd/diff/diffprocessor/function_provider.go b/cmd/diff/diffprocessor/function_provider.go index 9b8a580..94e4774 100644 --- a/cmd/diff/diffprocessor/function_provider.go +++ b/cmd/diff/diffprocessor/function_provider.go @@ -302,6 +302,78 @@ func generateContainerName(pkg, instanceID string) string { return containerName } +// RegistryOverrideFunctionProvider wraps another FunctionProvider and replaces +// the registry in each function's package reference before returning them. +type RegistryOverrideFunctionProvider struct { + inner FunctionProvider + registry string + logger logging.Logger +} + +// NewRegistryOverrideFunctionProvider wraps inner, replacing the registry +// portion of every function package ref with the given registry. +func NewRegistryOverrideFunctionProvider(inner FunctionProvider, registry string, logger logging.Logger) FunctionProvider { + return &RegistryOverrideFunctionProvider{ + inner: inner, + registry: registry, + logger: logger, + } +} + +// GetFunctionsForComposition delegates to the wrapped provider and rewrites +// the registry portion of each returned function's package ref. +func (p *RegistryOverrideFunctionProvider) GetFunctionsForComposition(comp *apiextensionsv1.Composition) ([]pkgv1.Function, error) { + fns, err := p.inner.GetFunctionsForComposition(comp) + if err != nil { + return nil, err + } + + for i := range fns { + orig := fns[i].Spec.Package + + replaced := replaceRegistry(orig, p.registry) + if replaced != orig { + p.logger.Debug("Overriding function registry", + "function", fns[i].GetName(), + "from", orig, + "to", replaced) + fns[i].Spec.Package = replaced + } + } + + return fns, nil +} + +// Cleanup delegates to the wrapped provider. +func (p *RegistryOverrideFunctionProvider) Cleanup(ctx context.Context) error { + return p.inner.Cleanup(ctx) +} + +// replaceRegistry replaces the registry portion of an OCI package reference, +// preserving the repository path, tag, and/or digest. A trailing slash on +// newRegistry is trimmed. +// +// The first path component is treated as a registry host only when it follows +// the standard OCI rule: it contains a '.' or ':', or it is exactly +// "localhost". Otherwise the ref has no explicit registry (e.g. +// "crossplane-contrib/function-auto-ready:v1.0.0") and newRegistry is prepended +// to the full ref instead of replacing the first path segment. +func replaceRegistry(pkg, newRegistry string) string { + newRegistry = strings.TrimRight(newRegistry, "/") + + idx := strings.Index(pkg, "/") + if idx < 0 { + return newRegistry + "/" + pkg + } + + first := pkg[:idx] + if !strings.ContainsAny(first, ".:") && first != "localhost" { + return newRegistry + "/" + pkg + } + + return newRegistry + pkg[idx:] +} + // truncateContainerName shortens a container name to fit within maxContainerNameLength // while maintaining uniqueness via a hash suffix derived from the original package name. func truncateContainerName(name, pkg, instanceID string) string { diff --git a/cmd/diff/diffprocessor/function_provider_test.go b/cmd/diff/diffprocessor/function_provider_test.go index 6df9826..89b6800 100644 --- a/cmd/diff/diffprocessor/function_provider_test.go +++ b/cmd/diff/diffprocessor/function_provider_test.go @@ -499,6 +499,117 @@ func TestGenerateContainerName_TruncationStability(t *testing.T) { } } +func TestReplaceRegistry(t *testing.T) { + tests := map[string]struct { + pkg string + newRegistry string + want string + }{ + "StandardPackage": { + pkg: "xpkg.crossplane.io/crossplane-contrib/function-go-templating:v0.11.0", + newRegistry: "registry.example.com", + want: "registry.example.com/crossplane-contrib/function-go-templating:v0.11.0", + }, + "DifferentRegistry": { + pkg: "ghcr.io/crossplane/function-auto-ready:v1.2.3", + newRegistry: "registry.example.com", + want: "registry.example.com/crossplane/function-auto-ready:v1.2.3", + }, + "SHA256Digest": { + pkg: "old.registry.io/org/func@sha256:abc123", + newRegistry: "new.registry.io", + want: "new.registry.io/org/func@sha256:abc123", + }, + "NoExplicitRegistry": { + // First segment lacks '.', ':' or "localhost", so it is treated as + // part of the path (per OCI rules), and newRegistry is prepended. + pkg: "crossplane-contrib/function-auto-ready:v1.0.0", + newRegistry: "new.registry.io", + want: "new.registry.io/crossplane-contrib/function-auto-ready:v1.0.0", + }, + "NewRegistryWithTrailingSlash": { + pkg: "xpkg.crossplane.io/foo/bar:v1", + newRegistry: "new.registry.io/", + want: "new.registry.io/foo/bar:v1", + }, + "LocalhostWithPort": { + pkg: "localhost:5000/foo/bar:v1", + newRegistry: "new.registry.io", + want: "new.registry.io/foo/bar:v1", + }, + "NoSlash": { + pkg: "bare-image:v1", + newRegistry: "new.registry.io", + want: "new.registry.io/bare-image:v1", + }, + "DeepPath": { + pkg: "registry.io/a/b/c/func:v1", + newRegistry: "other.io", + want: "other.io/a/b/c/func:v1", + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + got := replaceRegistry(tt.pkg, tt.newRegistry) + if got != tt.want { + t.Errorf("replaceRegistry(%q, %q) = %q, want %q", tt.pkg, tt.newRegistry, got, tt.want) + } + }) + } +} + +func TestRegistryOverrideFunctionProvider(t *testing.T) { + functions := []pkgv1.Function{ + { + ObjectMeta: metav1.ObjectMeta{Name: "function-go-templating"}, + Spec: pkgv1.FunctionSpec{ + PackageSpec: pkgv1.PackageSpec{ + Package: "original.registry.io/crossplane-contrib/function-go-templating:v0.11.0", + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{Name: "function-auto-ready"}, + Spec: pkgv1.FunctionSpec{ + PackageSpec: pkgv1.PackageSpec{ + Package: "other.registry.io/crossplane-contrib/function-auto-ready:v1.0.0", + }, + }, + }, + } + + fnClient := tu.NewMockFunctionClient(). + WithSuccessfulFunctionsFetch(functions). + Build() + + logger := tu.TestLogger(t, false) + inner := NewDefaultFunctionProvider(fnClient, logger) + + provider := NewRegistryOverrideFunctionProvider(inner, "registry.example.com", logger) + + comp := &apiextensionsv1.Composition{ + ObjectMeta: metav1.ObjectMeta{Name: "test-composition"}, + } + + fns, err := provider.GetFunctionsForComposition(comp) + if err != nil { + t.Fatalf("GetFunctionsForComposition() error = %v", err) + } + + if len(fns) != 2 { + t.Fatalf("GetFunctionsForComposition() returned %d functions, want 2", len(fns)) + } + + if fns[0].Spec.Package != "registry.example.com/crossplane-contrib/function-go-templating:v0.11.0" { + t.Errorf("function[0].Spec.Package = %q, want overridden registry", fns[0].Spec.Package) + } + + if fns[1].Spec.Package != "registry.example.com/crossplane-contrib/function-auto-ready:v1.0.0" { + t.Errorf("function[1].Spec.Package = %q, want overridden registry", fns[1].Spec.Package) + } +} + func TestGenerateContainerName(t *testing.T) { const testInstanceID = "test1234" diff --git a/cmd/diff/diffprocessor/processor_config.go b/cmd/diff/diffprocessor/processor_config.go index c75796d..be0bb56 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 + // FunctionRegistryOverride overrides the registry in all function package refs. + FunctionRegistryOverride string + // Stdout is the writer for diff output (defaults to os.Stdout) Stdout io.Writer @@ -169,6 +172,13 @@ func WithFunctionCredentials(creds []corev1.Secret) ProcessorOption { } } +// WithFunctionRegistryOverride overrides the registry in all function package refs. +func WithFunctionRegistryOverride(registry string) ProcessorOption { + return func(config *ProcessorConfig) { + config.FunctionRegistryOverride = registry + } +} + // WithStdout sets the writer for diff output. func WithStdout(w io.Writer) ProcessorOption { return func(config *ProcessorConfig) { diff --git a/cmd/diff/main.go b/cmd/diff/main.go index 4a72913..6a95670 100644 --- a/cmd/diff/main.go +++ b/cmd/diff/main.go @@ -94,15 +94,16 @@ func (f *FunctionCredentials) Decode(ctx *kong.DecodeContext) error { // after flag parsing completes. type CommonCmdFields struct { // Configuration options - Context KubeContext `help:"Kubernetes context to use (defaults to current context)." name:"context"` - Output string `default:"diff" enum:"diff,json,yaml" help:"Output format (diff, json, or yaml)." name:"output" short:"o"` - NoColor bool `help:"Disable colorized output." name:"no-color"` - Compact bool `help:"Show compact diffs with minimal context." name:"compact"` - MaxNestedDepth int `default:"10" help:"Maximum depth for nested XR recursion." name:"max-nested-depth"` - MaxIterations int `default:"20" help:"Maximum render iterations. Increase for complex pipelines that need more cycles to converge." name:"max-iterations"` - Timeout time.Duration `default:"1m" help:"How long to run before timing out."` - IgnorePaths []string `help:"Paths to ignore in diffs (e.g., 'metadata.annotations[argocd.argoproj.io/tracking-id]')." name:"ignore-paths"` - FunctionCredentials FunctionCredentials `help:"A YAML file or directory of YAML files specifying Secret credentials to pass to Functions." name:"function-credentials" placeholder:"PATH"` + Context KubeContext `help:"Kubernetes context to use (defaults to current context)." name:"context"` + Output string `default:"diff" enum:"diff,json,yaml" help:"Output format (diff, json, or yaml)." name:"output" short:"o"` + NoColor bool `help:"Disable colorized output." name:"no-color"` + Compact bool `help:"Show compact diffs with minimal context." name:"compact"` + MaxNestedDepth int `default:"10" help:"Maximum depth for nested XR recursion." name:"max-nested-depth"` + MaxIterations int `default:"20" help:"Maximum render iterations. Increase for complex pipelines that need more cycles to converge." name:"max-iterations"` + Timeout time.Duration `default:"1m" help:"How long to run before timing out."` + IgnorePaths []string `help:"Paths to ignore in diffs (e.g., 'metadata.annotations[argocd.argoproj.io/tracking-id]')." name:"ignore-paths"` + FunctionCredentials FunctionCredentials `help:"A YAML file or directory of YAML files specifying Secret credentials to pass to Functions." name:"function-credentials" placeholder:"PATH"` + FunctionRegistryOverride string `help:"Override the registry for all function images (e.g., 'my-company.registry.io')." name:"function-registry-override"` } // GetKubeContext implements ContextProvider.