diff --git a/internal/controllers/reconciliation/controller.go b/internal/controllers/reconciliation/controller.go index a0a5abe1..aa90815c 100644 --- a/internal/controllers/reconciliation/controller.go +++ b/internal/controllers/reconciliation/controller.go @@ -208,7 +208,7 @@ func (c *Controller) reconcileSnapshot(ctx context.Context, comp *apiv1.Composit reconciliationLatency.Observe(float64(time.Since(start).Milliseconds())) }() - if res.Deleted() { + if res.Deleted() || (res.Recreate && prev != nil && res.CompareManifest(prev) != 0) { if current == nil || current.GetDeletionTimestamp() != nil || res.Orphan || (comp.Labels != nil && comp.Labels["eno.azure.io/symphony-deleting"] == "true") { return false, nil // already deleted - nothing to do } diff --git a/internal/controllers/reconciliation/edgecase_test.go b/internal/controllers/reconciliation/edgecase_test.go index b470bed4..26113ca3 100644 --- a/internal/controllers/reconciliation/edgecase_test.go +++ b/internal/controllers/reconciliation/edgecase_test.go @@ -730,3 +730,62 @@ func TestFailOpen_WithAnnotationTrue(t *testing.T) { return err == nil && comp.Status.CurrentSynthesis != nil && comp.Status.CurrentSynthesis.Reconciled != nil }) } + +func TestRecreateImmutableConfigmap(t *testing.T) { + mgr := testutil.NewManager(t) + setupTestSubject(t, mgr) + testRecreateImmutableConfigmap(t, mgr) +} + +func TestRecreateImmutableConfigmap_DisableSSA(t *testing.T) { + mgr := testutil.NewManager(t) + setupTestSubjectForOptions(t, mgr, Options{ + Manager: mgr.Manager, + Timeout: time.Minute, + ReadinessPollInterval: time.Hour, + DisableServerSideApply: true, + FailOpen: false, + }) + testRecreateImmutableConfigmap(t, mgr) +} + +func testRecreateImmutableConfigmap(t *testing.T, mgr *testutil.Manager) { + ctx := testutil.NewContext(t) + upstream := mgr.GetClient() + + registerControllers(t, mgr) + testutil.WithFakeExecutor(t, mgr, func(ctx context.Context, s *apiv1.Synthesizer, input *krmv1.ResourceList) (*krmv1.ResourceList, error) { + output := &krmv1.ResourceList{} + output.Items = []*unstructured.Unstructured{{ + Object: map[string]any{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]any{ + "name": "test-obj", + "namespace": "default", + "annotations": map[string]any{ + "eno.azure.io/recreate": "true", + }, + }, + "immutable": true, + "data": map[string]any{ + "synthImage": s.Spec.Image, + }, + }, + }} + return output, nil + }) + + mgr.Start(t) + synth, comp := writeGenericComposition(t, upstream) + waitForReadiness(t, mgr, comp, synth, nil) + + // Updating the configmap should be possible even though it's immutable because Eno will recreate it + err := retry.RetryOnConflict(testutil.Backoff, func() error { + upstream.Get(ctx, client.ObjectKeyFromObject(synth), synth) + synth.Spec.Image = "updated" + return upstream.Update(ctx, synth) + }) + require.NoError(t, err) + waitForReadiness(t, mgr, comp, synth, nil) +} diff --git a/internal/resource/resource.go b/internal/resource/resource.go index 80b36260..a3f2fd44 100644 --- a/internal/resource/resource.go +++ b/internal/resource/resource.go @@ -231,10 +231,8 @@ func newResource(ctx context.Context, parsed *unstructured.Unstructured, strict func (r *Resource) State() *apiv1.ResourceState { return r.latestKnownState.Load() } -// Less returns true when r < than. -// Used to establish determinstic ordering for conflicting resources. -func (r *Resource) Less(than *Resource) bool { - return bytes.Compare(r.manifestHash, than.manifestHash) < 0 +func (r *Resource) CompareManifest(than *Resource) int { + return bytes.Compare(r.manifestHash, than.manifestHash) } // group returns the readiness or deletion group index that is relevant to the resource's current deletion state. @@ -284,6 +282,9 @@ func (r *Resource) SnapshotWithOverrides(ctx context.Context, comp *apiv1.Compos const replaceKey = "eno.azure.io/replace" snap.Replace = cascadeAnnotation(comp, copy, replaceKey) == "true" + const recreateKey = "eno.azure.io/recreate" + snap.Recreate = cascadeAnnotation(comp, copy, recreateKey) == "true" + const deletionStratKey = "eno.azure.io/deletion-strategy" snap.Orphan = strings.EqualFold(cascadeAnnotation(comp, copy, deletionStratKey), "orphan") snap.Orphan = !r.isPatch && strings.EqualFold(cascadeAnnotation(comp, copy, deletionStratKey), "orphan") @@ -314,6 +315,7 @@ type Snapshot struct { Disable bool DisableUpdates bool Replace bool + Recreate bool Orphan bool ForegroundDeletion bool diff --git a/internal/resource/resource_test.go b/internal/resource/resource_test.go index 3c8d8f8f..f3335e82 100644 --- a/internal/resource/resource_test.go +++ b/internal/resource/resource_test.go @@ -501,7 +501,7 @@ func TestResourceOrdering(t *testing.T) { {manifestHash: []byte("c")}, } sort.Slice(resources, func(i, j int) bool { - return resources[i].Less(resources[j]) + return resources[i].CompareManifest(resources[j]) < 0 }) assert.Equal(t, []*Resource{ diff --git a/internal/resource/tree.go b/internal/resource/tree.go index bf4e77a4..f0853c81 100644 --- a/internal/resource/tree.go +++ b/internal/resource/tree.go @@ -58,7 +58,7 @@ func (b *treeBuilder) Add(resource *Resource) { b.init() // Handle conflicting refs deterministically - if existing, ok := b.byRef[resource.Ref]; ok && resource.Less(existing.Resource) { + if existing, ok := b.byRef[resource.Ref]; ok && resource.CompareManifest(existing.Resource) < 0 { return }