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
9 changes: 9 additions & 0 deletions cmd/eno-reconciler/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ func run() error {
namespaceCleanup bool
enoBuildVersion string
migratingFieldManagers string
migratingFields string

mgrOpts = &manager.Options{
Rest: ctrl.GetConfigOrDie(),
Expand All @@ -64,6 +65,7 @@ func run() error {
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", os.Getenv("MIGRATING_FIELD_MANAGERS"), "Comma-separated list of Kubernetes SSA field manager names to take ownership from during migrations")
flag.StringVar(&migratingFields, "migrating-fields", os.Getenv("MIGRATING_FIELDS"), "Comma-seperated list of fields Kubernetes fields(metadata.labels, spec, stringData...) to migrate the ownership to eno")
mgrOpts.Bind(flag.CommandLine)
flag.Parse()

Expand Down Expand Up @@ -135,6 +137,13 @@ func run() error {
}
}

if migratingFields != "" {
recOpts.MigratingFields = strings.Split(migratingFields, ",")
for i := range recOpts.MigratingFields {
recOpts.MigratingFields[i] = strings.TrimSpace(recOpts.MigratingFields[i])
}
}

err = reconciliation.New(mgr, recOpts)
if err != nil {
return fmt.Errorf("constructing reconciliation controller: %w", err)
Expand Down
5 changes: 4 additions & 1 deletion internal/controllers/reconciliation/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ type Options struct {
DisableServerSideApply bool
FailOpen bool
MigratingFieldManagers []string
MigratingFields []string

Timeout time.Duration
ReadinessPollInterval time.Duration
Expand All @@ -56,6 +57,7 @@ type Controller struct {
disableSSA bool
failOpen bool
migratingFieldManagers []string
migratingFields []string
}

func New(mgr ctrl.Manager, opts Options) error {
Expand Down Expand Up @@ -83,6 +85,7 @@ func New(mgr ctrl.Manager, opts Options) error {
disableSSA: opts.DisableServerSideApply,
failOpen: opts.FailOpen,
migratingFieldManagers: opts.MigratingFieldManagers,
migratingFields: opts.MigratingFields,
}

return builder.TypedControllerManagedBy[resource.Request](mgr).
Expand Down Expand Up @@ -296,7 +299,7 @@ func (c *Controller) reconcileSnapshot(ctx context.Context, comp *apiv1.Composit
// subsequent SSA Apply will treat eno as the sole owner and automatically merge the managedFields
// entries into a single consolidated entry for eno.
if current != nil && len(c.migratingFieldManagers) > 0 {
wasModified, err := resource.NormalizeConflictingManagers(ctx, current, c.migratingFieldManagers)
wasModified, err := resource.NormalizeConflictingManagers(ctx, current, c.migratingFieldManagers, c.migratingFields)
if err != nil {
return false, fmt.Errorf("normalize conflicting manager failed: %w", err)
}
Expand Down
2 changes: 2 additions & 0 deletions internal/controllers/reconciliation/overrides_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -881,6 +881,7 @@ func TestMigratingFieldManagers(t *testing.T) {
ReadinessPollInterval: time.Hour,
DisableServerSideApply: mgr.NoSsaSupport,
MigratingFieldManagers: []string{"legacy-tool"},
MigratingFields: []string{"data"},
})
mgr.Start(t)
_, comp := writeGenericComposition(t, upstream)
Expand Down Expand Up @@ -997,6 +998,7 @@ func TestMigratingFieldManagersFieldRemoval(t *testing.T) {
ReadinessPollInterval: time.Hour,
DisableServerSideApply: mgr.NoSsaSupport,
MigratingFieldManagers: []string{"legacy-tool"},
MigratingFields: []string{"data"},
})
mgr.Start(t)
_, comp := writeGenericComposition(t, upstream)
Expand Down
114 changes: 101 additions & 13 deletions internal/resource/fieldmanager.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"context"
"fmt"
"slices"
"strings"

"github.com/go-logr/logr"
"k8s.io/apimachinery/pkg/api/equality"
Expand All @@ -17,6 +18,26 @@ const (
enoManager = "eno"
)

// parseFieldPaths converts a slice of dot-separated field path strings into fieldpath.Path objects.
// For example: "metadata.labels" -> fieldpath.MakePathOrDie("metadata", "labels")
func parseFieldPaths(fields []string) []fieldpath.Path {
paths := make([]fieldpath.Path, 0, len(fields))
for _, field := range fields {
if field == "" {
continue
}
// Split on dots to handle nested paths like "metadata.labels"
parts := strings.Split(field, ".")
// Convert string parts to interface{} for MakePathOrDie
pathParts := make([]interface{}, len(parts))
for i, part := range parts {
pathParts[i] = part
}
paths = append(paths, fieldpath.MakePathOrDie(pathParts...))
}
return paths
}

// MergeEnoManagedFields corrects managed fields drift to ensure Eno can remove fields
// that are no longer set by the synthesizer, even when another client corrupts the
// managed fields metadata. Returns corrected managed fields, affected field paths,
Expand Down Expand Up @@ -135,19 +156,22 @@ func compareEnoManagedFields(a, b []metav1.ManagedFieldsEntry) bool {
return equality.Semantic.DeepEqual(a[ai].FieldsV1, b[ab].FieldsV1)
}

func NormalizeConflictingManagers(ctx context.Context, current *unstructured.Unstructured, migratingManagers []string) (modified bool, err error) {
func NormalizeConflictingManagers(ctx context.Context, current *unstructured.Unstructured, migratingManagers []string, migratingFields []string) (modified bool, err error) {
managedFields := current.GetManagedFields()
logger := logr.FromContextOrDiscard(ctx)
logger.Info("NormalizingConflictingManager", "Name", current.GetName(), "Namespace", current.GetNamespace())
if len(managedFields) == 0 {
return false, nil
}

// Parse the allowed field paths from the configuration
allowedPrefixes := parseFieldPaths(migratingFields)

// Build the unique list of managers to migrate from user-provided migratingManagers
uniqueMigratingManagers := buildUniqueManagersList(migratingManagers)

// Check if normalization is needed
hasLegacyManager, enoEntryCount, err := analyzeManagerConflicts(managedFields, uniqueMigratingManagers)
hasLegacyManager, enoEntryCount, err := analyzeManagerConflicts(managedFields, uniqueMigratingManagers, allowedPrefixes)
if err != nil {
return false, err
}
Expand All @@ -157,7 +181,7 @@ func NormalizeConflictingManagers(ctx context.Context, current *unstructured.Uns
}

// Merge all eno entries first to get the combined fieldset
mergedEnoSet, mergedEnoTime := mergeEnoEntries(managedFields)
mergedEnoSet, mergedEnoTime := mergeEnoEntries(managedFields, allowedPrefixes)

// Build new managedFields list, merging legacy managers into eno and excluding original eno entries
newManagedFields := make([]metav1.ManagedFieldsEntry, 0, len(managedFields))
Expand Down Expand Up @@ -189,12 +213,28 @@ func NormalizeConflictingManagers(ctx context.Context, current *unstructured.Uns
logger.Info("NormalizeConflictingManagers found migrating managers", "manager", entry.Manager,
"resoruceName", current.GetName(), "resourceNamespace", current.GetNamespace())
// Check if this is a legacy manager that should be migrated to eno
// Merge legacy manager's fields into the eno fieldset instead of creating a separate entry
// Separate allowed fields (to migrate) from excluded fields (to keep with legacy manager)
if mergedEnoSet == nil {
mergedEnoSet = &fieldpath.Set{}
}
if set := parseFieldsEntry(*entry); set != nil {
mergedEnoSet = mergedEnoSet.Union(set)
// Filter to only include allowed field paths for migration
allowedFields := filterAllowedFieldPaths(set, allowedPrefixes)
if !allowedFields.Empty() {
mergedEnoSet = mergedEnoSet.Union(allowedFields)
}

// Keep the legacy manager entry if it has excluded fields
excludedFields := set.Difference(allowedFields)
if !excludedFields.Empty() {
// Create a new entry with only the excluded fields
js, err := excludedFields.ToJSON()
if err == nil {
entryCopy := *entry
entryCopy.FieldsV1 = &metav1.FieldsV1{Raw: js}
newManagedFields = append(newManagedFields, entryCopy)
}
}
}
// Update the timestamp to the most recent
if mergedEnoTime == nil || (entry.Time != nil && entry.Time.After(mergedEnoTime.Time)) {
Expand All @@ -203,13 +243,14 @@ func NormalizeConflictingManagers(ctx context.Context, current *unstructured.Uns
modified = true
}

// Add the merged eno entry if we found any eno entries
// Add the merged eno entry if we found any eno entries OR migrated any legacy manager fields
if mergedEnoSet != nil && !mergedEnoSet.Empty() {
mergedEntry, err := createMergedEnoEntry(mergedEnoSet, mergedEnoTime, managedFields)
if err != nil {
return false, err
}
newManagedFields = append(newManagedFields, mergedEntry)
modified = true // Ensure modified is true when we create/update the eno entry
}

if modified {
Expand All @@ -234,9 +275,11 @@ func buildUniqueManagersList(migratingManagers []string) map[string]bool {
return unique
}

// analyzeManagerConflicts checks if there are legacy managers present
// and counts the number of eno entries
func analyzeManagerConflicts(managedFields []metav1.ManagedFieldsEntry, uniqueMigratingManagers map[string]bool) (hasLegacyManager bool, enoEntryCount int, err error) {
// analyzeManagerConflicts checks if there are legacy managers present that own allowed fields
// and counts the number of eno entries. Only returns hasLegacyManager=true if a legacy manager
// owns at least one field from the allowed list (spec, data, etc.), preventing infinite loops
// when legacy managers only own excluded fields (status, finalizers, etc.).
func analyzeManagerConflicts(managedFields []metav1.ManagedFieldsEntry, uniqueMigratingManagers map[string]bool, allowedPrefixes []fieldpath.Path) (hasLegacyManager bool, enoEntryCount int, err error) {
for i := range managedFields {
entry := &managedFields[i]

Expand All @@ -247,16 +290,22 @@ func analyzeManagerConflicts(managedFields []metav1.ManagedFieldsEntry, uniqueMi

// Check if this is a legacy manager we need to normalize
if uniqueMigratingManagers[entry.Manager] {
hasLegacyManager = true
// Only consider it a legacy manager needing migration if it owns at least one allowed field
if set := parseFieldsEntry(*entry); set != nil {
allowedFields := filterAllowedFieldPaths(set, allowedPrefixes)
if !allowedFields.Empty() {
hasLegacyManager = true
}
}
}
}

return hasLegacyManager, enoEntryCount, nil
}

// mergeEnoEntries merges all eno Apply entries into a single fieldpath.Set
// and tracks the most recent timestamp
func mergeEnoEntries(managedFields []metav1.ManagedFieldsEntry) (*fieldpath.Set, *metav1.Time) {
// and tracks the most recent timestamp. Filters the result to only include allowed field paths.
func mergeEnoEntries(managedFields []metav1.ManagedFieldsEntry, allowedPrefixes []fieldpath.Path) (*fieldpath.Set, *metav1.Time) {
var mergedSet *fieldpath.Set
var latestTime *metav1.Time

Expand All @@ -268,7 +317,11 @@ func mergeEnoEntries(managedFields []metav1.ManagedFieldsEntry) (*fieldpath.Set,
mergedSet = &fieldpath.Set{}
}
if set := parseFieldsEntry(*entry); set != nil {
mergedSet = mergedSet.Union(set)
// Filter to only include allowed field paths
filteredSet := filterAllowedFieldPaths(set, allowedPrefixes)
if !filteredSet.Empty() {
mergedSet = mergedSet.Union(filteredSet)
}
}
if latestTime == nil || (entry.Time != nil && entry.Time.After(latestTime.Time)) {
latestTime = entry.Time
Expand Down Expand Up @@ -309,3 +362,38 @@ func createMergedEnoEntry(mergedSet *fieldpath.Set, timestamp *metav1.Time, mana
FieldsV1: &metav1.FieldsV1{Raw: js},
}, nil
}

// filterAllowedFieldPaths filters a fieldpath.Set to only include paths that are safe to migrate.
// The allowedPrefixes parameter specifies which field paths should be migrated.
// Common examples: spec.*, data.*, stringData.*, binaryData.*, metadata.labels.*, metadata.annotations.*
// Excluded paths: metadata.finalizers, metadata.deletionTimestamp, metadata.ownerReferences, status.*, and other metadata fields
func filterAllowedFieldPaths(set *fieldpath.Set, allowedPrefixes []fieldpath.Path) *fieldpath.Set {
if set == nil || set.Empty() {
return &fieldpath.Set{}
}

filtered := &fieldpath.Set{}
set.Iterate(func(path fieldpath.Path) {
for _, prefix := range allowedPrefixes {
if hasPrefix(path, prefix) {
filtered.Insert(path)
break
}
}
})

return filtered
}

// hasPrefix checks if a path starts with the given prefix
func hasPrefix(path, prefix fieldpath.Path) bool {
if len(path) < len(prefix) {
return false
}
for i, elem := range prefix {
if !path[i].Equals(elem) {
return false
}
}
return true
}
Loading
Loading