Skip to content
Open
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
9 changes: 9 additions & 0 deletions cmd/eno-reconciler/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"flag"
"fmt"
"os"
"strings"
"time"

"go.uber.org/zap"
Expand Down Expand Up @@ -41,6 +42,7 @@ func run() error {
namespaceCreationGracePeriod time.Duration
namespaceCleanup bool
enoBuildVersion string
migratingFieldManagers string

mgrOpts = &manager.Options{
Rest: ctrl.GetConfigOrDie(),
Expand All @@ -61,6 +63,7 @@ func run() error {
flag.DurationVar(&namespaceCreationGracePeriod, "ns-creation-grace-period", time.Second, "A namespace is assumed to be missing if it doesn't exist once one of its resources has existed for this long")
flag.BoolVar(&namespaceCleanup, "namespace-cleanup", true, "Clean up orphaned resources caused by namespace force-deletions")
flag.BoolVar(&recOpts.FailOpen, "fail-open", false, "Report that resources are reconciled once they've been seen, even if reconciliation failed. Overridden by individual resources with 'eno.azure.io/fail-open: true|false'")
flag.StringVar(&migratingFieldManagers, "migrating-field-managers", "", "Comma-separated list of Kubernetes SSA field manager names to take ownership from during migrations")
mgrOpts.Bind(flag.CommandLine)
flag.Parse()

Expand Down Expand Up @@ -124,6 +127,12 @@ func run() error {
recOpts.Manager = mgr
recOpts.WriteBuffer = writeBuffer
recOpts.Downstream = remoteConfig
if migratingFieldManagers != "" {
recOpts.MigratingFieldManagers = strings.Split(migratingFieldManagers, ",")
for i := range recOpts.MigratingFieldManagers {
recOpts.MigratingFieldManagers[i] = strings.TrimSpace(recOpts.MigratingFieldManagers[i])
}
}

err = reconciliation.New(mgr, recOpts)
if err != nil {
Expand Down
70 changes: 41 additions & 29 deletions internal/controllers/reconciliation/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,23 +37,25 @@ type Options struct {

DisableServerSideApply bool
FailOpen bool
MigratingFieldManagers []string

Timeout time.Duration
ReadinessPollInterval time.Duration
MinReconcileInterval time.Duration
}

type Controller struct {
client client.Client
writeBuffer *flowcontrol.ResourceSliceWriteBuffer
resourceClient *resource.Cache
resourceFilter cel.Program
timeout time.Duration
readinessPollInterval time.Duration
upstreamClient client.Client
minReconcileInterval time.Duration
disableSSA bool
failOpen bool
client client.Client
writeBuffer *flowcontrol.ResourceSliceWriteBuffer
resourceClient *resource.Cache
resourceFilter cel.Program
timeout time.Duration
readinessPollInterval time.Duration
upstreamClient client.Client
minReconcileInterval time.Duration
disableSSA bool
failOpen bool
migratingFieldManagers []string
}

func New(mgr ctrl.Manager, opts Options) error {
Expand All @@ -70,16 +72,17 @@ func New(mgr ctrl.Manager, opts Options) error {
}

c := &Controller{
client: opts.Manager.GetClient(),
writeBuffer: opts.WriteBuffer,
resourceClient: cache,
resourceFilter: opts.ResourceFilter,
timeout: opts.Timeout,
readinessPollInterval: opts.ReadinessPollInterval,
upstreamClient: upstreamClient,
minReconcileInterval: opts.MinReconcileInterval,
disableSSA: opts.DisableServerSideApply,
failOpen: opts.FailOpen,
client: opts.Manager.GetClient(),
writeBuffer: opts.WriteBuffer,
resourceClient: cache,
resourceFilter: opts.ResourceFilter,
timeout: opts.Timeout,
readinessPollInterval: opts.ReadinessPollInterval,
upstreamClient: upstreamClient,
minReconcileInterval: opts.MinReconcileInterval,
disableSSA: opts.DisableServerSideApply,
failOpen: opts.FailOpen,
migratingFieldManagers: opts.MigratingFieldManagers,
}

return builder.TypedControllerManagedBy[resource.Request](mgr).
Expand Down Expand Up @@ -281,18 +284,27 @@ func (c *Controller) reconcileSnapshot(ctx context.Context, comp *apiv1.Composit

// When using server side apply, make sure we haven't lost any managedFields metadata.
// Eno should always remove fields that are no longer set by the synthesizer, even if another client messed with managedFields.
if current != nil && prev != nil && !res.Replace {
snap, err := prev.SnapshotWithOverrides(ctx, comp, current, res.Resource)
if err != nil {
return false, fmt.Errorf("snapshotting previous version: %w", err)
// Also handle taking ownership from migrating field managers.
if current != nil && !res.Replace {
var dryRunPrev *unstructured.Unstructured
if prev != nil {
snap, err := prev.SnapshotWithOverrides(ctx, comp, current, res.Resource)
if err != nil {
return false, fmt.Errorf("snapshotting previous version: %w", err)
}
dryRunPrev = snap.Unstructured()
err = c.upstreamClient.Patch(ctx, dryRunPrev, client.Apply, client.ForceOwnership, client.FieldOwner("eno"), client.DryRunAll)
if err != nil {
return false, fmt.Errorf("getting managed fields values for previous version: %w", err)
}
}
dryRunPrev := snap.Unstructured()
err = c.upstreamClient.Patch(ctx, dryRunPrev, client.Apply, client.ForceOwnership, client.FieldOwner("eno"), client.DryRunAll)
if err != nil {
return false, fmt.Errorf("getting managed fields values for previous version: %w", err)

var prevManagedFields []metav1.ManagedFieldsEntry
if dryRunPrev != nil {
prevManagedFields = dryRunPrev.GetManagedFields()
}

merged, fields, modified := resource.MergeEnoManagedFields(dryRunPrev.GetManagedFields(), current.GetManagedFields(), dryRun.GetManagedFields())
merged, fields, modified := resource.MergeEnoManagedFields(prevManagedFields, current.GetManagedFields(), dryRun.GetManagedFields(), c.migratingFieldManagers)
if modified {
current.SetManagedFields(merged)

Expand Down
217 changes: 217 additions & 0 deletions internal/controllers/reconciliation/overrides_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -847,3 +847,220 @@ func TestOverrideTransferResource(t *testing.T) {
return syn1.Name == cm.Annotations["synthName"]
})
}

func TestMigratingFieldManagers(t *testing.T) {
ctx := testutil.NewContext(t)
mgr := testutil.NewManager(t)
upstream := mgr.GetClient()

requireSSA(t, mgr)
registerControllers(t, mgr)

// Use a variable to change Eno's desired state during resynthesis
enoValue := "eno-value"
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": enoValue},
},
}}
return output, nil
})

// Setup with migrating field managers
setupTestSubjectForOptions(t, mgr, Options{
Manager: mgr.Manager,
Timeout: time.Minute,
ReadinessPollInterval: time.Hour,
DisableServerSideApply: mgr.NoSsaSupport,
MigratingFieldManagers: []string{"legacy-tool"},
})
mgr.Start(t)
_, comp := writeGenericComposition(t, upstream)

// Wait for initial reconciliation
testutil.Eventually(t, func() bool {
return upstream.Get(ctx, client.ObjectKeyFromObject(comp), comp) == nil && comp.Status.CurrentSynthesis != nil && comp.Status.CurrentSynthesis.Ready != nil
})

// Resource should be created with Eno as the field manager
cm := &corev1.ConfigMap{}
cm.Name = "test-obj"
cm.Namespace = "default"
testutil.Eventually(t, func() bool {
err := mgr.DownstreamClient.Get(ctx, client.ObjectKeyFromObject(cm), cm)
return err == nil && cm.Data["foo"] == "eno-value"
})

// Simulate a legacy tool taking ownership of a field by updating managed fields
// This simulates the scenario where a field was previously managed by another tool
err := retry.RetryOnConflict(testutil.Backoff, func() error {
err := mgr.DownstreamClient.Get(ctx, client.ObjectKeyFromObject(cm), cm)
if err != nil {
return err
}
cm.ManagedFields = nil
cm.Data["bar"] = "legacy-value"
cm.APIVersion = "v1"
cm.Kind = "ConfigMap"
return mgr.DownstreamClient.Patch(ctx, cm, client.Apply, client.ForceOwnership, client.FieldOwner("legacy-tool"))
})
require.NoError(t, err)

// Verify the field is owned by legacy-tool
testutil.Eventually(t, func() bool {
mgr.DownstreamClient.Get(ctx, client.ObjectKeyFromObject(cm), cm)
for _, entry := range cm.GetManagedFields() {
if entry.Manager == "legacy-tool" {
return true
}
}
return false
})

// Change Eno's desired state and force a resynthesis to trigger field manager migration
enoValue = "eno-value-updated"
err = retry.RetryOnConflict(testutil.Backoff, func() error {
upstream.Get(ctx, client.ObjectKeyFromObject(comp), comp)
comp.Spec.SynthesisEnv = []apiv1.EnvVar{{Name: "TRIGGER", Value: "resynthesis"}}
return upstream.Update(ctx, comp)
})
require.NoError(t, err)

// Wait for reconciliation
testutil.Eventually(t, func() bool {
return upstream.Get(ctx, client.ObjectKeyFromObject(comp), comp) == nil && comp.Status.CurrentSynthesis != nil && comp.Status.CurrentSynthesis.Ready != nil
})

// Verify that Eno has taken ownership from legacy-tool
testutil.Eventually(t, func() bool {
mgr.DownstreamClient.Get(ctx, client.ObjectKeyFromObject(cm), cm)
hasEno := false
hasLegacy := false
for _, entry := range cm.GetManagedFields() {
if entry.Manager == "eno" {
hasEno = true
}
if entry.Manager == "legacy-tool" {
hasLegacy = true
}
}
// Eno should have taken ownership, and legacy-tool should no longer own fields
// (or should own an empty set of fields)
// Also verify the value was updated
return hasEno && !hasLegacy && cm.Data["foo"] == "eno-value-updated"
})
}

func TestMigratingFieldManagersFieldRemoval(t *testing.T) {
ctx := testutil.NewContext(t)
mgr := testutil.NewManager(t)
upstream := mgr.GetClient()

requireSSA(t, mgr)
registerControllers(t, mgr)

// Start with synthesizer that includes a field
includeField := true
fooValue := "eno-value"
testutil.WithFakeExecutor(t, mgr, func(ctx context.Context, s *apiv1.Synthesizer, input *krmv1.ResourceList) (*krmv1.ResourceList, error) {
output := &krmv1.ResourceList{}
data := map[string]any{"foo": fooValue}
if includeField {
data["bar"] = "eno-bar-value"
}
output.Items = []*unstructured.Unstructured{{
Object: map[string]any{
"apiVersion": "v1",
"kind": "ConfigMap",
"metadata": map[string]any{
"name": "test-obj",
"namespace": "default",
},
"data": data,
},
}}
return output, nil
})

// Setup with migrating field managers
setupTestSubjectForOptions(t, mgr, Options{
Manager: mgr.Manager,
Timeout: time.Minute,
ReadinessPollInterval: time.Hour,
DisableServerSideApply: mgr.NoSsaSupport,
MigratingFieldManagers: []string{"legacy-tool"},
})
mgr.Start(t)
_, comp := writeGenericComposition(t, upstream)

// Wait for initial reconciliation
testutil.Eventually(t, func() bool {
return upstream.Get(ctx, client.ObjectKeyFromObject(comp), comp) == nil && comp.Status.CurrentSynthesis != nil && comp.Status.CurrentSynthesis.Ready != nil
})

cm := &corev1.ConfigMap{}
cm.Name = "test-obj"
cm.Namespace = "default"
testutil.Eventually(t, func() bool {
err := mgr.DownstreamClient.Get(ctx, client.ObjectKeyFromObject(cm), cm)
return err == nil && cm.Data["bar"] == "eno-bar-value"
})

// Simulate legacy tool taking ownership of the "bar" field
err := retry.RetryOnConflict(testutil.Backoff, func() error {
err := mgr.DownstreamClient.Get(ctx, client.ObjectKeyFromObject(cm), cm)
if err != nil {
return err
}
cm.ManagedFields = nil
cm.Data["bar"] = "legacy-bar-value"
cm.APIVersion = "v1"
cm.Kind = "ConfigMap"
return mgr.DownstreamClient.Patch(ctx, cm, client.Apply, client.ForceOwnership, client.FieldOwner("legacy-tool"))
})
require.NoError(t, err)

// Verify legacy-tool owns the field
testutil.Eventually(t, func() bool {
mgr.DownstreamClient.Get(ctx, client.ObjectKeyFromObject(cm), cm)
return cm.Data["bar"] == "legacy-bar-value"
})

// Now remove the field from Eno's desired state and change foo to trigger reconciliation
includeField = false
fooValue = "eno-value-updated"

// Force resynthesis
err = retry.RetryOnConflict(testutil.Backoff, func() error {
upstream.Get(ctx, client.ObjectKeyFromObject(comp), comp)
comp.Spec.SynthesisEnv = []apiv1.EnvVar{{Name: "REMOVE_FIELD", Value: "true"}}
return upstream.Update(ctx, comp)
})
require.NoError(t, err)

// Wait for reconciliation
testutil.Eventually(t, func() bool {
return upstream.Get(ctx, client.ObjectKeyFromObject(comp), comp) == nil && comp.Status.CurrentSynthesis != nil && comp.Status.CurrentSynthesis.Ready != nil
})

// The critical test: since Eno took ownership from legacy-tool,
// it should be able to remove the field even though it was originally owned by legacy-tool.
// This is the whole point of the migration feature - to allow safe field removal during migrations.
testutil.Eventually(t, func() bool {
mgr.DownstreamClient.Get(ctx, client.ObjectKeyFromObject(cm), cm)
_, exists := cm.Data["bar"]
return !exists && cm.Data["foo"] == "eno-value-updated" // Field should be removed and foo should be updated
})

// Verify foo is still present with the updated value
require.NoError(t, mgr.DownstreamClient.Get(ctx, client.ObjectKeyFromObject(cm), cm))
assert.Equal(t, "eno-value-updated", cm.Data["foo"])
}
Loading
Loading