diff --git a/README.md b/README.md index 940b96d..80059f1 100644 --- a/README.md +++ b/README.md @@ -166,10 +166,14 @@ Flags: --eventual-state Show eventual state after all reconciliation cycles complete. Useful with function-sequencer which hides later stage resources until earlier stages become Ready. + --max-recv-message-size=INT Max gRPC message size (MB) for render function + containers (4MB if undefined) ($CROSSPLANE_DIFF_MAX_RECV_MESSAGE_SIZE). ``` **Note**: XR namespaces are read directly from the YAML files being diffed, not from command-line flags. +**Large composites**: `crossplane render` starts functions with the function-sdk-go default 4MB gRPC receive limit and does not apply the cluster's DeploymentRuntimeConfig. Very large XRs (many/large observed resources) can exceed this and fail with `ResourceExhausted: received message larger than max`. Set `--max-recv-message-size` (or `CROSSPLANE_DIFF_MAX_RECV_MESSAGE_SIZE`) to raise it; crossplane-diff injects the value as the `MAX_RECV_MESSAGE_SIZE` container env var. This only takes effect on functions whose image reads that variable. + **Ignored Paths**: By default, `metadata.annotations[kubectl.kubernetes.io/last-applied-configuration]` is always ignored. Additional paths can be specified with `--ignore-paths`. This is useful for filtering out metadata added by tools like ArgoCD (e.g., tracking IDs, sync waves) that shouldn't affect diff results. #### `comp` - Diff Composition Impact @@ -210,6 +214,8 @@ Flags: --eventual-state Show eventual state after all reconciliation cycles complete. Useful with function-sequencer which hides later stage resources until earlier stages become Ready. + --max-recv-message-size=INT Max gRPC message size (MB) for render function + containers (4MB if undefined) ($CROSSPLANE_DIFF_MAX_RECV_MESSAGE_SIZE). --resource=STRING,... Limit impact analysis to specific composites in [namespace/]name format. Repeatable or comma-separated. Bare name means cluster-scoped. Mutually exclusive with diff --git a/cmd/diff/cmd_utils.go b/cmd/diff/cmd_utils.go index d4b4d5b..bfa4016 100644 --- a/cmd/diff/cmd_utils.go +++ b/cmd/diff/cmd_utils.go @@ -88,6 +88,10 @@ func defaultProcessorOptions(fields CommonCmdFields) []dp.ProcessorOption { opts = append(opts, dp.WithFunctionRegistryOverride(fields.FunctionRegistryOverride)) } + if fields.MaxRecvMessageSize > 0 { + opts = append(opts, dp.WithMaxRecvMessageSize(fields.MaxRecvMessageSize)) + } + if fields.CrossplaneRenderBinary != "" { opts = append(opts, dp.WithCrossplaneRenderBinary(fields.CrossplaneRenderBinary)) } diff --git a/cmd/diff/diffprocessor/diff_processor.go b/cmd/diff/diffprocessor/diff_processor.go index 2786dfc..dc21962 100644 --- a/cmd/diff/diffprocessor/diff_processor.go +++ b/cmd/diff/diffprocessor/diff_processor.go @@ -8,6 +8,7 @@ import ( "os" "slices" "sort" + "strconv" "strings" "time" @@ -129,6 +130,11 @@ func NewDiffProcessor(k8cs k8.Clients, xpcs xp.Clients, opts ...ProcessorOption) functionProvider = NewRegistryOverrideFunctionProvider(functionProvider, config.FunctionRegistryOverride, config.Logger) } + if config.MaxRecvMessageSize > 0 { + functionProvider = NewEnvInjectingFunctionProvider(functionProvider, + map[string]string{EnvMaxRecvMessageSize: strconv.Itoa(config.MaxRecvMessageSize)}, config.Logger) + } + processor := &DefaultDiffProcessor{ compClient: xpcs.Composition, credentialClient: xpcs.Credential, diff --git a/cmd/diff/diffprocessor/function_provider.go b/cmd/diff/diffprocessor/function_provider.go index d521c23..1af0384 100644 --- a/cmd/diff/diffprocessor/function_provider.go +++ b/cmd/diff/diffprocessor/function_provider.go @@ -357,6 +357,103 @@ func (p *RegistryOverrideFunctionProvider) Cleanup(ctx context.Context) error { return p.inner.Cleanup(ctx) } +// EnvMaxRecvMessageSize is the container env var crossplane-diff injects to +// raise a function's gRPC max receive size. It is intentionally unprefixed and +// function-agnostic, mirroring the SDK-wide TLS_SERVER_CERTS_DIR convention +// shared by crossplane composition functions: functions that bind it honor it, +// functions that don't simply ignore it. +const EnvMaxRecvMessageSize = "MAX_RECV_MESSAGE_SIZE" + +// annKeyRuntimeDockerEnv mirrors crossplane/cli render's +// AnnotationKeyRuntimeEnvironmentVariables: a comma-separated list of key=value +// pairs set as env on the function's render container. +const annKeyRuntimeDockerEnv = "render.crossplane.io/runtime-docker-env" + +// EnvInjectingFunctionProvider wraps another FunctionProvider and upserts a set +// of env pairs into each function's runtime-docker-env annotation before +// returning them. Used to raise the function gRPC max-recv-message-size so large +// XRs don't trip the function-sdk-go 4MB default under `crossplane render` +// (which, unlike the in-cluster runtime, does not apply DeploymentRuntimeConfig). +type EnvInjectingFunctionProvider struct { + inner FunctionProvider + envs map[string]string + logger logging.Logger +} + +// NewEnvInjectingFunctionProvider wraps inner, upserting the given env pairs +// into each returned function's runtime-docker-env annotation. +func NewEnvInjectingFunctionProvider(inner FunctionProvider, envs map[string]string, logger logging.Logger) FunctionProvider { + return &EnvInjectingFunctionProvider{ + inner: inner, + envs: envs, + logger: logger, + } +} + +// GetFunctionsForComposition delegates to the wrapped provider and upserts the +// configured env pairs into each function's runtime-docker-env annotation. The +// upsert is idempotent so repeated calls over a caching inner provider don't +// duplicate pairs. +func (p *EnvInjectingFunctionProvider) GetFunctionsForComposition(comp *apiextensionsv1.Composition) ([]pkgv1.Function, error) { + fns, err := p.inner.GetFunctionsForComposition(comp) + if err != nil { + return nil, err + } + + for i := range fns { + if fns[i].Annotations == nil { + fns[i].Annotations = make(map[string]string) + } + + cur := fns[i].Annotations[annKeyRuntimeDockerEnv] + for k, v := range p.envs { + cur = upsertEnvPair(cur, k, v) + } + + fns[i].Annotations[annKeyRuntimeDockerEnv] = cur + + p.logger.Debug("Injected function runtime env", + "function", fns[i].GetName(), + annKeyRuntimeDockerEnv, cur) + } + + return fns, nil +} + +// Cleanup delegates to the wrapped provider. +func (p *EnvInjectingFunctionProvider) Cleanup(ctx context.Context) error { + return p.inner.Cleanup(ctx) +} + +// upsertEnvPair sets key=val in a comma-separated "k=v,k=v" list, replacing an +// existing entry for key (idempotent) or appending a new one. Blank segments +// are dropped. +func upsertEnvPair(cur, key, val string) string { + pairs := make([]string, 0, 4) + replaced := false + + for seg := range strings.SplitSeq(cur, ",") { + if seg == "" { + continue + } + + if k, _, ok := strings.Cut(seg, "="); ok && k == key { + pairs = append(pairs, key+"="+val) + replaced = true + + continue + } + + pairs = append(pairs, seg) + } + + if !replaced { + pairs = append(pairs, key+"="+val) + } + + return strings.Join(pairs, ",") +} + // 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. diff --git a/cmd/diff/diffprocessor/function_provider_test.go b/cmd/diff/diffprocessor/function_provider_test.go index bc14296..0c5f3f4 100644 --- a/cmd/diff/diffprocessor/function_provider_test.go +++ b/cmd/diff/diffprocessor/function_provider_test.go @@ -610,6 +610,95 @@ func TestRegistryOverrideFunctionProvider(t *testing.T) { } } +func TestUpsertEnvPair(t *testing.T) { + tests := []struct { + name string + cur string + key string + val string + want string + }{ + {name: "empty", cur: "", key: "A", val: "1", want: "A=1"}, + {name: "append to other key", cur: "FOO=bar", key: "A", val: "1", want: "FOO=bar,A=1"}, + {name: "replace same key", cur: "A=1", key: "A", val: "2", want: "A=2"}, + {name: "replace same key among others", cur: "FOO=bar,A=1,BAZ=qux", key: "A", val: "2", want: "FOO=bar,A=2,BAZ=qux"}, + {name: "ignores blank segments", cur: "FOO=bar,", key: "A", val: "1", want: "FOO=bar,A=1"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := upsertEnvPair(tt.cur, tt.key, tt.val); got != tt.want { + t.Errorf("upsertEnvPair(%q, %q, %q) = %q, want %q", tt.cur, tt.key, tt.val, got, tt.want) + } + }) + } +} + +// TestEnvInjectingFunctionProvider verifies the decorator injects the env pair +// into every function's runtime-docker-env annotation, appends to any existing +// value, and is idempotent across repeated calls (which matters when the inner +// provider caches and returns the same Function values). +func TestEnvInjectingFunctionProvider(t *testing.T) { + newInner := func(anns map[string]string) FunctionProvider { + fn := pkgv1.Function{ObjectMeta: metav1.ObjectMeta{Name: "function-go-templating"}} + if anns != nil { + fn.Annotations = anns + } + + fnClient := tu.NewMockFunctionClient(). + WithSuccessfulFunctionsFetch([]pkgv1.Function{fn}). + Build() + // CachedFunctionProvider caches and returns the same values on repeat + // calls, so it exercises idempotency of the decorator. + return NewCachedFunctionProvider(fnClient, tu.TestLogger(t, false)) + } + + comp := &apiextensionsv1.Composition{ObjectMeta: metav1.ObjectMeta{Name: "test-composition"}} + envs := map[string]string{EnvMaxRecvMessageSize: "16"} + + t.Run("injects when annotation absent", func(t *testing.T) { + p := NewEnvInjectingFunctionProvider(newInner(nil), envs, tu.TestLogger(t, false)) + + fns, err := p.GetFunctionsForComposition(comp) + if err != nil { + t.Fatalf("GetFunctionsForComposition() error = %v", err) + } + + if got := fns[0].Annotations[annKeyRuntimeDockerEnv]; got != EnvMaxRecvMessageSize+"=16" { + t.Errorf("annotation = %q, want %q", got, EnvMaxRecvMessageSize+"=16") + } + }) + + t.Run("appends to existing runtime-docker-env", func(t *testing.T) { + p := NewEnvInjectingFunctionProvider(newInner(map[string]string{annKeyRuntimeDockerEnv: "FOO=bar"}), envs, tu.TestLogger(t, false)) + + fns, err := p.GetFunctionsForComposition(comp) + if err != nil { + t.Fatalf("GetFunctionsForComposition() error = %v", err) + } + + if got := fns[0].Annotations[annKeyRuntimeDockerEnv]; got != "FOO=bar,"+EnvMaxRecvMessageSize+"=16" { + t.Errorf("annotation = %q, want %q", got, "FOO=bar,"+EnvMaxRecvMessageSize+"=16") + } + }) + + t.Run("idempotent across repeated calls", func(t *testing.T) { + p := NewEnvInjectingFunctionProvider(newInner(nil), envs, tu.TestLogger(t, false)) + if _, err := p.GetFunctionsForComposition(comp); err != nil { + t.Fatalf("first call error = %v", err) + } + + fns, err := p.GetFunctionsForComposition(comp) + if err != nil { + t.Fatalf("second call error = %v", err) + } + + if got := fns[0].Annotations[annKeyRuntimeDockerEnv]; got != EnvMaxRecvMessageSize+"=16" { + t.Errorf("annotation after repeat = %q, want single pair %q", got, EnvMaxRecvMessageSize+"=16") + } + }) +} + 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 86cf01d..9fb69e0 100644 --- a/cmd/diff/diffprocessor/processor_config.go +++ b/cmd/diff/diffprocessor/processor_config.go @@ -48,6 +48,12 @@ type ProcessorConfig struct { // FunctionRegistryOverride overrides the registry in all function package refs. FunctionRegistryOverride string + // MaxRecvMessageSize is the max gRPC message size (MB) for render function + // containers. Zero leaves the function's own default (function-sdk-go uses + // 4MB). When >0 it is injected as the appropriate container env var, + // so large XRs don't trip the default limit under render. + MaxRecvMessageSize int + // Stdout is the writer for diff output (defaults to os.Stdout) Stdout io.Writer @@ -175,6 +181,14 @@ func WithFunctionRegistryOverride(registry string) ProcessorOption { } } +// WithMaxRecvMessageSize sets the max gRPC message size (MB) injected into +// render function containers. +func WithMaxRecvMessageSize(mb int) ProcessorOption { + return func(config *ProcessorConfig) { + config.MaxRecvMessageSize = mb + } +} + // WithStdout sets the writer for diff output. func WithStdout(w io.Writer) ProcessorOption { return func(config *ProcessorConfig) { diff --git a/cmd/diff/diffprocessor/processor_config_test.go b/cmd/diff/diffprocessor/processor_config_test.go index cfc5b9c..6a79743 100644 --- a/cmd/diff/diffprocessor/processor_config_test.go +++ b/cmd/diff/diffprocessor/processor_config_test.go @@ -229,3 +229,19 @@ func TestWithStdoutStderr(t *testing.T) { t.Errorf("Expected config.Stderr to be the injected buffer, got: %v", config.Stderr) } } + +// TestWithMaxRecvMessageSize verifies the option sets the field and that the +// zero value is preserved when the option is not applied. +func TestWithMaxRecvMessageSize(t *testing.T) { + def := ProcessorConfig{} + if def.MaxRecvMessageSize != 0 { + t.Errorf("Expected default MaxRecvMessageSize to be 0, got: %d", def.MaxRecvMessageSize) + } + + config := ProcessorConfig{} + WithMaxRecvMessageSize(16)(&config) + + if config.MaxRecvMessageSize != 16 { + t.Errorf("Expected config.MaxRecvMessageSize to be 16, got: %d", config.MaxRecvMessageSize) + } +} diff --git a/cmd/diff/main.go b/cmd/diff/main.go index 14ca162..92beaf5 100644 --- a/cmd/diff/main.go +++ b/cmd/diff/main.go @@ -105,6 +105,7 @@ type CommonCmdFields struct { 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"` EventualState bool `default:"false" help:"Show eventual state after all reconciliation cycles complete (useful with function-sequencer)." name:"eventual-state"` + MaxRecvMessageSize int `env:"CROSSPLANE_DIFF_MAX_RECV_MESSAGE_SIZE" help:"Max gRPC message size (MB) for render function containers (4MB if undefined)." name:"max-recv-message-size"` // CrossplaneRenderBinary is a hidden test-only override that points the // render engine at a local `crossplane` binary. Production users leave