diff --git a/api/v1/composition.go b/api/v1/composition.go index f7124f75..8ee0249f 100644 --- a/api/v1/composition.go +++ b/api/v1/composition.go @@ -135,6 +135,7 @@ type InputRevisions struct { Revision *int `json:"revision,omitempty"` SynthesizerGeneration *int64 `json:"synthesizerGeneration,omitempty"` CompositionGeneration *int64 `json:"compositionGeneration,omitempty"` + IgnoreSideEffects *bool `json:"ignoreSideEffects,omitempty"` } func NewInputRevisions(obj client.Object, refKey string) *InputRevisions { @@ -151,6 +152,9 @@ func NewInputRevisions(obj client.Object, refKey string) *InputRevisions { if rev, _ := strconv.ParseInt(obj.GetAnnotations()["eno.azure.io/composition-generation"], 10, 64); rev != 0 { ir.CompositionGeneration = &rev } + if val, err := strconv.ParseBool(obj.GetAnnotations()["eno.azure.io/ignore-side-effects"]); err == nil { + ir.IgnoreSideEffects = &val + } return &ir } @@ -162,6 +166,9 @@ func (i *InputRevisions) Less(b InputRevisions) bool { return *i.Revision < *b.Revision } if i.ResourceVersion == b.ResourceVersion { + if i.IgnoreSideEffects != nil && b.IgnoreSideEffects != nil && *i.IgnoreSideEffects != *b.IgnoreSideEffects { + return *i.IgnoreSideEffects && !*b.IgnoreSideEffects + } return false } iInt, iErr := strconv.Atoi(i.ResourceVersion) diff --git a/api/v1/composition_test.go b/api/v1/composition_test.go index 19535b86..93729519 100644 --- a/api/v1/composition_test.go +++ b/api/v1/composition_test.go @@ -9,6 +9,8 @@ import ( func TestInputRevisionsLess(t *testing.T) { revision1 := 1 revision2 := 2 + trueVal := true + falseVal := false tests := []struct { Name string A InputRevisions @@ -171,6 +173,90 @@ func TestInputRevisionsLess(t *testing.T) { }, Expectation: false, }, + { + Name: "same ResourceVersion with IgnoreSideEffects true vs false", + A: InputRevisions{ + Key: "key7", + ResourceVersion: "7", + IgnoreSideEffects: &trueVal, + }, + B: InputRevisions{ + Key: "key7", + ResourceVersion: "7", + IgnoreSideEffects: &falseVal, + }, + Expectation: true, + }, + { + Name: "same ResourceVersion with IgnoreSideEffects false vs true", + A: InputRevisions{ + Key: "key8", + ResourceVersion: "8", + IgnoreSideEffects: &falseVal, + }, + B: InputRevisions{ + Key: "key8", + ResourceVersion: "8", + IgnoreSideEffects: &trueVal, + }, + Expectation: false, + }, + { + Name: "same ResourceVersion with both IgnoreSideEffects true", + A: InputRevisions{ + Key: "key9", + ResourceVersion: "9", + IgnoreSideEffects: &trueVal, + }, + B: InputRevisions{ + Key: "key9", + ResourceVersion: "9", + IgnoreSideEffects: &trueVal, + }, + Expectation: false, + }, + { + Name: "same ResourceVersion with both IgnoreSideEffects false", + A: InputRevisions{ + Key: "key10", + ResourceVersion: "10", + IgnoreSideEffects: &falseVal, + }, + B: InputRevisions{ + Key: "key10", + ResourceVersion: "10", + IgnoreSideEffects: &falseVal, + }, + Expectation: false, + }, + { + Name: "same ResourceVersion with one nil IgnoreSideEffects", + A: InputRevisions{ + Key: "key11", + ResourceVersion: "11", + IgnoreSideEffects: &trueVal, + }, + B: InputRevisions{ + Key: "key11", + ResourceVersion: "11", + IgnoreSideEffects: nil, + }, + Expectation: false, + }, + { + Name: "same ResourceVersion with both nil IgnoreSideEffects", + A: InputRevisions{ + Key: "key12", + ResourceVersion: "12", + IgnoreSideEffects: nil, + }, + B: InputRevisions{ + Key: "key12", + ResourceVersion: "12", + IgnoreSideEffects: nil, + }, + Expectation: false, + }, } for _, tt := range tests { diff --git a/api/v1/config/crd/eno.azure.io_compositions.yaml b/api/v1/config/crd/eno.azure.io_compositions.yaml index c6fc4d42..fd2714ed 100644 --- a/api/v1/config/crd/eno.azure.io_compositions.yaml +++ b/api/v1/config/crd/eno.azure.io_compositions.yaml @@ -146,6 +146,8 @@ spec: compositionGeneration: format: int64 type: integer + ignoreSideEffects: + type: boolean key: type: string resourceVersion: @@ -248,6 +250,8 @@ spec: compositionGeneration: format: int64 type: integer + ignoreSideEffects: + type: boolean key: type: string resourceVersion: @@ -323,6 +327,8 @@ spec: compositionGeneration: format: int64 type: integer + ignoreSideEffects: + type: boolean key: type: string resourceVersion: @@ -367,6 +373,8 @@ spec: compositionGeneration: format: int64 type: integer + ignoreSideEffects: + type: boolean key: type: string resourceVersion: diff --git a/go.work.sum b/go.work.sum index b5582001..7d789a25 100644 --- a/go.work.sum +++ b/go.work.sum @@ -2175,6 +2175,7 @@ golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= golang.org/x/mod v0.23.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ= +golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -2565,6 +2566,7 @@ golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0 golang.org/x/tools v0.28.0/go.mod h1:dcIOrVd3mfQKTgrDVQHqCPMWy6lnhfhtX3hLXYVLfRw= golang.org/x/tools v0.29.0/go.mod h1:KMQVMRsVxU6nHCFXrBPhDB8XncLNLM0lIy/F14RP588= golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= +golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8= golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= @@ -2891,6 +2893,7 @@ k8s.io/code-generator v0.33.1/go.mod h1:HUKT7Ubp6bOgIbbaPIs9lpd2Q02uqkMCMx9/GjDr k8s.io/code-generator v0.33.2 h1:PCJ0Y6viTCxxJHMOyGqYwWEteM4q6y1Hqo2rNpl6jF4= k8s.io/code-generator v0.33.2/go.mod h1:hBjCA9kPMpjLWwxcr75ReaQfFXY8u+9bEJJ7kRw3J8c= k8s.io/code-generator v0.33.3/go.mod h1:6Y02+HQJYgNphv9z3wJB5w+sjYDIEBQW7sh62PkufvA= +k8s.io/code-generator v0.34.0 h1:Ze2i1QsvUprIlX3oHiGv09BFQRLCz+StA8qKwwFzees= k8s.io/component-base v0.20.1/go.mod h1:guxkoJnNoh8LNrbtiQOlyp2Y2XFCZQmrcg2n/DeYNLk= k8s.io/component-base v0.20.4/go.mod h1:t4p9EdiagbVCJKrQ1RsA5/V4rFQNDfRlevJajlGwgjI= k8s.io/component-base v0.20.6/go.mod h1:6f1MPBAeI+mvuts3sIdtpjljHWBQ2cIy38oBIWMYnrM= @@ -2925,6 +2928,7 @@ k8s.io/gengo/v2 v2.0.0-20240228010128-51d4e06bde70/go.mod h1:VH3AT8AaQOqiGjMF9p0 k8s.io/gengo/v2 v2.0.0-20240826214909-a7b603a56eb7/go.mod h1:EJykeLsmFC60UQbYJezXkEsG2FLrt0GPNkU5iK5GWxU= k8s.io/gengo/v2 v2.0.0-20240911193312-2b36238f13e9 h1:si3PfKm8dDYxgfbeA6orqrtLkvvIeH8UqffFJDl0bz4= k8s.io/gengo/v2 v2.0.0-20240911193312-2b36238f13e9/go.mod h1:EJykeLsmFC60UQbYJezXkEsG2FLrt0GPNkU5iK5GWxU= +k8s.io/gengo/v2 v2.0.0-20250604051438-85fd79dbfd9f h1:SLb+kxmzfA87x4E4brQzB33VBbT2+x7Zq9ROIHmGn9Q= k8s.io/klog/v2 v2.0.0/go.mod h1:PBfzABfn139FHAV07az/IF9Wp1bkk3vpT2XSJ76fSDE= k8s.io/klog/v2 v2.2.0/go.mod h1:Od+F08eJP+W3HUb4pSrPpgp9DGU4GzlpG/TmITuYh/Y= k8s.io/klog/v2 v2.4.0/go.mod h1:Od+F08eJP+W3HUb4pSrPpgp9DGU4GzlpG/TmITuYh/Y= diff --git a/internal/controllers/reconciliation/crud_test.go b/internal/controllers/reconciliation/crud_test.go index 0f433708..549414f8 100644 --- a/internal/controllers/reconciliation/crud_test.go +++ b/internal/controllers/reconciliation/crud_test.go @@ -1072,3 +1072,165 @@ func TestResourceSelector(t *testing.T) { }) assert.True(t, errors.IsNotFound(mgr.DownstreamClient.Get(ctx, types.NamespacedName{Namespace: "default", Name: "test-obj-1"}, &corev1.ConfigMap{}))) } + +func TestIgnoreSideEffectsInputAnnotationOverrideFalse(t *testing.T) { + ctx := testutil.NewContext(t) + mgr := testutil.NewManager(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", + }, + "data": map[string]any{"foo": "bar"}, + }, + }} + return output, nil + }) + + setupTestSubject(t, mgr) + mgr.Start(t) + + input := &corev1.ConfigMap{} + input.Name = "test-input" + input.Namespace = "default" + input.Data = map[string]string{"replicas": "1"} + input.Annotations = map[string]string{"eno.azure.io/ignore-side-effects": "false"} + require.NoError(t, upstream.Create(ctx, input)) + + synth := &apiv1.Synthesizer{} + synth.Name = "test-syn" + synth.Spec.Image = "test-image" + synth.Spec.Refs = []apiv1.Ref{{ + Key: "config", + Resource: apiv1.ResourceRef{ + Version: "v1", + Kind: "ConfigMap", + }, + }} + require.NoError(t, upstream.Create(ctx, synth)) + + comp := &apiv1.Composition{} + comp.Name = "test-comp" + comp.Namespace = "default" + comp.Annotations = map[string]string{"eno.azure.io/ignore-side-effects": "true"} + comp.Spec.Synthesizer.Name = synth.Name + comp.Spec.Bindings = []apiv1.Binding{{ + Key: "config", + Resource: apiv1.ResourceBinding{ + Name: input.Name, + Namespace: input.Namespace, + }, + }} + require.NoError(t, upstream.Create(ctx, comp)) + + testutil.Eventually(t, func() bool { + upstream.Get(ctx, client.ObjectKeyFromObject(comp), comp) + return len(comp.Status.InputRevisions) == 1 && + comp.Status.InputRevisions[0].IgnoreSideEffects != nil && + *comp.Status.InputRevisions[0].IgnoreSideEffects == false + }) + + waitForReadiness(t, mgr, comp, synth, nil) + + initialUUID := comp.Status.CurrentSynthesis.UUID + + err := retry.RetryOnConflict(testutil.Backoff, func() error { + err := upstream.Get(ctx, client.ObjectKeyFromObject(input), input) + if err != nil { + return err + } + input.Data["replicas"] = "3" + return upstream.Update(ctx, input) + }) + require.NoError(t, err) + + testutil.Eventually(t, func() bool { + upstream.Get(ctx, client.ObjectKeyFromObject(comp), comp) + return comp.Status.CurrentSynthesis != nil && comp.Status.CurrentSynthesis.UUID != initialUUID + }) +} + +func TestIgnoreSideEffectsInputAnnotationOverrideTrue(t *testing.T) { + ctx := testutil.NewContext(t) + mgr := testutil.NewManager(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", + }, + "data": map[string]any{"foo": "bar"}, + }, + }} + return output, nil + }) + + setupTestSubject(t, mgr) + mgr.Start(t) + + input := &corev1.ConfigMap{} + input.Name = "test-input" + input.Namespace = "default" + input.Data = map[string]string{"replicas": "1"} + input.Annotations = map[string]string{"eno.azure.io/ignore-side-effects": "true"} + require.NoError(t, upstream.Create(ctx, input)) + + synth := &apiv1.Synthesizer{} + synth.Name = "test-syn" + synth.Spec.Image = "test-image" + synth.Spec.Refs = []apiv1.Ref{{ + Key: "config", + Resource: apiv1.ResourceRef{ + Version: "v1", + Kind: "ConfigMap", + }, + }} + require.NoError(t, upstream.Create(ctx, synth)) + + comp := &apiv1.Composition{} + comp.Name = "test-comp" + comp.Namespace = "default" + comp.Spec.Synthesizer.Name = synth.Name + comp.Spec.Bindings = []apiv1.Binding{{ + Key: "config", + Resource: apiv1.ResourceBinding{ + Name: input.Name, + Namespace: input.Namespace, + }, + }} + require.NoError(t, upstream.Create(ctx, comp)) + + waitForReadiness(t, mgr, comp, synth, nil) + + initialUUID := comp.Status.CurrentSynthesis.UUID + + err := retry.RetryOnConflict(testutil.Backoff, func() error { + err := upstream.Get(ctx, client.ObjectKeyFromObject(input), input) + if err != nil { + return err + } + input.Data["replicas"] = "3" + return upstream.Update(ctx, input) + }) + require.NoError(t, err) + + time.Sleep(time.Millisecond * 500) + + require.NoError(t, upstream.Get(ctx, client.ObjectKeyFromObject(comp), comp)) + assert.Equal(t, initialUUID, comp.Status.CurrentSynthesis.UUID) +} diff --git a/internal/controllers/scheduling/op.go b/internal/controllers/scheduling/op.go index b4895870..e8364061 100644 --- a/internal/controllers/scheduling/op.go +++ b/internal/controllers/scheduling/op.go @@ -72,9 +72,6 @@ func classifyOp(synth *apiv1.Synthesizer, comp *apiv1.Composition) (opReason, bo case compositionHasBeenModified(comp): return compositionModifiedOp, true - - case comp.ShouldIgnoreSideEffects(): - return 0, false } syn := comp.Status.CurrentSynthesis @@ -82,7 +79,11 @@ func classifyOp(synth *apiv1.Synthesizer, comp *apiv1.Composition) (opReason, bo syn = comp.Status.InFlightSynthesis } - nonDeferredInputChanges, deferredInputChanges := inputChangeCount(synth, comp.Status.InputRevisions, syn.InputRevisions) + nonDeferredInputChanges, deferredInputChanges, forced := inputChangeCount(synth, comp.Status.InputRevisions, syn.InputRevisions) + if comp.ShouldIgnoreSideEffects() && forced == 0 { + return 0, false + } + if nonDeferredInputChanges > 0 { return inputModifiedOp, true } @@ -250,7 +251,7 @@ func (r opReason) String() string { } } -func inputChangeCount(synth *apiv1.Synthesizer, a, b []apiv1.InputRevisions) (nonDeferred, deferred int) { +func inputChangeCount(synth *apiv1.Synthesizer, a, b []apiv1.InputRevisions) (nonDeferred, deferred, forced int) { refsByKey := map[string]apiv1.Ref{} for _, ref := range synth.Spec.Refs { ref := ref @@ -273,6 +274,14 @@ func inputChangeCount(synth *apiv1.Synthesizer, a, b []apiv1.InputRevisions) (no } if br.Less(ar) { + if ar.IgnoreSideEffects != nil { + if *ar.IgnoreSideEffects { + continue + } else { + forced++ + } + } + if ref.Defer { deferred++ } else { @@ -281,5 +290,5 @@ func inputChangeCount(synth *apiv1.Synthesizer, a, b []apiv1.InputRevisions) (no } } - return nonDeferred, deferred + return nonDeferred, deferred, forced } diff --git a/internal/controllers/scheduling/op_test.go b/internal/controllers/scheduling/op_test.go index 2eb2db32..2f012a12 100644 --- a/internal/controllers/scheduling/op_test.go +++ b/internal/controllers/scheduling/op_test.go @@ -89,6 +89,16 @@ func TestFuzzNewOp(t *testing.T) { }).WithMutation("ignoreSideEffects", func(state newOpTestState) newOpTestState { state.comp.EnableIgnoreSideEffects() return state + }).WithMutation("ignoreSideEffectsTrue", func(state newOpTestState) newOpTestState { + if len(state.comp.Status.InputRevisions) >= 1 { + state.comp.Status.InputRevisions[0].IgnoreSideEffects = ptr.To(true) + } + return state + }).WithMutation("ignoreSideEffectsFalse", func(state newOpTestState) newOpTestState { + if len(state.comp.Status.InputRevisions) >= 1 { + state.comp.Status.InputRevisions[0].IgnoreSideEffects = ptr.To(false) + } + return state }).WithMutation("missingFinalizer", func(state newOpTestState) newOpTestState { state.comp.Finalizers = nil return state @@ -153,9 +163,29 @@ func TestFuzzNewOp(t *testing.T) { if state.hasInvalidState() || state.hasNilSynthesis() || state.comp.ShouldForceResynthesis() || state.isCompositionModified() || !state.comp.ShouldIgnoreSideEffects() { return true } + if state.hasInputWithIgnoreSideEffectsFalse() { + return true + } return op == nil + }).WithInvariant("creates input modified operation when input with IgnoreSideEffects=false forces change", func(state newOpTestState, op *op) bool { + if state.hasInvalidState() || state.hasNilSynthesis() || state.comp.ShouldForceResynthesis() || state.isCompositionModified() { + return true + } + if !state.comp.ShouldIgnoreSideEffects() || !state.hasInputWithIgnoreSideEffectsFalse() || !state.hasInputModified() { + return true + } + return op != nil && op.Reason == inputModifiedOp && !op.Reason.Deferred() }).WithInvariant("creates input modified operation when non-deferred input resource version changed", func(state newOpTestState, op *op) bool { - if state.hasInvalidState() || state.hasNilSynthesis() || state.comp.ShouldForceResynthesis() || state.isCompositionModified() || state.comp.ShouldIgnoreSideEffects() || !state.hasInputModified() { + if state.hasInvalidState() || state.hasNilSynthesis() || state.comp.ShouldForceResynthesis() || state.isCompositionModified() { + return true + } + if state.comp.ShouldIgnoreSideEffects() && !state.hasInputWithIgnoreSideEffectsFalse() { + return true + } + if state.hasInputWithIgnoreSideEffectsTrue() && !state.hasInputWithIgnoreSideEffectsFalse() { + return true + } + if !state.hasInputModified() { return true } return op != nil && op.Reason == inputModifiedOp && !op.Reason.Deferred() @@ -283,6 +313,18 @@ func (s newOpTestState) isSynthesizerModified() bool { syn.ObservedSynthesizerGeneration > 0 && syn.ObservedSynthesizerGeneration < s.synth.Generation } +func (s newOpTestState) hasInputWithIgnoreSideEffectsTrue() bool { + return len(s.comp.Status.InputRevisions) >= 1 && + s.comp.Status.InputRevisions[0].IgnoreSideEffects != nil && + *s.comp.Status.InputRevisions[0].IgnoreSideEffects +} + +func (s newOpTestState) hasInputWithIgnoreSideEffectsFalse() bool { + return len(s.comp.Status.InputRevisions) >= 1 && + s.comp.Status.InputRevisions[0].IgnoreSideEffects != nil && + !*s.comp.Status.InputRevisions[0].IgnoreSideEffects +} + func TestFuzzInputChangeCount(t *testing.T) { for i := 0; i < 10000; i++ { synth := &apiv1.Synthesizer{} @@ -298,20 +340,20 @@ func TestFuzzInputChangeCount(t *testing.T) { for i := 0; i < rand.Intn(20); i++ { b = append(b, newTestInputRevisions()) - synth.Spec.Refs = append(synth.Spec.Refs, apiv1.Ref{Key: strconv.Itoa(rand.Intn(10)), Defer: rand.Intn(2) == 0}) + ref := apiv1.Ref{ + Key: strconv.Itoa(rand.Intn(10)), + Defer: rand.Intn(2) == 0, + } + synth.Spec.Refs = append(synth.Spec.Refs, ref) } - nonDeferred, deferred := inputChangeCount(synth, a, b) + nonDeferred, deferred, _ := inputChangeCount(synth, a, b) - // No refs means no possible input changes if len(synth.Spec.Refs) == 0 { assert.Equal(t, 0, nonDeferred) assert.Equal(t, 0, deferred) continue } - - // There isn't much to test for here without re-implementing all of the logic - // Just make sure it doesn't panic } } @@ -520,3 +562,286 @@ func TestOpNewerInputInSynthesis(t *testing.T) { assert.Nil(t, newOp(synth, comp, time.Time{})) } + +func TestInputChangeCount(t *testing.T) { + tests := []struct { + name string + refs []apiv1.Ref + current []apiv1.InputRevisions + synthesis []apiv1.InputRevisions + wantNonDeferred int + wantDeferred int + wantForced int + }{ + { + name: "no changes", + refs: []apiv1.Ref{ + {Key: "foo"}, + {Key: "bar"}, + }, + current: []apiv1.InputRevisions{ + {Key: "foo", ResourceVersion: "1"}, + {Key: "bar", ResourceVersion: "2"}, + }, + synthesis: []apiv1.InputRevisions{ + {Key: "foo", ResourceVersion: "1"}, + {Key: "bar", ResourceVersion: "2"}, + }, + wantNonDeferred: 0, + wantDeferred: 0, + wantForced: 0, + }, + { + name: "single non-deferred change", + refs: []apiv1.Ref{ + {Key: "foo"}, + }, + current: []apiv1.InputRevisions{ + {Key: "foo", ResourceVersion: "2"}, + }, + synthesis: []apiv1.InputRevisions{ + {Key: "foo", ResourceVersion: "1"}, + }, + wantNonDeferred: 1, + wantDeferred: 0, + wantForced: 0, + }, + { + name: "single deferred change", + refs: []apiv1.Ref{ + {Key: "foo", Defer: true}, + }, + current: []apiv1.InputRevisions{ + {Key: "foo", ResourceVersion: "2"}, + }, + synthesis: []apiv1.InputRevisions{ + {Key: "foo", ResourceVersion: "1"}, + }, + wantNonDeferred: 0, + wantDeferred: 1, + wantForced: 0, + }, + { + name: "mixed deferred and non-deferred changes", + refs: []apiv1.Ref{ + {Key: "foo"}, + {Key: "bar", Defer: true}, + }, + current: []apiv1.InputRevisions{ + {Key: "foo", ResourceVersion: "2"}, + {Key: "bar", ResourceVersion: "3"}, + }, + synthesis: []apiv1.InputRevisions{ + {Key: "foo", ResourceVersion: "1"}, + {Key: "bar", ResourceVersion: "2"}, + }, + wantNonDeferred: 1, + wantDeferred: 1, + wantForced: 0, + }, + { + name: "ignoreSideEffects=true blocks change", + refs: []apiv1.Ref{ + {Key: "foo"}, + }, + current: []apiv1.InputRevisions{ + {Key: "foo", ResourceVersion: "2", IgnoreSideEffects: ptr.To(true)}, + }, + synthesis: []apiv1.InputRevisions{ + {Key: "foo", ResourceVersion: "1"}, + }, + wantNonDeferred: 0, + wantDeferred: 0, + wantForced: 0, + }, + { + name: "ignoreSideEffects=false forces change", + refs: []apiv1.Ref{ + {Key: "foo"}, + }, + current: []apiv1.InputRevisions{ + {Key: "foo", ResourceVersion: "2", IgnoreSideEffects: ptr.To(false)}, + }, + synthesis: []apiv1.InputRevisions{ + {Key: "foo", ResourceVersion: "1"}, + }, + wantNonDeferred: 1, + wantDeferred: 0, + wantForced: 1, + }, + { + name: "ignoreSideEffects=nil allows normal change", + refs: []apiv1.Ref{ + {Key: "foo"}, + }, + current: []apiv1.InputRevisions{ + {Key: "foo", ResourceVersion: "2", IgnoreSideEffects: nil}, + }, + synthesis: []apiv1.InputRevisions{ + {Key: "foo", ResourceVersion: "1"}, + }, + wantNonDeferred: 1, + wantDeferred: 0, + wantForced: 0, + }, + { + name: "ignoreSideEffects=true on deferred input blocks change", + refs: []apiv1.Ref{ + {Key: "foo", Defer: true}, + }, + current: []apiv1.InputRevisions{ + {Key: "foo", ResourceVersion: "2", IgnoreSideEffects: ptr.To(true)}, + }, + synthesis: []apiv1.InputRevisions{ + {Key: "foo", ResourceVersion: "1"}, + }, + wantNonDeferred: 0, + wantDeferred: 0, + wantForced: 0, + }, + { + name: "ignoreSideEffects=false on deferred input forces change", + refs: []apiv1.Ref{ + {Key: "foo", Defer: true}, + }, + current: []apiv1.InputRevisions{ + {Key: "foo", ResourceVersion: "2", IgnoreSideEffects: ptr.To(false)}, + }, + synthesis: []apiv1.InputRevisions{ + {Key: "foo", ResourceVersion: "1"}, + }, + wantNonDeferred: 0, + wantDeferred: 1, + wantForced: 1, + }, + { + name: "multiple inputs with mixed ignoreSideEffects settings", + refs: []apiv1.Ref{ + {Key: "foo"}, + {Key: "bar"}, + {Key: "baz", Defer: true}, + }, + current: []apiv1.InputRevisions{ + {Key: "foo", ResourceVersion: "2", IgnoreSideEffects: ptr.To(true)}, + {Key: "bar", ResourceVersion: "3", IgnoreSideEffects: ptr.To(false)}, + {Key: "baz", ResourceVersion: "4"}, + }, + synthesis: []apiv1.InputRevisions{ + {Key: "foo", ResourceVersion: "1"}, + {Key: "bar", ResourceVersion: "2"}, + {Key: "baz", ResourceVersion: "3"}, + }, + wantNonDeferred: 1, + wantDeferred: 1, + wantForced: 1, + }, + { + name: "all inputs blocked by ignoreSideEffects=true", + refs: []apiv1.Ref{ + {Key: "foo"}, + {Key: "bar", Defer: true}, + }, + current: []apiv1.InputRevisions{ + {Key: "foo", ResourceVersion: "2", IgnoreSideEffects: ptr.To(true)}, + {Key: "bar", ResourceVersion: "3", IgnoreSideEffects: ptr.To(true)}, + }, + synthesis: []apiv1.InputRevisions{ + {Key: "foo", ResourceVersion: "1"}, + {Key: "bar", ResourceVersion: "2"}, + }, + wantNonDeferred: 0, + wantDeferred: 0, + wantForced: 0, + }, + { + name: "all inputs forced by ignoreSideEffects=false", + refs: []apiv1.Ref{ + {Key: "foo"}, + {Key: "bar", Defer: true}, + }, + current: []apiv1.InputRevisions{ + {Key: "foo", ResourceVersion: "2", IgnoreSideEffects: ptr.To(false)}, + {Key: "bar", ResourceVersion: "3", IgnoreSideEffects: ptr.To(false)}, + }, + synthesis: []apiv1.InputRevisions{ + {Key: "foo", ResourceVersion: "1"}, + {Key: "bar", ResourceVersion: "2"}, + }, + wantNonDeferred: 1, + wantDeferred: 1, + wantForced: 2, + }, + { + name: "missing key in synthesis", + refs: []apiv1.Ref{ + {Key: "foo"}, + {Key: "bar"}, + }, + current: []apiv1.InputRevisions{ + {Key: "foo", ResourceVersion: "2"}, + {Key: "bar", ResourceVersion: "2"}, + }, + synthesis: []apiv1.InputRevisions{ + {Key: "foo", ResourceVersion: "1"}, + }, + wantNonDeferred: 1, + wantDeferred: 0, + wantForced: 0, + }, + { + name: "missing key in refs", + refs: []apiv1.Ref{ + {Key: "foo"}, + }, + current: []apiv1.InputRevisions{ + {Key: "foo", ResourceVersion: "2"}, + {Key: "bar", ResourceVersion: "2"}, + }, + synthesis: []apiv1.InputRevisions{ + {Key: "foo", ResourceVersion: "1"}, + {Key: "bar", ResourceVersion: "1"}, + }, + wantNonDeferred: 1, + wantDeferred: 0, + wantForced: 0, + }, + { + name: "no change when synthesis is newer", + refs: []apiv1.Ref{ + {Key: "foo"}, + }, + current: []apiv1.InputRevisions{ + {Key: "foo", ResourceVersion: "1"}, + }, + synthesis: []apiv1.InputRevisions{ + {Key: "foo", ResourceVersion: "2"}, + }, + wantNonDeferred: 0, + wantDeferred: 0, + wantForced: 0, + }, + { + name: "empty inputs", + refs: []apiv1.Ref{}, + current: []apiv1.InputRevisions{}, + synthesis: []apiv1.InputRevisions{}, + wantNonDeferred: 0, + wantDeferred: 0, + wantForced: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + synth := &apiv1.Synthesizer{ + Spec: apiv1.SynthesizerSpec{ + Refs: tt.refs, + }, + } + gotNonDeferred, gotDeferred, gotForced := inputChangeCount(synth, tt.current, tt.synthesis) + assert.Equal(t, tt.wantNonDeferred, gotNonDeferred, "nonDeferred mismatch") + assert.Equal(t, tt.wantDeferred, gotDeferred, "deferred mismatch") + assert.Equal(t, tt.wantForced, gotForced, "forced mismatch") + }) + } +}