Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions cmd/diff/cmd_utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Expand Down
6 changes: 6 additions & 0 deletions cmd/diff/diffprocessor/diff_processor.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"os"
"slices"
"sort"
"strconv"
"strings"
"time"

Expand Down Expand Up @@ -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,
Expand Down
97 changes: 97 additions & 0 deletions cmd/diff/diffprocessor/function_provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
89 changes: 89 additions & 0 deletions cmd/diff/diffprocessor/function_provider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
14 changes: 14 additions & 0 deletions cmd/diff/diffprocessor/processor_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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) {
Expand Down
16 changes: 16 additions & 0 deletions cmd/diff/diffprocessor/processor_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
1 change: 1 addition & 0 deletions cmd/diff/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading