diff --git a/.requirements/20260511T183423Z_comp_eventual_state/REQUIREMENTS.md b/.requirements/20260511T183423Z_comp_eventual_state/REQUIREMENTS.md new file mode 100644 index 0000000..a85eb78 --- /dev/null +++ b/.requirements/20260511T183423Z_comp_eventual_state/REQUIREMENTS.md @@ -0,0 +1,140 @@ +# Composition Diff: Eventual State Resolution + +## As Is + +`crossplane-diff xr` supports `--eventual-state` (added in PR #276, refined in PR #281). When this flag is set, the XR diff processor runs `RenderToStableState` with `synthesizeReady=true`, iteratively rendering until no new composed resources appear (or `MaxRenderIterations` is hit). This is essential for compositions that use `function-sequencer`, which hides later-stage resources until earlier stages become Ready. + +`crossplane-diff comp` currently has **no** `--eventual-state` flag. When a composition uses `function-sequencer`, the impact analysis only shows the resources from the first stage, missing the true eventual impact. + +Key facts about the existing plumbing: + +- `DefaultCompDiffProcessor.collectXRDiffs` delegates per-XR rendering to `p.xrProc.DiffSingleResource` (where `xrProc` is the injected `DiffProcessor`). +- `DiffSingleResource` internally calls `RenderToStableState`, which honors `config.EventualState`. +- `ProcessorOption` → `WithEventualState(bool)` already exists. +- `makeDefaultCompProc` in `cmd/diff/comp.go` constructs the XR processor and the composition processor from a shared `opts` list — adding `WithEventualState` to that list propagates to both. +- `MaxRenderIterations` is already applied via `defaultProcessorOptions`, so no additional flag wiring is required. +- The XR integration test `EventualStateWithSequencer` in `cmd/diff/diff_integration_test.go` (line 1593) already proves the feature works end-to-end through the XR code path. The `eventualState bool` field on `IntegrationTestCase` (line 58) is currently gated by `testType == XRDiffTest` (line 231). + +## To Be + +`crossplane-diff comp` supports `--eventual-state` with semantics identical to the XR command: + +- When `--eventual-state` is set, every impacted XR (including nested XRs encountered during rendering) is rendered in eventual-state mode. +- When unset, behavior is unchanged from today — single render, earliest-stage view. +- Help text, README, and CLI reference all mention the new flag. +- An integration test proves the flag flows from `CompCmd` through to the eventual-state loop, producing all-stage resources for a composition that uses `function-sequencer`. + +## Requirements + +1. **R1. Flag definition.** `CompCmd` exposes a boolean Kong flag named `--eventual-state` with `default:"false"`, matching `XRCmd`'s field tag verbatim (same help text). +2. **R2. Flag propagation.** `makeDefaultCompProc` passes `dp.WithEventualState(c.EventualState)` into the shared `opts` slice so that both the XR peer processor and the composition processor are configured consistently. +3. **R3. Help output.** `CompCmd.Help()` includes an example line demonstrating `--eventual-state`, mirroring `XRCmd.Help()`. +4. **R4. Documentation.** The README's "Composition Diff" section gains an `--eventual-state` example and the `comp` flags reference block gains an `--eventual-state` entry matching the `xr` block. +5. **R5. Integration test.** `TestCompDiffIntegration` gains a case (`EventualStateWithSequencer`) that (a) sets up a sequencer-based composition, (b) creates an existing XR that uses it, (c) runs with `--eventual-state`, and (d) asserts via structured JSON that both sequencer stages appear in the diff. +6. **R6. Test harness.** The `eventualState` gate in `runIntegrationTest` is extended to accept `CompositionDiffTest` so the new test can set `eventualState: true` and see `--eventual-state` appended to `args`. + +### Acceptance Criteria + +- **AC1 (R1).** `crossplane-diff comp --help` lists `--eventual-state` with the same help text as `xr`. +- **AC2 (R2).** A unit/integration test demonstrates that setting `--eventual-state` on the CLI causes `RenderToStableState` to iterate more than once for a sequencer composition (observable: multiple stages appear in the diff). +- **AC3 (R3).** `crossplane-diff comp --help` prints the new example line. +- **AC4 (R4).** README changes show the flag alongside other `comp` flags in the reference block and in the usage section. +- **AC5 (R5).** The new integration test passes; without the R2 wiring it MUST fail (verifying the red step of TDD). +- **AC6 (R6).** Running the existing XR `EventualStateWithSequencer` test remains green (no regression from the gate change). + +## Testing Plan + +Following TDD — red tests first. + +### T1. Integration test — `EventualStateWithSequencer` for `comp` + +**Location:** `cmd/diff/diff_integration_test.go`, inside `TestCompDiffIntegration`. + +**Shape:** Mirror the XR test at line 1593 but adapted for `comp`: + +- `eventualState: true` +- `outputFormat: "json"` +- `setupFiles`: XRD + sequencer composition + sequencer composition-revision + functions + a pre-existing XR (so `FindCompositesUsingComposition` returns something to diff against). The existing sequencer fixtures under `testdata/diff/resources/` can be reused. +- `inputFiles`: the updated composition file (path to `sequencer-composition.yaml` or a variant to force a change). +- `expectedStructuredOutput`: `ExpectCompDiff()` asserting both stage0 and stage1 resources appear in the XR's downstream diff. + +**What it proves:** + +1. Flag is parsed into `CompCmd`. +2. Flag propagates to the XR processor used by `collectXRDiffs`. +3. `RenderToStableState` runs multiple iterations and yields both stages. + +**Red behavior:** Before implementing R1/R2/R6, the test MUST fail because either (a) Kong rejects the unknown flag, (b) the gate in `runIntegrationTest` ignores the flag for `CompositionDiffTest`, or (c) `RenderToStableState` runs single-iteration and stage1 is missing. + +### T2. Help text assertion (covered implicitly by R3 diff review + manual verification) + +Explicit unit test is overkill here; the help string is a static literal reviewed at edit time. + +### T3. Regression check + +Re-run `TestDiffIntegration/EventualStateWithSequencer` to ensure the gate change in R6 doesn't regress XR behavior. + +### T4. Full package tests + +`go test ./cmd/diff/...` after wiring is complete to catch any mocks or builder assumptions. + +## Implementation Plan + +Smallest possible steps, each paired with its verification. + +### Step 1: Add failing integration test (RED) + +**Change:** In `cmd/diff/diff_integration_test.go`: +1. Extend the gate at line 231 from `tt.eventualState && testType == XRDiffTest` to `tt.eventualState` (i.e., drop the XR-only restriction). Rationale: `--eventual-state` is now a valid comp flag too. +2. Add the new test case `EventualStateWithSequencer` inside `TestCompDiffIntegration`'s `tests` map. Follow the existing composition-diff test style (see line 2960-ish for shape), use `ExpectCompDiff()` helpers, and reuse `sequencer-composition.yaml` + sequencer fixtures. + +**Verify (expect FAIL):** `go test ./cmd/diff -run TestCompDiffIntegration/EventualStateWithSequencer -v` + +Expected failure mode: Kong parsing error (`unknown flag: --eventual-state`) because `CompCmd` doesn't declare it yet. + +### Step 2: Declare the flag on CompCmd (still RED or partial-green) + +**Change:** In `cmd/diff/comp.go`, add the `EventualState` field to `CompCmd`: + +```go +EventualState bool `default:"false" help:"Show eventual state after all reconciliation cycles complete (useful with function-sequencer)." name:"eventual-state"` +``` + +**Verify:** `go build ./cmd/diff/...` succeeds. Re-run the test from Step 1 — expect it to still FAIL, but now because the flag is accepted by Kong but ignored (stage1 resources still missing). + +### Step 3: Wire flag into processor options (GREEN) + +**Change:** In `makeDefaultCompProc`, add `dp.WithEventualState(c.EventualState)` to the shared `opts` slice (modeled on `makeDefaultXRProc`). + +**Verify:** Re-run the integration test — expect it to PASS. + +### Step 4: Help text + +**Change:** Add an example line to `CompCmd.Help()`: + +``` + # Show eventual state with function-sequencer (all stages, not just first) + crossplane-diff comp updated-composition.yaml --eventual-state +``` + +**Verify:** Visual inspection + `go test ./cmd/diff/...` to confirm no regressions. + +### Step 5: README + +**Change:** In `README.md`: +- Add an example under "Composition Diff" usage matching the XR example. +- Add the `--eventual-state` entry to the `comp` flags reference block, copying the text from the `xr` block for consistency. + +**Verify:** Visual review. + +### Step 6: Full test sweep + +**Change:** None. + +**Verify:** +- `go test ./cmd/diff/...` (includes both XR and comp integration tests). +- `earthly +go-test` if time permits for the full containerized run. + +### Step 7: Regression check on XR + +**Verify explicitly:** `go test ./cmd/diff -run TestDiffIntegration/EventualStateWithSequencer -v` remains green to confirm the Step 1 gate change didn't break XR. diff --git a/.serena/project.yml b/.serena/project.yml index 26faf35..aa2bdbc 100644 --- a/.serena/project.yml +++ b/.serena/project.yml @@ -3,24 +3,26 @@ project_name: "crossplane-diff" # list of languages for which language servers are started; choose from: -# 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 +# al angular ansible bash clojure +# cpp cpp_ccls crystal csharp csharp_omnisharp +# dart elixir elm erlang fortran +# fsharp go groovy haskell haxe +# hlsl html 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 scss 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.) # Note: # - For C, use cpp # - For JavaScript, use typescript +# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root) +# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three) # - For Free Pascal/Lazarus, use pascal # Special requirements: # Some languages require additional setup/installations. @@ -125,3 +127,14 @@ ignored_memory_patterns: [] # 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: + +# list of additional workspace folder paths for cross-package reference support (e.g. in monorepos). +# Paths can be absolute or relative to the project root. +# Each folder is registered as an LSP workspace folder, enabling language servers to discover +# symbols and references across package boundaries. +# Currently supported for: TypeScript. +# Example: +# additional_workspace_folders: +# - ../sibling-package +# - ../shared-lib +additional_workspace_folders: [] diff --git a/README.md b/README.md index f98d288..e7e4140 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,9 @@ crossplane-diff comp updated-composition.yaml --output json # Output in YAML format crossplane-diff comp updated-composition.yaml -o yaml + +# Show eventual state with function-sequencer (all stages, not just first) +crossplane-diff comp updated-composition.yaml --eventual-state ``` ### Command Options @@ -177,9 +180,9 @@ Flags: --no-color Disable colorized output. --compact Show compact diffs with minimal context. --max-nested-depth=10 Maximum depth for nested XR recursion. - --max-iterations=20 Maximum render iterations for requirements resolution. - Increase for complex pipelines that need more cycles - to converge. + --max-iterations=20 Maximum render iterations for requirements resolution + or eventual-state simulation. Increase for complex + pipelines that need more cycles to converge. --timeout=1m How long to run before timing out. -n, --namespace="" Namespace to find Composites (empty = all namespaces). --include-manual Include Composites with Manual update policy (default: @@ -196,6 +199,9 @@ Flags: (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. ``` **Note**: The `diff` subcommand is deprecated. Use `xr` instead. diff --git a/cmd/diff/cmd_utils.go b/cmd/diff/cmd_utils.go index 702c1cf..dcafaf1 100644 --- a/cmd/diff/cmd_utils.go +++ b/cmd/diff/cmd_utils.go @@ -67,6 +67,7 @@ func defaultProcessorOptions(fields CommonCmdFields, namespace string) []dp.Proc dp.WithCompact(fields.Compact), dp.WithMaxNestedDepth(fields.MaxNestedDepth), dp.WithMaxRenderIterations(fields.MaxIterations), + dp.WithEventualState(fields.EventualState), dp.WithIgnorePaths(allIgnorePaths), } diff --git a/cmd/diff/comp.go b/cmd/diff/comp.go index 0cacc7b..bf36bfb 100644 --- a/cmd/diff/comp.go +++ b/cmd/diff/comp.go @@ -66,6 +66,9 @@ Examples: # Include XRs with Manual update policy (pinned revisions) crossplane-diff comp updated-composition.yaml --include-manual + + # Show eventual state with function-sequencer (all stages, not just first). + crossplane-diff comp updated-composition.yaml --eventual-state ` } diff --git a/cmd/diff/diff_integration_test.go b/cmd/diff/diff_integration_test.go index dcaaa51..4527719 100644 --- a/cmd/diff/diff_integration_test.go +++ b/cmd/diff/diff_integration_test.go @@ -55,13 +55,16 @@ type IntegrationTestCase struct { xrdAPIVersion XrdAPIVersion // For XR tests (optional) ignorePaths []string // Paths to ignore in diffs functionCredentials string // Path to function credentials file (optional) - eventualState bool // For XR tests: enable eventual state simulation (optional) + eventualState bool // Enable eventual state simulation for XR or composition tests (optional) timeout time.Duration // Custom timeout for this test (0 = use default) skip bool skipReason string - // JSON output support: set outputFormat to "json" to use structured assertions - outputFormat string // "json" or "" (default=visual diff) - expectedStructuredOutput *tu.ExpectedDiff // for JSON output assertions + // JSON output support: set outputFormat to "json" to use structured assertions. + // For XR tests, populate expectedStructuredOutput. For CompositionDiffTest tests, + // populate expectedStructuredCompOutput. Only one should be set per test case. + outputFormat string // "json" or "" (default=visual diff) + expectedStructuredOutput *tu.ExpectedDiff // for XR JSON output assertions + expectedStructuredCompOutput *tu.ExpectedCompDiff // for comp JSON output assertions } type XrdAPIVersion int @@ -103,6 +106,18 @@ func runIntegrationTest(t *testing.T, testType DiffTestType, tt IntegrationTestC t.Parallel() // Enable parallel test execution + // Validate structured-output config up front so misconfiguration fails loudly + // instead of silently routing assertions to the wrong path. + if tt.expectedStructuredOutput != nil && tt.expectedStructuredCompOutput != nil { + t.Fatalf("test case sets both expectedStructuredOutput and expectedStructuredCompOutput; set only one") + } + if tt.expectedStructuredOutput != nil && testType != XRDiffTest { + t.Fatalf("expectedStructuredOutput is only valid for XRDiffTest (got %q)", testType) + } + if tt.expectedStructuredCompOutput != nil && testType != CompositionDiffTest { + t.Fatalf("expectedStructuredCompOutput is only valid for CompositionDiffTest (got %q)", testType) + } + // Create a fresh scheme for each test to avoid concurrent map access. // Each parallel test needs its own scheme because envtest modifies it during CRD installation. scheme := createTestScheme() @@ -227,8 +242,8 @@ func runIntegrationTest(t *testing.T, testType DiffTestType, tt IntegrationTestC args = append(args, fmt.Sprintf("--function-credentials=%s", tt.functionCredentials)) } - // Add eventual-state flag if specified (XR tests only) - if tt.eventualState && testType == XRDiffTest { + // Add eventual-state flag if specified (supported by both `xr` and `comp`). + if tt.eventualState { args = append(args, "--eventual-state") } @@ -308,6 +323,11 @@ func runIntegrationTest(t *testing.T, testType DiffTestType, tt IntegrationTestC return } + if tt.outputFormat == "json" && tt.expectedStructuredCompOutput != nil { + tu.AssertStructuredCompDiff(t, outputStr, tt.expectedStructuredCompOutput) + return + } + // Using TrimSpace because the output might have trailing newlines if !strings.Contains(strings.TrimSpace(outputStr), strings.TrimSpace(tt.expectedOutput)) { // Strings aren't equal, *including* ansi. but we can compare ignoring ansi to determine what output to @@ -1590,32 +1610,114 @@ Summary: 2 modified, 2 removed`, And(), expectedError: false, }, - "EventualStateWithSequencer": { - reason: "Shows eventual state after all stages complete when using function-sequencer with --eventual-state", - timeout: longTimeout, // Multiple simulation iterations need more time - // The sequencer composition has 2 stages: stage0-resource (shown immediately) and stage1-resource (hidden until stage0 is Ready) - // Without --eventual-state, only stage0-resource would appear - // With --eventual-state, both stage0-resource and stage1-resource should appear + // Paired sequencer gating tests — the fixture sequencer-gating-composition.yaml + // is deliberately correct (rules match composition-resource-names stage0-/stage1- + // AND auto-ready runs BEFORE sequencer so DesiredComposed.Ready is populated + // before sequencer's gating check). Together these two cases discriminate + // --eventual-state from default behavior: only eventual-state surfaces stage1. + "SequencerGatesLaterStageWithoutEventualState": { + reason: "Without --eventual-state, function-sequencer hides stage1 until stage0 is ready", + timeout: longTimeout, + outputFormat: "json", + // eventualState deliberately false + inputFiles: []string{"testdata/diff/resources/sequencer-xr.yaml"}, + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/sequencer-gating-composition.yaml", + "testdata/diff/resources/sequencer-gating-composition-revision.yaml", + "testdata/diff/resources/functions.yaml", + }, + expectedExitCode: dp.ExitCodeDiffDetected, + // Expect: XR + stage0 added; stage1 NOT present (gated). + expectedStructuredOutput: tu.ExpectDiff(). + WithSummary(2, 0, 0). + WithAddedResource("XNopResource", "sequencer-test", "default"). + WithField("spec.coolField", "test-value"). + And(). + WithAddedResource("XDownstreamResource", "0-stage0-resource", "default"). + WithField("spec.forProvider.configData", "test-value"). + And(), + expectedError: false, + }, + "SequencerGatedStageAppearsWithEventualState": { + reason: "With --eventual-state, sequencer-gated stage1 surfaces via synthesize-Ready iteration", + timeout: longTimeout, // Multiple simulation iterations need more time outputFormat: "json", eventualState: true, inputFiles: []string{"testdata/diff/resources/sequencer-xr.yaml"}, setupFiles: []string{ "testdata/diff/resources/xrd.yaml", - "testdata/diff/resources/sequencer-composition.yaml", - "testdata/diff/resources/sequencer-composition-revision.yaml", + "testdata/diff/resources/sequencer-gating-composition.yaml", + "testdata/diff/resources/sequencer-gating-composition-revision.yaml", "testdata/diff/resources/functions.yaml", }, expectedExitCode: dp.ExitCodeDiffDetected, + // Expect: XR + stage0 + stage1 all added (stage1 unlocked via eventual-state). expectedStructuredOutput: tu.ExpectDiff(). - WithSummary(3, 0, 0). // 2 downstream resources + 1 XR + WithSummary(3, 0, 0). + WithAddedResource("XNopResource", "sequencer-test", "default"). + WithField("spec.coolField", "test-value"). + And(). WithAddedResource("XDownstreamResource", "0-stage0-resource", "default"). WithField("spec.forProvider.configData", "test-value"). And(). WithAddedResource("XDownstreamResource", "1-stage1-resource", "default"). WithField("spec.forProvider.configData", "test-value"). + And(), + expectedError: false, + }, + // Paired composition-driven gating tests — the fixture conditional-gating-composition.yaml + // encodes gating in go-templating itself (no function-sequencer): stage1 is only rendered + // when observed stage0 has Ready=True. This isolates eventual-state simulation from + // function-sequencer and exercises the synthesize-Ready path directly. + "ConditionalGatingHidesLaterStageWithoutEventualState": { + reason: "Without --eventual-state, template conditionally gates stage1 because observed stage0 is not Ready", + timeout: longTimeout, + outputFormat: "json", + // eventualState deliberately false + inputFiles: []string{"testdata/diff/resources/conditional-gating-xr.yaml"}, + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/conditional-gating-composition.yaml", + "testdata/diff/resources/conditional-gating-composition-revision.yaml", + "testdata/diff/resources/functions.yaml", + }, + expectedExitCode: dp.ExitCodeDiffDetected, + // Expect: XR + stage0 added; stage1 NOT present (gated by the template's Ready check). + expectedStructuredOutput: tu.ExpectDiff(). + WithSummary(2, 0, 0). + WithAddedResource("XNopResource", "conditional-gating-test", "default"). + WithField("spec.coolField", "test-value"). And(). - WithAddedResource("XNopResource", "sequencer-test", "default"). + WithAddedResource("XDownstreamResource", "0-stage0-resource", "default"). + WithField("spec.forProvider.configData", "test-value"). + And(), + expectedError: false, + }, + "ConditionalGatedStageAppearsWithEventualState": { + reason: "With --eventual-state, synthesize-Ready flips stage0 Ready=True in observed so the template renders stage1", + timeout: longTimeout, // Multiple simulation iterations need more time + outputFormat: "json", + eventualState: true, + inputFiles: []string{"testdata/diff/resources/conditional-gating-xr.yaml"}, + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/conditional-gating-composition.yaml", + "testdata/diff/resources/conditional-gating-composition-revision.yaml", + "testdata/diff/resources/functions.yaml", + }, + expectedExitCode: dp.ExitCodeDiffDetected, + // Expect: XR + stage0 + stage1 all added. + expectedStructuredOutput: tu.ExpectDiff(). + WithSummary(3, 0, 0). + WithAddedResource("XNopResource", "conditional-gating-test", "default"). WithField("spec.coolField", "test-value"). + And(). + WithAddedResource("XDownstreamResource", "0-stage0-resource", "default"). + WithField("spec.forProvider.configData", "test-value"). + And(). + WithAddedResource("XDownstreamResource", "1-stage1-resource", "default"). + WithField("spec.forProvider.configData", "test-value"). And(), expectedError: false, }, @@ -2974,6 +3076,122 @@ Summary: 2 modified`, expectedExitCode: dp.ExitCodeDiffDetected, noColor: true, }, + // Paired comp sequencer-gating tests — mirror of the XR pair. Verifies that + // --eventual-state on `comp` surfaces downstream resources hidden by + // function-sequencer in the impact analysis. + "CompSequencerGatesLaterStageWithoutEventualState": { + reason: "`comp` without --eventual-state: sequencer-gated stage1 is absent from the impact analysis", + timeout: longTimeout, + outputFormat: "json", + // eventualState deliberately false + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/sequencer-gating-composition.yaml", + "testdata/diff/resources/sequencer-gating-composition-revision.yaml", + "testdata/diff/resources/functions.yaml", + // Pre-existing XR that references sequencer-gating-composition by name so + // FindCompositesUsingComposition discovers it. + "testdata/comp/resources/existing-sequencer-gating-xr.yaml", + }, + inputFiles: []string{"testdata/comp/updated-sequencer-gating-composition.yaml"}, + namespace: "default", + expectedExitCode: dp.ExitCodeDiffDetected, + // Expect: impact shows stage0 added (its configData changed and it doesn't exist yet); + // stage1 is absent from downstream impact because sequencer gates it. + expectedStructuredCompOutput: tu.ExpectCompDiff(). + WithComposition("sequencer-gating-composition"). + WithCompositionModified(). + WithXRImpact("XNopResource", "sequencer-gating-test", "default", "changed"). + WithDownstreamSummary(1, 0, 0). + WithDownstreamResource("added", "XDownstreamResource", "0-stage0-resource", "default"). + WithField("spec.forProvider.configData", "updated-existing-value"). + AndXR().AndComp().And(), + expectedError: false, + }, + "CompSequencerGatedStageAppearsWithEventualState": { + reason: "`comp` with --eventual-state: sequencer-gated stage1 surfaces in the impact analysis", + timeout: longTimeout, // Multiple simulation iterations need more time + outputFormat: "json", + eventualState: true, + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/sequencer-gating-composition.yaml", + "testdata/diff/resources/sequencer-gating-composition-revision.yaml", + "testdata/diff/resources/functions.yaml", + "testdata/comp/resources/existing-sequencer-gating-xr.yaml", + }, + inputFiles: []string{"testdata/comp/updated-sequencer-gating-composition.yaml"}, + namespace: "default", + expectedExitCode: dp.ExitCodeDiffDetected, + // Expect: impact shows BOTH stage0 and stage1 added (eventual-state unlocks stage1). + expectedStructuredCompOutput: tu.ExpectCompDiff(). + WithComposition("sequencer-gating-composition"). + WithCompositionModified(). + WithXRImpact("XNopResource", "sequencer-gating-test", "default", "changed"). + WithDownstreamSummary(2, 0, 0). + WithDownstreamResource("added", "XDownstreamResource", "0-stage0-resource", "default"). + WithField("spec.forProvider.configData", "updated-existing-value"). + AndXR(). + WithDownstreamResource("added", "XDownstreamResource", "1-stage1-resource", "default"). + WithField("spec.forProvider.configData", "updated-existing-value"). + AndXR().AndComp().And(), + expectedError: false, + }, + // Paired comp composition-driven gating tests — gating encoded in go-templating, + // no function-sequencer. Mirrors the XR composition-driven pair. + "CompConditionalGatingHidesLaterStageWithoutEventualState": { + reason: "`comp` without --eventual-state: template-gated stage1 is absent from the impact analysis", + timeout: longTimeout, + outputFormat: "json", + // eventualState deliberately false + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/conditional-gating-composition.yaml", + "testdata/diff/resources/conditional-gating-composition-revision.yaml", + "testdata/diff/resources/functions.yaml", + "testdata/comp/resources/existing-conditional-gating-xr.yaml", + }, + inputFiles: []string{"testdata/comp/updated-conditional-gating-composition.yaml"}, + namespace: "default", + expectedExitCode: dp.ExitCodeDiffDetected, + expectedStructuredCompOutput: tu.ExpectCompDiff(). + WithComposition("conditional-gating-composition"). + WithCompositionModified(). + WithXRImpact("XNopResource", "conditional-gating-test", "default", "changed"). + WithDownstreamSummary(1, 0, 0). + WithDownstreamResource("added", "XDownstreamResource", "0-stage0-resource", "default"). + WithField("spec.forProvider.configData", "updated-existing-value"). + AndXR().AndComp().And(), + expectedError: false, + }, + "CompConditionalGatedStageAppearsWithEventualState": { + reason: "`comp` with --eventual-state: template-gated stage1 surfaces in the impact analysis", + timeout: longTimeout, // Multiple simulation iterations need more time + outputFormat: "json", + eventualState: true, + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/conditional-gating-composition.yaml", + "testdata/diff/resources/conditional-gating-composition-revision.yaml", + "testdata/diff/resources/functions.yaml", + "testdata/comp/resources/existing-conditional-gating-xr.yaml", + }, + inputFiles: []string{"testdata/comp/updated-conditional-gating-composition.yaml"}, + namespace: "default", + expectedExitCode: dp.ExitCodeDiffDetected, + expectedStructuredCompOutput: tu.ExpectCompDiff(). + WithComposition("conditional-gating-composition"). + WithCompositionModified(). + WithXRImpact("XNopResource", "conditional-gating-test", "default", "changed"). + WithDownstreamSummary(2, 0, 0). + WithDownstreamResource("added", "XDownstreamResource", "0-stage0-resource", "default"). + WithField("spec.forProvider.configData", "updated-existing-value"). + AndXR(). + WithDownstreamResource("added", "XDownstreamResource", "1-stage1-resource", "default"). + WithField("spec.forProvider.configData", "updated-existing-value"). + AndXR().AndComp().And(), + expectedError: false, + }, } for name, tt := range tests { diff --git a/cmd/diff/main.go b/cmd/diff/main.go index 6a95670..136bd0d 100644 --- a/cmd/diff/main.go +++ b/cmd/diff/main.go @@ -99,11 +99,12 @@ type CommonCmdFields struct { 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"` + MaxIterations int `default:"20" help:"Maximum render iterations for requirements resolution or eventual-state simulation. 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"` + EventualState bool `default:"false" help:"Show eventual state after all reconciliation cycles complete (useful with function-sequencer)." name:"eventual-state"` } // GetKubeContext implements ContextProvider. diff --git a/cmd/diff/testdata/comp/resources/existing-conditional-gating-xr.yaml b/cmd/diff/testdata/comp/resources/existing-conditional-gating-xr.yaml new file mode 100644 index 0000000..0a9b016 --- /dev/null +++ b/cmd/diff/testdata/comp/resources/existing-conditional-gating-xr.yaml @@ -0,0 +1,10 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: conditional-gating-test + namespace: default +spec: + coolField: existing-value + crossplane: + compositionRef: + name: conditional-gating-composition diff --git a/cmd/diff/testdata/comp/resources/existing-sequencer-gating-xr.yaml b/cmd/diff/testdata/comp/resources/existing-sequencer-gating-xr.yaml new file mode 100644 index 0000000..bf00981 --- /dev/null +++ b/cmd/diff/testdata/comp/resources/existing-sequencer-gating-xr.yaml @@ -0,0 +1,10 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: sequencer-gating-test + namespace: default +spec: + coolField: existing-value + crossplane: + compositionRef: + name: sequencer-gating-composition diff --git a/cmd/diff/testdata/comp/updated-conditional-gating-composition.yaml b/cmd/diff/testdata/comp/updated-conditional-gating-composition.yaml new file mode 100644 index 0000000..6d5a3ed --- /dev/null +++ b/cmd/diff/testdata/comp/updated-conditional-gating-composition.yaml @@ -0,0 +1,57 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: conditional-gating-composition +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: 0-stage0-resource + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: stage0-resource + spec: + forProvider: + configData: updated-{{ .observed.composite.resource.spec.coolField }} + {{- $ready := false -}} + {{- with .observed.resources -}} + {{- with (index . "stage0-resource") -}} + {{- with .resource.status.conditions -}} + {{- range . -}} + {{- if and (eq .type "Ready") (eq .status "True") -}} + {{- $ready = true -}} + {{- end -}} + {{- end -}} + {{- end -}} + {{- end -}} + {{- end -}} + {{- if $ready }} + --- + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: 1-stage1-resource + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: stage1-resource + spec: + forProvider: + configData: updated-{{ .observed.composite.resource.spec.coolField }} + {{- end }} + - step: mark-ready-from-observed + functionRef: + name: function-auto-ready diff --git a/cmd/diff/testdata/comp/updated-sequencer-gating-composition.yaml b/cmd/diff/testdata/comp/updated-sequencer-gating-composition.yaml new file mode 100644 index 0000000..603c3ec --- /dev/null +++ b/cmd/diff/testdata/comp/updated-sequencer-gating-composition.yaml @@ -0,0 +1,53 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: sequencer-gating-composition +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-all-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: 0-stage0-resource + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: stage0-resource + spec: + forProvider: + configData: updated-{{ .observed.composite.resource.spec.coolField }} + --- + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: 1-stage1-resource + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: stage1-resource + spec: + forProvider: + configData: updated-{{ .observed.composite.resource.spec.coolField }} + - step: mark-ready-from-observed + functionRef: + name: function-auto-ready + - step: sequence-resources + functionRef: + name: function-sequencer + input: + apiVersion: sequencer.fn.crossplane.io/v1beta1 + kind: Input + rules: + - sequence: + - ^stage0- + - ^stage1- diff --git a/cmd/diff/testdata/diff/resources/conditional-gating-composition-revision.yaml b/cmd/diff/testdata/diff/resources/conditional-gating-composition-revision.yaml new file mode 100644 index 0000000..93d903c --- /dev/null +++ b/cmd/diff/testdata/diff/resources/conditional-gating-composition-revision.yaml @@ -0,0 +1,60 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: CompositionRevision +metadata: + name: conditional-gating-composition-rev-1 + labels: + crossplane.io/composition-name: conditional-gating-composition +spec: + revision: 1 + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: 0-stage0-resource + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: stage0-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + {{- $ready := false -}} + {{- with .observed.resources -}} + {{- with (index . "stage0-resource") -}} + {{- with .resource.status.conditions -}} + {{- range . -}} + {{- if and (eq .type "Ready") (eq .status "True") -}} + {{- $ready = true -}} + {{- end -}} + {{- end -}} + {{- end -}} + {{- end -}} + {{- end -}} + {{- if $ready }} + --- + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: 1-stage1-resource + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: stage1-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + {{- end }} + - step: mark-ready-from-observed + functionRef: + name: function-auto-ready diff --git a/cmd/diff/testdata/diff/resources/conditional-gating-composition.yaml b/cmd/diff/testdata/diff/resources/conditional-gating-composition.yaml new file mode 100644 index 0000000..3c4a1b4 --- /dev/null +++ b/cmd/diff/testdata/diff/resources/conditional-gating-composition.yaml @@ -0,0 +1,78 @@ +# Composition-driven gating (no function-sequencer). stage1 is only rendered by +# go-templating when observed stage0 has Ready=True. This isolates eventual-state +# simulation from sequencer behavior: the gating is encoded in the template itself. +# +# Template data model: observed composed resources are at .observed.resources +# (map keyed by composition-resource-name), matching the RunFunctionRequest proto +# field State.resources. Each entry has .resource (the composed resource object). +# +# Flow under eventual-state: +# iter 1: observed.resources is empty -> stage1 guard skipped -> renders stage0 only +# synthesizeReady flips rendered stage0 Ready=True and merges into observed +# iter 2: observed.resources[stage0-resource].resource.status.conditions has +# Ready=True -> renders stage0+stage1 +# iter 3: renders stage0+stage1 again; newComposed=[] -> stable +# +# Flow without eventual-state: iter 1 only. Renders stage0. Stable immediately. +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: conditional-gating-composition +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: 0-stage0-resource + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: stage0-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + {{- /* Render stage1 iff observed stage0 has a Ready=True condition. + Access pattern: .observed.resources is the protojson of State.resources + (map keyed by composition-resource-name). + Compute $ready once, then emit the manifest once to avoid + duplicates if multiple Ready=True conditions ever appear. */ -}} + {{- $ready := false -}} + {{- with .observed.resources -}} + {{- with (index . "stage0-resource") -}} + {{- with .resource.status.conditions -}} + {{- range . -}} + {{- if and (eq .type "Ready") (eq .status "True") -}} + {{- $ready = true -}} + {{- end -}} + {{- end -}} + {{- end -}} + {{- end -}} + {{- end -}} + {{- if $ready }} + --- + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: 1-stage1-resource + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: stage1-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + {{- end }} + - step: mark-ready-from-observed + functionRef: + name: function-auto-ready diff --git a/cmd/diff/testdata/diff/resources/conditional-gating-xr.yaml b/cmd/diff/testdata/diff/resources/conditional-gating-xr.yaml new file mode 100644 index 0000000..b871f6b --- /dev/null +++ b/cmd/diff/testdata/diff/resources/conditional-gating-xr.yaml @@ -0,0 +1,7 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: conditional-gating-test + namespace: default +spec: + coolField: "test-value" diff --git a/cmd/diff/testdata/diff/resources/sequencer-gating-composition-revision.yaml b/cmd/diff/testdata/diff/resources/sequencer-gating-composition-revision.yaml new file mode 100644 index 0000000..54e7bd6 --- /dev/null +++ b/cmd/diff/testdata/diff/resources/sequencer-gating-composition-revision.yaml @@ -0,0 +1,56 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: CompositionRevision +metadata: + name: sequencer-gating-composition-rev-1 + labels: + crossplane.io/composition-name: sequencer-gating-composition +spec: + revision: 1 + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-all-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: 0-stage0-resource + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: stage0-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + --- + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: 1-stage1-resource + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: stage1-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + - step: mark-ready-from-observed + functionRef: + name: function-auto-ready + - step: sequence-resources + functionRef: + name: function-sequencer + input: + apiVersion: sequencer.fn.crossplane.io/v1beta1 + kind: Input + rules: + - sequence: + - ^stage0- + - ^stage1- diff --git a/cmd/diff/testdata/diff/resources/sequencer-gating-composition.yaml b/cmd/diff/testdata/diff/resources/sequencer-gating-composition.yaml new file mode 100644 index 0000000..2c91269 --- /dev/null +++ b/cmd/diff/testdata/diff/resources/sequencer-gating-composition.yaml @@ -0,0 +1,68 @@ +# Composition used by tests that need function-sequencer to actually gate later stages. +# +# IMPORTANT notes: +# +# 1. function-sequencer matches on the resource's composition-resource-name +# (the SDK's resource.Name), NOT on metadata.name. The rules use regexes that +# match the composition-resource-names emitted by go-templating +# (stage0-resource / stage1-resource). +# +# 2. function-sequencer's gating checks DesiredComposed.Ready == True for earlier +# stages. DesiredComposed.Ready is set by function-auto-ready based on the +# observed state. Therefore auto-ready MUST run BEFORE sequencer for eventual- +# state to propagate (synthesize observed Ready -> auto-ready sets desired +# Ready -> sequencer unlocks the next stage). +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: sequencer-gating-composition +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-all-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: 0-stage0-resource + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: stage0-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + --- + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: 1-stage1-resource + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: stage1-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + # auto-ready FIRST so sequencer sees desired.stage0.Ready reflecting observed. + - step: mark-ready-from-observed + functionRef: + name: function-auto-ready + - step: sequence-resources + functionRef: + name: function-sequencer + input: + apiVersion: sequencer.fn.crossplane.io/v1beta1 + kind: Input + rules: + - sequence: + - ^stage0- # Stage 0 (composition-resource-name starts with stage0-) + - ^stage1- # Stage 1 (gated until stage0- desired.Ready==True) diff --git a/cmd/diff/testdata/diff/resources/sequencer-stage0-notready.yaml b/cmd/diff/testdata/diff/resources/sequencer-stage0-notready.yaml new file mode 100644 index 0000000..3bf2b19 --- /dev/null +++ b/cmd/diff/testdata/diff/resources/sequencer-stage0-notready.yaml @@ -0,0 +1,18 @@ +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + annotations: + crossplane.io/composition-resource-name: stage0-resource + labels: + crossplane.io/composite: sequencer-test + name: 0-stage0-resource + namespace: default +spec: + forProvider: + configData: test-value +status: + conditions: + - type: Ready + status: "False" + reason: Creating + lastTransitionTime: "2026-01-01T00:00:00Z" diff --git a/cmd/diff/xr.go b/cmd/diff/xr.go index 528404a..8ed11e5 100644 --- a/cmd/diff/xr.go +++ b/cmd/diff/xr.go @@ -34,10 +34,6 @@ type XRCmd struct { // Embed common fields CommonCmdFields - // EventualState enables iterative simulation to show eventual state after all reconciliation - // cycles complete. Useful with function-sequencer which hides later stage resources. - EventualState bool `default:"false" help:"Show eventual state after all reconciliation cycles complete (useful with function-sequencer)." name:"eventual-state"` - Files []string `arg:"" help:"YAML files containing Crossplane resources to diff." optional:""` } @@ -95,7 +91,6 @@ func makeDefaultXRProc(c *XRCmd, kongCtx *kong.Context, appCtx *AppContext, log opts = append(opts, dp.WithLogger(log), dp.WithRenderMutex(&globalRenderMutex), - dp.WithEventualState(c.EventualState), dp.WithStdout(kongCtx.Stdout), dp.WithStderr(kongCtx.Stderr), )