From 08091d3ead6b622e00b333f1203811d76a4b4139 Mon Sep 17 00:00:00 2001 From: Mariano Uvalle Date: Thu, 31 Jul 2025 16:51:47 -0700 Subject: [PATCH 1/3] Implement the replace-when annotation that allows resources to be replaced when a condition is met. Signed-off-by: Mariano Uvalle --- internal/resource/resource.go | 25 ++++++++ internal/resource/resource_test.go | 93 +++++++++++++++++++++++++++++- 2 files changed, 117 insertions(+), 1 deletion(-) diff --git a/internal/resource/resource.go b/internal/resource/resource.go index 0cb9a979..8432c04a 100644 --- a/internal/resource/resource.go +++ b/internal/resource/resource.go @@ -15,6 +15,7 @@ import ( "time" apiv1 "github.com/Azure/eno/api/v1" + enocel "github.com/Azure/eno/internal/cel" "github.com/Azure/eno/internal/readiness" "github.com/Azure/eno/internal/resource/mutation" "github.com/go-logr/logr" @@ -25,6 +26,7 @@ import ( "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/structured-merge-diff/v4/fieldpath" + "github.com/google/cel-go/cel" ) var patchGVK = schema.GroupVersionKind{ @@ -65,6 +67,7 @@ type Resource struct { manifestDeleted bool readinessGroup int overrides []*mutation.Op + replaceWhen cel.Program latestKnownState atomic.Pointer[apiv1.ResourceState] } @@ -113,6 +116,17 @@ func (r *Resource) Snapshot(ctx context.Context, comp *apiv1.Composition, actual const replaceKey = "eno.azure.io/replace" snap.Replace = anno[replaceKey] == "true" + // Handle replace-when annotation + if r.replaceWhen != nil { + // Evaluate the condition + val, err := enocel.Eval(ctx, r.replaceWhen, comp, actual, nil) + if err != nil { + logr.FromContextOrDiscard(ctx).V(0).Info("error evaluating replace-when condition", "error", err) + } else if b, ok := val.Value().(bool); ok && b { + snap.Replace = true + } + } + const reconcileIntervalKey = "eno.azure.io/reconcile-interval" if str, ok := anno[reconcileIntervalKey]; ok { reconcileInterval, err := time.ParseDuration(str) @@ -259,6 +273,17 @@ func NewResource(ctx context.Context, slice *apiv1.ResourceSlice, index int) (*R } } + // Parse replace-when condition + const replaceWhenKey = "eno.azure.io/replace-when" + if condition, ok := anno[replaceWhenKey]; ok { + prgm, err := enocel.Parse(condition) + if err != nil { + logger.Error(err, "invalid replace-when condition") + } else { + res.replaceWhen = prgm + } + } + const readinessGroupKey = "eno.azure.io/readiness-group" if str, ok := anno[readinessGroupKey]; ok { rg, err := strconv.Atoi(str) diff --git a/internal/resource/resource_test.go b/internal/resource/resource_test.go index 95e203c0..19cfc94d 100644 --- a/internal/resource/resource_test.go +++ b/internal/resource/resource_test.go @@ -313,6 +313,92 @@ var newResourceTests = []struct { assert.Len(t, r.overrides, 0) }, }, + { + Name: "replace-when-condition-true", + Manifest: `{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": { + "name": "foo", + "annotations": { + "eno.azure.io/replace-when": "true" + } + } + }`, + Assert: func(t *testing.T, r *Snapshot) { + assert.True(t, r.Replace) + }, + }, + { + Name: "replace-when-condition-false", + Manifest: `{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": { + "name": "foo", + "annotations": { + "eno.azure.io/replace-when": "false" + } + } + }`, + Assert: func(t *testing.T, r *Snapshot) { + assert.False(t, r.Replace) + }, + }, + { + Name: "replace-when-with-self-condition", + Manifest: `{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": { + "name": "foo", + "annotations": { + "eno.azure.io/replace-when": "has(self.data.replaceMe)" + } + }, + "data": { + "replaceMe": "yes" + } + }`, + Assert: func(t *testing.T, r *Snapshot) { + assert.True(t, r.Replace) + }, + }, + { + Name: "replace-when-invalid-condition", + Manifest: `{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": { + "name": "foo", + "annotations": { + "eno.azure.io/replace-when": "invalid CEL expression !@#$" + } + } + }`, + Assert: func(t *testing.T, r *Snapshot) { + // Should remain false when condition parsing fails + assert.False(t, r.Replace) + }, + }, + { + Name: "replace-when-overrides-replace", + Manifest: `{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": { + "name": "foo", + "annotations": { + "eno.azure.io/replace": "false", + "eno.azure.io/replace-when": "true" + } + } + }`, + Assert: func(t *testing.T, r *Snapshot) { + // replace-when should override the replace annotation when condition is true + assert.True(t, r.Replace) + }, + }, { Name: "labels", Manifest: `{ @@ -359,7 +445,12 @@ func TestNewResource(t *testing.T) { }, 0) require.NoError(t, err) - rs, err := r.Snapshot(t.Context(), &apiv1.Composition{}, nil) + // Create actual resource for snapshot evaluation + actual := &unstructured.Unstructured{} + err = actual.UnmarshalJSON([]byte(tc.Manifest)) + require.NoError(t, err) + + rs, err := r.Snapshot(t.Context(), &apiv1.Composition{}, actual) require.NoError(t, err) tc.Assert(t, rs) From 0f1223de7e030b3a6f1b0135466baff42d0b9b42 Mon Sep 17 00:00:00 2001 From: Mariano Uvalle Date: Thu, 31 Jul 2025 16:57:11 -0700 Subject: [PATCH 2/3] Add docs. Signed-off-by: Mariano Uvalle --- docs/reconciliation/README.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/reconciliation/README.md b/docs/reconciliation/README.md index 98c13915..c1942c9c 100644 --- a/docs/reconciliation/README.md +++ b/docs/reconciliation/README.md @@ -16,6 +16,17 @@ Exceptions: - The eno-reconciler process can fall back to client-side three-way merge patch by setting `--disable-ssa` - Merge can be disabled for a resource by setting the `eno.azure.io/replace: "true"` annotation (a full `update` request will be used instead of a `patch`) +- Merge can be disabled conditionally by setting the `eno.azure.io/replace-when` annotation with a CEL expression: + ```yaml + apiVersion: v1 + kind: ConfigMap + metadata: + name: foo + annotations: + eno.azure.io/replace-when: "has(self.data.replaceMe)" + data: + replaceMe: "yes" + ``` - All updates can be disabled for a resource by setting the `eno.azure.io/disable-updates: "true"` annotation ## Deletion From 85269f8c20697af00476dacb06c4a1d0a01efd80 Mon Sep 17 00:00:00 2001 From: Mariano Uvalle Date: Thu, 31 Jul 2025 17:01:56 -0700 Subject: [PATCH 3/3] Precedence note. Signed-off-by: Mariano Uvalle --- docs/reconciliation/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reconciliation/README.md b/docs/reconciliation/README.md index c1942c9c..df8b9933 100644 --- a/docs/reconciliation/README.md +++ b/docs/reconciliation/README.md @@ -16,7 +16,7 @@ Exceptions: - The eno-reconciler process can fall back to client-side three-way merge patch by setting `--disable-ssa` - Merge can be disabled for a resource by setting the `eno.azure.io/replace: "true"` annotation (a full `update` request will be used instead of a `patch`) -- Merge can be disabled conditionally by setting the `eno.azure.io/replace-when` annotation with a CEL expression: +- Merge can be disabled conditionally by setting the `eno.azure.io/replace-when` annotation with a CEL expression, it takes precedence over the non-conditional replace: ```yaml apiVersion: v1 kind: ConfigMap