Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion api/v1/composition.go
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,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 {
Expand All @@ -199,18 +200,31 @@ 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
}

// Less reports whetehr i should be ordered before b
// both revisiions must share the same key. We can not compare across differnt keys
// Below is the ordering rules
// 1. If both sides have an explicit Revision, compare by Revision
// 2. If ResourceVersions are equal, check if IgnoreSideEffects annotation is present. If ignore side effects variant is preferred.
// 3. If ResourceVersions aren't parseable as ints, fall back to treating i as "less" so comparison degrades gracefully.
func (i *InputRevisions) Less(b InputRevisions) bool {
if i.Key != b.Key {
panic(fmt.Sprintf("cannot compare input revisions for different keys: %s != %s", i.Key, b.Key))
}
if i.Revision != nil && b.Revision != nil {
return *i.Revision < *b.Revision
}
// When ResourceVersions match, prefer the revision that has IgnoreSideEffects=true
// A nil IgnoreSideEffects is treated as false, so we only return true when i is
// explicitly true and b is either nil or false.
if i.ResourceVersion == b.ResourceVersion {
return false
return i.IgnoreSideEffects != nil && *i.IgnoreSideEffects &&
(b.IgnoreSideEffects == nil || !*b.IgnoreSideEffects)
}
iInt, iErr := strconv.Atoi(i.ResourceVersion)
bInt, bErr := strconv.Atoi(b.ResourceVersion)
Expand Down
86 changes: 86 additions & 0 deletions api/v1/composition_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import (
func TestInputRevisionsLess(t *testing.T) {
revision1 := 1
revision2 := 2
trueVal := true
falseVal := false
tests := []struct {
Name string
A InputRevisions
Expand Down Expand Up @@ -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: true,
},
{
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 {
Expand Down
8 changes: 8 additions & 0 deletions api/v1/config/crd/eno.azure.io_compositions.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,8 @@ spec:
compositionGeneration:
format: int64
type: integer
ignoreSideEffects:
type: boolean
key:
type: string
resourceVersion:
Expand Down Expand Up @@ -343,6 +345,8 @@ spec:
compositionGeneration:
format: int64
type: integer
ignoreSideEffects:
type: boolean
key:
type: string
resourceVersion:
Expand Down Expand Up @@ -418,6 +422,8 @@ spec:
compositionGeneration:
format: int64
type: integer
ignoreSideEffects:
type: boolean
key:
type: string
resourceVersion:
Expand Down Expand Up @@ -462,6 +468,8 @@ spec:
compositionGeneration:
format: int64
type: integer
ignoreSideEffects:
type: boolean
key:
type: string
resourceVersion:
Expand Down
5 changes: 5 additions & 0 deletions api/v1/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions examples/02-go-synthesizer/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
FROM mcr.microsoft.com/devcontainers/go:1.24 AS builder

ENV GOTOOLCHAIN=auto

# Disable workspace mode so go build uses the toolchain from go.mod
ENV GOWORK=off

WORKDIR /app

ADD go.mod .
Expand Down
8 changes: 3 additions & 5 deletions examples/03-helm-shim/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
FROM mcr.microsoft.com/devcontainers/go:1.24 AS builder
WORKDIR /app
ENV GOTOOLCHAIN=auto

ADD go.mod .
ADD go.sum .
RUN --mount=type=cache,target=/root/.cache/go-build go mod download
WORKDIR /app

COPY . .
RUN --mount=type=cache,target=/root/.cache/go-build CGO_ENABLED=0 go build -o helm-shim ./examples/03-helm-shim
RUN --mount=type=cache,target=/root/.cache/go-build cd examples/03-helm-shim && CGO_ENABLED=0 go build -o /app/helm-shim .

FROM gcr.io/distroless/static

Expand Down
8 changes: 7 additions & 1 deletion examples/05-crd/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
FROM mcr.microsoft.com/devcontainers/go:1.24 AS builder

ENV GOTOOLCHAIN=auto

# Disable workspace mode so go build uses the toolchain from go.mod
ENV GOWORK=off

WORKDIR /app

ADD go.mod .
Expand All @@ -14,4 +20,4 @@ FROM scratch
USER 65532:65532

COPY --from=builder /app/crd-synthesizer /bin/synthesize
COPY examples/05-crd/config /config
COPY examples/05-crd/config /config
162 changes: 162 additions & 0 deletions internal/controllers/reconciliation/crud_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1137,3 +1137,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)
}
Loading
Loading