Skip to content

Commit c8d0531

Browse files
committed
fix: nested XRs should not show add/remove under claims
Signed-off-by: Jonathan Ogilvie <[email protected]>
1 parent 2515afc commit c8d0531

18 files changed

+7219
-7
lines changed

cmd/diff/diffprocessor/diff_processor.go

Lines changed: 68 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -303,7 +303,7 @@ func (p *DefaultDiffProcessor) DiffSingleResource(ctx context.Context, res *un.U
303303
// Check for nested XRs in the composed resources and process them recursively
304304
p.config.Logger.Debug("Checking for nested XRs", "resource", resourceID, "composedCount", len(desired.ComposedResources))
305305

306-
nestedDiffs, err := p.ProcessNestedXRs(ctx, desired.ComposedResources, compositionProvider, resourceID, 1)
306+
nestedDiffs, err := p.ProcessNestedXRs(ctx, desired.ComposedResources, compositionProvider, resourceID, xr, 1)
307307
if err != nil {
308308
p.config.Logger.Debug("Error processing nested XRs", "resource", resourceID, "error", err)
309309
return nil, errors.Wrap(err, "cannot process nested XRs")
@@ -329,6 +329,7 @@ func (p *DefaultDiffProcessor) ProcessNestedXRs(
329329
composedResources []cpd.Unstructured,
330330
compositionProvider types.CompositionProvider,
331331
parentResourceID string,
332+
parentXR *cmp.Unstructured,
332333
depth int,
333334
) (map[string]*dt.ResourceDiff, error) {
334335
if depth > p.config.MaxNestedDepth {
@@ -345,27 +346,88 @@ func (p *DefaultDiffProcessor) ProcessNestedXRs(
345346
"composedResourceCount", len(composedResources),
346347
"depth", depth)
347348

349+
// Fetch observed resources from parent XR to find existing nested XRs
350+
// This allows us to preserve the identity of nested XRs that already exist in the cluster
351+
var observedResources []cpd.Unstructured
352+
if parentXR != nil {
353+
obs, err := p.diffCalculator.FetchObservedResources(ctx, parentXR)
354+
if err != nil {
355+
// Log but continue - nested XRs without existing cluster state will show as new (with "(generated)")
356+
p.config.Logger.Debug("Could not fetch observed resources for parent XR (continuing)",
357+
"parentResource", parentResourceID,
358+
"error", err)
359+
} else {
360+
observedResources = obs
361+
}
362+
}
363+
348364
allDiffs := make(map[string]*dt.ResourceDiff)
349365

350366
for _, composed := range composedResources {
351-
un := &un.Unstructured{Object: composed.UnstructuredContent()}
367+
nestedXR := &un.Unstructured{Object: composed.UnstructuredContent()}
352368

353369
// Check if this composed resource is itself an XR
354-
isXR, _ := p.getCompositeResourceXRD(ctx, un)
370+
isXR, _ := p.getCompositeResourceXRD(ctx, nestedXR)
355371

356372
if !isXR {
357373
// Skip non-XR resources
358374
continue
359375
}
360376

361-
nestedResourceID := fmt.Sprintf("%s/%s (nested depth %d)", un.GetKind(), un.GetName(), depth)
377+
nestedResourceID := fmt.Sprintf("%s/%s (nested depth %d)", nestedXR.GetKind(), nestedXR.GetName(), depth)
362378
p.config.Logger.Debug("Found nested XR, processing recursively",
363379
"nestedXR", nestedResourceID,
364380
"parentXR", parentResourceID,
365381
"depth", depth)
366382

383+
// Find the matching existing nested XR in observed resources (if it exists)
384+
// Match by composition-resource-name annotation to find the correct existing resource
385+
var existingNestedXR *un.Unstructured
386+
compositionResourceName := nestedXR.GetAnnotations()["crossplane.io/composition-resource-name"]
387+
if compositionResourceName != "" {
388+
for _, obs := range observedResources {
389+
obsUnstructured := &un.Unstructured{Object: obs.UnstructuredContent()}
390+
obsCompResName := obsUnstructured.GetAnnotations()["crossplane.io/composition-resource-name"]
391+
392+
// Match by composition-resource-name annotation and kind
393+
if obsCompResName == compositionResourceName && obsUnstructured.GetKind() == nestedXR.GetKind() {
394+
existingNestedXR = obsUnstructured
395+
p.config.Logger.Debug("Found existing nested XR in cluster",
396+
"nestedXR", nestedResourceID,
397+
"existingName", existingNestedXR.GetName(),
398+
"compositionResourceName", compositionResourceName)
399+
break
400+
}
401+
}
402+
}
403+
404+
// If we found an existing nested XR, preserve its identity (name, composite label)
405+
// This ensures its managed resources can be matched correctly
406+
if existingNestedXR != nil {
407+
// Preserve the actual cluster name
408+
nestedXR.SetName(existingNestedXR.GetName())
409+
nestedXR.SetGenerateName(existingNestedXR.GetGenerateName())
410+
411+
// Preserve the composite label so child resources get matched correctly
412+
if labels := existingNestedXR.GetLabels(); labels != nil {
413+
if compositeLabel, exists := labels["crossplane.io/composite"]; exists {
414+
nestedXRLabels := nestedXR.GetLabels()
415+
if nestedXRLabels == nil {
416+
nestedXRLabels = make(map[string]string)
417+
}
418+
nestedXRLabels["crossplane.io/composite"] = compositeLabel
419+
nestedXR.SetLabels(nestedXRLabels)
420+
421+
p.config.Logger.Debug("Preserved nested XR identity",
422+
"nestedXR", nestedResourceID,
423+
"preservedName", nestedXR.GetName(),
424+
"preservedCompositeLabel", compositeLabel)
425+
}
426+
}
427+
}
428+
367429
// Recursively process this nested XR
368-
nestedDiffs, err := p.DiffSingleResource(ctx, un, compositionProvider)
430+
nestedDiffs, err := p.DiffSingleResource(ctx, nestedXR, compositionProvider)
369431
if err != nil {
370432
// Check if the error is due to missing composition
371433
// Note: It's valid to have an XRD in Crossplane without a composition attached to it.
@@ -376,7 +438,7 @@ func (p *DefaultDiffProcessor) ProcessNestedXRs(
376438
p.config.Logger.Info("Skipping nested XR processing due to missing composition",
377439
"nestedXR", nestedResourceID,
378440
"parentXR", parentResourceID,
379-
"gvk", un.GroupVersionKind().String())
441+
"gvk", nestedXR.GroupVersionKind().String())
380442
// Continue processing other nested XRs
381443
continue
382444
}

cmd/diff/diffprocessor/diff_processor_test.go

Lines changed: 113 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1685,6 +1685,115 @@ func TestDefaultDiffProcessor_ProcessNestedXRs(t *testing.T) {
16851685
wantDiffCount: 1, // Only the child XR should be processed
16861686
wantErr: false,
16871687
},
1688+
"NestedXRWithExistingResourcesPreservesIdentity": {
1689+
setupMocks: func() (xp.Clients, k8.Clients) {
1690+
// This test reproduces the bug where existing nested XR identity is not preserved
1691+
// resulting in all managed resources showing as removed/added instead of modified
1692+
1693+
// Create an EXISTING nested XR with actual cluster name (not generateName)
1694+
existingChildXR := tu.NewResource("nested.example.org/v1alpha1", "XChildResource", "parent-xr-child-abc123").
1695+
WithGenerateName("parent-xr-").
1696+
WithSpecField("childField", "existing-value").
1697+
WithCompositionResourceName("child-xr").
1698+
WithLabels(map[string]string{
1699+
"crossplane.io/composite": "parent-xr-abc", // Existing composite label
1700+
}).
1701+
Build()
1702+
1703+
// Create an existing managed resource owned by the nested XR
1704+
existingManagedResource := tu.NewResource("nop.example.org/v1alpha1", "NopResource", "parent-xr-child-abc123-managed-xyz").
1705+
WithGenerateName("parent-xr-child-abc123-").
1706+
WithSpecField("forProvider", map[string]any{
1707+
"configData": "existing-data",
1708+
}).
1709+
WithCompositionResourceName("managed-resource").
1710+
WithLabels(map[string]string{
1711+
"crossplane.io/composite": "parent-xr-child-abc123", // Points to existing nested XR
1712+
}).
1713+
Build()
1714+
1715+
// Create a parent XR that owns the nested XR
1716+
parentXR := tu.NewResource("parent.example.org/v1alpha1", "XParentResource", "parent-xr-abc").
1717+
WithGenerateName("parent-xr-").
1718+
Build()
1719+
1720+
// Create functions
1721+
functions := []pkgv1.Function{
1722+
{
1723+
ObjectMeta: metav1.ObjectMeta{
1724+
Name: "function-go-templating",
1725+
},
1726+
Spec: pkgv1.FunctionSpec{
1727+
PackageSpec: pkgv1.PackageSpec{
1728+
Package: "xpkg.crossplane.io/crossplane-contrib/function-go-templating:v0.11.0",
1729+
},
1730+
},
1731+
},
1732+
}
1733+
1734+
xpClients := xp.Clients{
1735+
Definition: tu.NewMockDefinitionClient().
1736+
WithXRD(childXRD).
1737+
Build(),
1738+
Composition: tu.NewMockCompositionClient().
1739+
WithComposition(childComposition).
1740+
Build(),
1741+
Function: tu.NewMockFunctionClient().
1742+
WithSuccessfulFunctionsFetch(functions).
1743+
Build(),
1744+
Environment: tu.NewMockEnvironmentClient().
1745+
WithNoEnvironmentConfigs().
1746+
Build(),
1747+
// Mock resource tree to return existing nested XR and its managed resources
1748+
ResourceTree: tu.NewMockResourceTreeClient().
1749+
WithResourceTreeFromXRAndComposed(
1750+
parentXR,
1751+
[]*un.Unstructured{existingChildXR, existingManagedResource},
1752+
).
1753+
Build(),
1754+
}
1755+
1756+
// Create CRDs
1757+
childCRD := tu.NewCRD("xchildresources.nested.example.org", "nested.example.org", "XChildResource").
1758+
WithListKind("XChildResourceList").
1759+
WithPlural("xchildresources").
1760+
WithSingular("xchildresource").
1761+
WithVersion("v1alpha1", true, true).
1762+
WithStandardSchema("childField").
1763+
Build()
1764+
1765+
nopCRD := tu.NewCRD("nopresources.nop.example.org", "nop.example.org", "NopResource").
1766+
WithListKind("NopResourceList").
1767+
WithPlural("nopresources").
1768+
WithSingular("nopresource").
1769+
WithVersion("v1alpha1", true, true).
1770+
WithStandardSchema("configData").
1771+
Build()
1772+
1773+
k8sClients := k8.Clients{
1774+
Apply: tu.NewMockApplyClient().Build(),
1775+
Resource: tu.NewMockResourceClient().Build(),
1776+
Schema: tu.NewMockSchemaClient().
1777+
WithFoundCRD("nested.example.org", "XChildResource", childCRD).
1778+
WithFoundCRD("nop.example.org", "NopResource", nopCRD).
1779+
WithSuccessfulCRDByNameFetch("xchildresources.nested.example.org", childCRD).
1780+
Build(),
1781+
Type: tu.NewMockTypeConverter().Build(),
1782+
}
1783+
1784+
return xpClients, k8sClients
1785+
},
1786+
composedResources: []cpd.Unstructured{
1787+
// The RENDERED nested XR (from parent composition) with generateName but no name
1788+
{Unstructured: *childXR},
1789+
},
1790+
parentResourceID: "XParentResource/parent-xr-abc",
1791+
depth: 1,
1792+
// TODO: This will currently fail because the nested XR gets "(generated)" name
1793+
// After fix, should NOT show managed resources as removed/added
1794+
wantDiffCount: 1, // Just the nested XR diff, not its managed resources as separate remove/add
1795+
wantErr: false,
1796+
},
16881797
}
16891798

16901799
for name, tt := range tests {
@@ -1730,8 +1839,11 @@ func TestDefaultDiffProcessor_ProcessNestedXRs(t *testing.T) {
17301839
return xpClients.Composition.FindMatchingComposition(ctx, res)
17311840
}
17321841

1842+
// Create a mock parent XR (nil is acceptable for tests that don't need observed resources)
1843+
var parentXR *cmp.Unstructured
1844+
17331845
// Call the method under test
1734-
diffs, err := processor.ProcessNestedXRs(ctx, tt.composedResources, compositionProvider, tt.parentResourceID, tt.depth)
1846+
diffs, err := processor.ProcessNestedXRs(ctx, tt.composedResources, compositionProvider, tt.parentResourceID, parentXR, tt.depth)
17351847

17361848
// Check error
17371849
if (err != nil) != tt.wantErr {

0 commit comments

Comments
 (0)