diff --git a/ssa/go.mod b/ssa/go.mod index 066c61a2d..8ef15b2c8 100644 --- a/ssa/go.mod +++ b/ssa/go.mod @@ -5,6 +5,7 @@ go 1.25.0 require ( github.com/evanphx/json-patch/v5 v5.9.11 github.com/fluxcd/cli-utils v0.37.2-flux.1 + github.com/go-openapi/jsonpointer v0.21.1 github.com/google/go-cmp v0.7.0 github.com/onsi/gomega v1.39.0 github.com/wI2L/jsondiff v0.6.1 @@ -34,7 +35,6 @@ require ( github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/go-errors/errors v1.5.1 // indirect github.com/go-logr/logr v1.4.3 // indirect - github.com/go-openapi/jsonpointer v0.21.1 // indirect github.com/go-openapi/jsonreference v0.21.0 // indirect github.com/go-openapi/swag v0.23.1 // indirect github.com/google/btree v1.1.3 // indirect diff --git a/ssa/jsondiff/patch.go b/ssa/jsondiff/patch.go index 812fac09f..6d6a67626 100644 --- a/ssa/jsondiff/patch.go +++ b/ssa/jsondiff/patch.go @@ -38,6 +38,26 @@ func GenerateRemovePatch(paths ...string) jsondiff.Patch { return patch } +// ReplaceOp describes a single JSON Patch replace operation. +type ReplaceOp struct { + Path string + Value any +} + +// GenerateReplacePatch generates a JSON patch that replaces the values at the +// given JSON pointer paths. +func GenerateReplacePatch(ops ...ReplaceOp) jsondiff.Patch { + var patch jsondiff.Patch + for _, op := range ops { + patch = append(patch, jsondiff.Operation{ + Type: jsondiff.OperationReplace, + Path: op.Path, + Value: op.Value, + }) + } + return patch +} + // ApplyPatchToUnstructured applies the given JSON patch to the given // unstructured object. The patch is applied in-place. // It permits the patch to contain "remove" operations that target non-existing diff --git a/ssa/jsondiff/unstructured.go b/ssa/jsondiff/unstructured.go index 400c15ec2..7d7f45878 100644 --- a/ssa/jsondiff/unstructured.go +++ b/ssa/jsondiff/unstructured.go @@ -44,6 +44,23 @@ type IgnoreRule struct { Selector *Selector } +// CompiledIgnoreRules is a set of IgnoreRule with compiled selectors. +type CompiledIgnoreRules map[*SelectorRegex][]string + +// CompileIgnoreRules compiles the selectors in the given IgnoreRule slice +// and returns a CompiledIgnoreRules. +func CompileIgnoreRules(rules []IgnoreRule) (CompiledIgnoreRules, error) { + compiled := make(CompiledIgnoreRules, len(rules)) + for _, rule := range rules { + sr, err := NewSelectorRegex(rule.Selector) + if err != nil { + return nil, fmt.Errorf("failed to create ignore rule selector: %w", err) + } + compiled[sr] = rule.Paths + } + return compiled, nil +} + // UnstructuredList runs a dry-run patch for a list of Kubernetes resources // against a Kubernetes cluster and compares the result against the original // objects. It returns a DiffSet, which contains differences between the @@ -59,13 +76,9 @@ func UnstructuredList(ctx context.Context, c client.Client, objs []*unstructured o := &ListOptions{} o.ApplyOptions(opts) - var sm = make(map[*SelectorRegex][]string, len(o.IgnoreRules)) - for _, ips := range o.IgnoreRules { - sr, err := NewSelectorRegex(ips.Selector) - if err != nil { - return nil, fmt.Errorf("failed to create ignore rule selector: %w", err) - } - sm[sr] = ips.Paths + sm, err := CompileIgnoreRules(o.IgnoreRules) + if err != nil { + return nil, err } var resOpts []ResourceOption diff --git a/ssa/manager_apply.go b/ssa/manager_apply.go index c42d26e42..fb6a76030 100644 --- a/ssa/manager_apply.go +++ b/ssa/manager_apply.go @@ -22,9 +22,13 @@ import ( "encoding/json" "fmt" "sort" + "strconv" + "strings" "time" + "github.com/go-openapi/jsonpointer" "golang.org/x/sync/errgroup" + apiequality "k8s.io/apimachinery/pkg/api/equality" "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" @@ -34,6 +38,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" ssaerrors "github.com/fluxcd/pkg/ssa/errors" + "github.com/fluxcd/pkg/ssa/jsondiff" "github.com/fluxcd/pkg/ssa/utils" ) @@ -78,6 +83,12 @@ type ApplyOptions struct { // tagged with the old API version causes the API server to fail the // apply with "field not declared in schema" for the new defaulted field. MigrateAPIVersion bool `json:"migrateAPIVersion,omitempty"` + + // DriftIgnoreRules defines a list of JSON pointer ignore rules that are used to + // remove specific fields from objects before applying them. + // This is useful for ignoring fields that are managed by other controllers + // (e.g. VPA, HPA) and would otherwise cause drift. + DriftIgnoreRules []jsondiff.IgnoreRule `json:"driftIgnoreRules,omitempty"` } // ApplyCleanupOptions defines which metadata entries are to be removed before applying objects. @@ -147,12 +158,38 @@ func (m *ResourceManager) Apply(ctx context.Context, object *unstructured.Unstru } patched = patched || patchedCleanupMetadata - // do not apply objects that have not drifted to avoid bumping the resource version - if !patched && !m.hasDrifted(existingObject, dryRunObject) { + // Compile ignore rules once for both drift detection and conditional field stripping. + var compiled jsondiff.CompiledIgnoreRules + if existingObject.GetResourceVersion() != "" && len(opts.DriftIgnoreRules) > 0 { + compiled, err = jsondiff.CompileIgnoreRules(opts.DriftIgnoreRules) + if err != nil { + return nil, err + } + } + + // Do not apply objects that have not drifted to avoid bumping the resource version. + // Ignored fields are excluded from the comparison so that differences in fields + // managed by other controllers (e.g. VPA, HPA) do not trigger unnecessary applies. + drifted, err := m.hasDriftedWithIgnore(existingObject, dryRunObject, compiled) + if err != nil { + return nil, err + } + if !patched && !drifted { return m.changeSetEntry(object, UnchangedAction), nil } appliedObject := object.DeepCopy() + + // For each drifted ignored field, either strip it from the payload (when + // another Apply manager owns it) or adopt the in-cluster value (when Flux + // is the sole owner) to avoid API errors and silent value corruption. + if compiled != nil { + dr := computeDriftedPaths(existingObject, dryRunObject, compiled, m.owner.Field) + if err := applyDriftResult(appliedObject, dr); err != nil { + return nil, err + } + } + if err := m.apply(ctx, appliedObject); err != nil { return nil, fmt.Errorf("%s apply failed: %w", utils.FmtUnstructured(appliedObject), err) } @@ -174,6 +211,17 @@ func (m *ResourceManager) ApplyAll(ctx context.Context, objects []*unstructured. // is an object to apply toApply := make([]*unstructured.Unstructured, len(objects)) changes := make([]ChangeSetEntry, len(objects)) + driftResults := make([]driftResult, len(objects)) + + // Compile ignore rules once for drift detection and conditional field stripping. + var compiled jsondiff.CompiledIgnoreRules + if len(opts.DriftIgnoreRules) > 0 { + var err error + compiled, err = jsondiff.CompileIgnoreRules(opts.DriftIgnoreRules) + if err != nil { + return nil, err + } + } { g, ctx := errgroup.WithContext(ctx) @@ -243,8 +291,16 @@ func (m *ResourceManager) ApplyAll(ctx context.Context, objects []*unstructured. } patched = patched || patchedCleanupMetadata - if patched || m.hasDrifted(existingObject, dryRunObject) { + drifted, err := m.hasDriftedWithIgnore(existingObject, dryRunObject, compiled) + if err != nil { + return err + } + if patched || drifted { toApply[i] = object + // Compute drifted paths while existingObject and dryRunObject are available. + if compiled != nil && existingObject.GetResourceVersion() != "" { + driftResults[i] = computeDriftedPaths(existingObject, dryRunObject, compiled, m.owner.Field) + } if dryRunObject.GetResourceVersion() == "" { changes[i] = *m.changeSetEntry(dryRunObject, CreatedAction) } else { @@ -262,9 +318,14 @@ func (m *ResourceManager) ApplyAll(ctx context.Context, objects []*unstructured. } } - for _, object := range toApply { + for i, object := range toApply { if object != nil { appliedObject := object.DeepCopy() + if changes[i].Action != CreatedAction && len(driftResults[i].entries) > 0 { + if err := applyDriftResult(appliedObject, driftResults[i]); err != nil { + return nil, err + } + } if err := m.apply(ctx, appliedObject); err != nil { return nil, fmt.Errorf("%s apply failed: %w", utils.FmtUnstructured(appliedObject), err) } @@ -409,7 +470,9 @@ func (m *ResourceManager) migrateAPIVersion(ctx context.Context, return true, nil } -// cleanupMetadata performs an HTTP PATCH request to remove entries from metadata annotations, labels and managedFields. +// cleanupMetadata performs an HTTP PATCH request to remove entries +// from metadata annotations, labels and managedFields. It updates +// object in-place with the server's response. func (m *ResourceManager) cleanupMetadata(ctx context.Context, desiredObject *unstructured.Unstructured, object *unstructured.Unstructured, @@ -421,19 +484,18 @@ func (m *ResourceManager) cleanupMetadata(ctx context.Context, if object == nil { return false, nil } - existingObject := object.DeepCopy() var patches []JSONPatch if len(opts.Annotations) > 0 { - patches = append(patches, PatchRemoveAnnotations(existingObject, opts.Annotations)...) + patches = append(patches, PatchRemoveAnnotations(object, opts.Annotations)...) } if len(opts.Labels) > 0 { - patches = append(patches, PatchRemoveLabels(existingObject, opts.Labels)...) + patches = append(patches, PatchRemoveLabels(object, opts.Labels)...) } if len(opts.FieldManagers) > 0 { - managedFieldPatch, err := PatchReplaceFieldsManagers(existingObject, opts.FieldManagers, m.owner.Field) + managedFieldPatch, err := PatchReplaceFieldsManagers(object, opts.FieldManagers, m.owner.Field) if err != nil { return false, err } @@ -451,7 +513,7 @@ func (m *ResourceManager) cleanupMetadata(ctx context.Context, } patch := client.RawPatch(types.JSONPatchType, rawPatch) - return true, m.client.Patch(ctx, existingObject, patch, client.FieldOwner(m.owner.Field)) + return true, m.client.Patch(ctx, object, patch, client.FieldOwner(m.owner.Field)) } // shouldForceApply determines based on the apply error and ApplyOptions if the object should be recreated. @@ -497,3 +559,383 @@ func (m *ResourceManager) shouldSkipApply(desiredObject *unstructured.Unstructur return false, nil } + +// removeIgnoredFields removes the fields matched by the given pre-compiled +// ignore rules from obj. Selectors are evaluated against matchObj so that +// existing and dry-run copies are stripped based on the same decision. +func removeIgnoredFields(matchObj, obj *unstructured.Unstructured, rules jsondiff.CompiledIgnoreRules) error { + var ignorePaths jsondiff.IgnorePaths + for sr, paths := range rules { + if sr.MatchUnstructured(matchObj) { + ignorePaths = append(ignorePaths, paths...) + } + } + + if len(ignorePaths) > 0 { + patch := jsondiff.GenerateRemovePatch(ignorePaths...) + if err := jsondiff.ApplyPatchToUnstructured(obj, patch); err != nil { + return err + } + } + + return nil +} + +// lookupJSONPointer resolves an RFC 6901 JSON pointer against the unstructured +// object's content. A missing path is reported as (nil, false, nil). +func lookupJSONPointer(obj *unstructured.Unstructured, pointer string) (any, bool, error) { + ptr, err := jsonpointer.New(pointer) + if err != nil { + return nil, false, err + } + val, _, err := ptr.Get(obj.Object) + if err != nil { + // jsonpointer returns an error when any segment of the pointer cannot + // be resolved; treat that as "not present" rather than a hard failure. + return nil, false, nil + } + return val, true, nil +} + +// driftAction describes what to do with a drifted ignored path. +type driftAction int + +const ( + // driftStrip removes the field from the payload. Used when another + // Apply manager owns the field. + driftStrip driftAction = iota + // driftAdopt replaces the field in the payload with the in-cluster + // value. Used when Flux is the sole owner. + driftAdopt +) + +// driftEntry holds the resolution for a single drifted ignored path. +type driftEntry struct { + path string + action driftAction + value any // in-cluster value; set only when action == driftAdopt +} + +// driftResult holds all drift resolutions for a single object. +type driftResult struct { + entries []driftEntry +} + +// applyDriftResult applies strip and adopt operations to the payload. +func applyDriftResult(target *unstructured.Unstructured, dr driftResult) error { + var stripPaths jsondiff.IgnorePaths + var adoptOps []jsondiff.ReplaceOp + for _, e := range dr.entries { + switch e.action { + case driftStrip: + stripPaths = append(stripPaths, e.path) + case driftAdopt: + adoptOps = append(adoptOps, jsondiff.ReplaceOp{ + Path: e.path, + Value: e.value, + }) + } + } + if len(stripPaths) > 0 { + patch := jsondiff.GenerateRemovePatch(stripPaths...) + if err := jsondiff.ApplyPatchToUnstructured(target, patch); err != nil { + return err + } + } + if len(adoptOps) > 0 { + patch := jsondiff.GenerateReplacePatch(adoptOps...) + if err := jsondiff.ApplyPatchToUnstructured(target, patch); err != nil { + return err + } + } + return nil +} + +// computeDriftedPaths returns the drift resolution for each ignored path whose +// values differ between existingObject and dryRunObject. For paths owned by +// another Apply manager, the action is driftStrip (remove from payload). For +// paths where Flux is the sole owner, the action is driftAdopt (copy in-cluster +// value into the payload). +func computeDriftedPaths( + existingObject, dryRunObject *unstructured.Unstructured, + rules jsondiff.CompiledIgnoreRules, + fluxManager string, +) driftResult { + var result driftResult + parsed := parseManagedFieldsApply(existingObject) + + for sr, paths := range rules { + if sr.MatchUnstructured(dryRunObject) { + for _, path := range paths { + existingVal, ef, eerr := lookupJSONPointer(existingObject, path) + dryRunVal, df, derr := lookupJSONPointer(dryRunObject, path) + if eerr != nil || derr != nil || ef != df || + !apiequality.Semantic.DeepEqual(existingVal, dryRunVal) { + if isFieldOwnedByOtherApplyManager(existingObject, path, fluxManager, parsed) { + result.entries = append(result.entries, driftEntry{ + path: path, + action: driftStrip, + }) + } else { + result.entries = append(result.entries, driftEntry{ + path: path, + action: driftAdopt, + value: existingVal, + }) + } + } + } + } + } + return result +} + +// parsedManagedField holds a pre-parsed Apply managed fields entry. +type parsedManagedField struct { + manager string + fields map[string]any +} + +// parseManagedFieldsApply returns all non-subresource Apply entries with their +// fieldsV1 pre-parsed into maps. Entries with missing or corrupt fieldsV1 are +// skipped. +func parseManagedFieldsApply(obj *unstructured.Unstructured) []parsedManagedField { + var result []parsedManagedField + for _, mf := range obj.GetManagedFields() { + if mf.Subresource != "" || mf.Operation != metav1.ManagedFieldsOperationApply { + continue + } + if mf.FieldsV1 == nil || len(mf.FieldsV1.Raw) == 0 { + continue + } + var fields map[string]any + if err := json.Unmarshal(mf.FieldsV1.Raw, &fields); err != nil { + continue + } + result = append(result, parsedManagedField{ + manager: mf.Manager, + fields: fields, + }) + } + return result +} + +// isFieldOwnedByOtherApplyManager checks whether any Apply field manager other +// than fluxManager owns the given JSON pointer path on the object. +func isFieldOwnedByOtherApplyManager( + obj *unstructured.Unstructured, + pointer string, + fluxManager string, + parsed []parsedManagedField, +) bool { + segments, err := pointerToFieldsV1Segments(obj, pointer, parsed) + if err != nil { + // Cannot resolve path in fieldsV1 — treat as sole owner (adopt). + return false + } + for _, entry := range parsed { + if entry.manager == fluxManager { + continue + } + if fieldsV1Contains(entry.fields, segments) { + return true + } + } + return false +} + +// pointerToFieldsV1Segments converts a JSON pointer (e.g., "/spec/replicas") +// into fieldsV1 traversal segments (e.g., ["f:spec", "f:replicas"]). +// For array-indexed segments, it resolves the k:{...} key by matching the item +// at the given index against k: entries in the parsed managed fields. +func pointerToFieldsV1Segments( + obj *unstructured.Unstructured, + pointer string, + parsed []parsedManagedField, +) ([]string, error) { + raw := strings.TrimPrefix(pointer, "/") + if raw == "" { + return nil, fmt.Errorf("empty pointer") + } + + // Split and unescape RFC 6901 segments. + rawSegments := strings.Split(raw, "/") + for i, s := range rawSegments { + s = strings.ReplaceAll(s, "~1", "/") + s = strings.ReplaceAll(s, "~0", "~") + rawSegments[i] = s + } + + var segments []string + // objPath tracks the raw field names for lookups into obj.Object. + var objPath []string + + for i, seg := range rawSegments { + idx, err := strconv.Atoi(seg) + if err != nil { + // Regular field — prefix with f: + segments = append(segments, "f:"+seg) + objPath = append(objPath, seg) + continue + } + + // Numeric index — resolve k:{...} from fieldsV1. + kKey, err := resolveArrayMergeKey(obj, objPath, idx, segments, parsed) + if err != nil { + return nil, fmt.Errorf("cannot resolve array key for %s[%d]: %w", + strings.Join(rawSegments[:i], "/"), idx, err) + } + segments = append(segments, kKey) + // Advance objPath with the numeric index for subsequent lookups. + objPath = append(objPath, seg) + } + return segments, nil +} + +// resolveArrayMergeKey finds the k:{...} fieldsV1 key that matches the array +// item at the given index. It inspects k: entries from the parsed managed +// fields at the given parent path, then matches each against the item's fields +// in the actual object. +func resolveArrayMergeKey( + obj *unstructured.Unstructured, + objPath []string, + index int, + fieldsV1ParentSegments []string, + parsed []parsedManagedField, +) (string, error) { + // Get the array and item from the actual object. + arr, found, _ := unstructured.NestedSlice(obj.Object, objPath...) + if !found || index >= len(arr) { + return "", fmt.Errorf("array not found or index %d out of bounds", index) + } + item, ok := arr[index].(map[string]any) + if !ok { + return "", fmt.Errorf("array item at index %d is not a map", index) + } + + // Collect all k:{...} keys from every managed fields entry at the parent. + kKeys := collectKKeys(fieldsV1ParentSegments, parsed) + if len(kKeys) == 0 { + return "", fmt.Errorf("no k: entries found at parent path") + } + + // Find the k:{...} key whose key-value pairs all match the item. + for _, kKey := range kKeys { + kv, err := parseKKey(kKey) + if err != nil { + continue + } + if matchesKKey(item, kv) { + return kKey, nil + } + } + return "", fmt.Errorf("no matching k: entry for item at index %d", index) +} + +// collectKKeys walks all parsed managed field entries to the given parent +// segments and returns all unique k:{...} keys found at that level. +func collectKKeys(parentSegments []string, parsed []parsedManagedField) []string { + seen := map[string]struct{}{} + for _, entry := range parsed { + node := walkFieldsV1(entry.fields, parentSegments) + if node == nil { + continue + } + for key := range node { + if strings.HasPrefix(key, "k:") { + if _, ok := seen[key]; !ok { + seen[key] = struct{}{} + } + } + } + } + keys := make([]string, 0, len(seen)) + for k := range seen { + keys = append(keys, k) + } + return keys +} + +// parseKKey parses a k:{...} fieldsV1 key into its key-value pairs. +func parseKKey(kKey string) (map[string]any, error) { + var kv map[string]any + if err := json.Unmarshal([]byte(strings.TrimPrefix(kKey, "k:")), &kv); err != nil { + return nil, err + } + return kv, nil +} + +// matchesKKey checks if all key-value pairs in kv exist with equal values in item. +func matchesKKey(item, kv map[string]any) bool { + for k, v := range kv { + if !apiequality.Semantic.DeepEqual(item[k], v) { + return false + } + } + return true +} + +// walkFieldsV1 traverses a parsed fieldsV1 map following the given segments. +// Returns the node at the end of the path, or nil if any segment is missing. +func walkFieldsV1(fields map[string]any, segments []string) map[string]any { + current := fields + for _, seg := range segments { + child, ok := current[seg] + if !ok { + return nil + } + childMap, ok := child.(map[string]any) + if !ok { + return nil + } + current = childMap + } + return current +} + +// fieldsV1Contains walks a parsed fieldsV1 map to check if the given segments +// path exists. +func fieldsV1Contains(fields map[string]any, segments []string) bool { + if len(segments) == 0 { + return false + } + current := fields + for i, seg := range segments { + child, ok := current[seg] + if !ok { + return false + } + // Last segment: existence is enough (leaf may be empty map). + if i == len(segments)-1 { + return true + } + childMap, ok := child.(map[string]any) + if !ok { + return false + } + current = childMap + } + return true +} + +// hasDriftedWithIgnore is like hasDrifted but strips ignored fields from deep +// copies before comparing. If compiled is nil, it falls back to hasDrifted. +// Selector matching is done against dryRunObject so both copies are stripped +// on the same decision, matching computeDriftedPaths. +func (m *ResourceManager) hasDriftedWithIgnore( + existingObject, dryRunObject *unstructured.Unstructured, + compiled jsondiff.CompiledIgnoreRules, +) (bool, error) { + if compiled == nil { + return m.hasDrifted(existingObject, dryRunObject), nil + } + existingCopy := existingObject.DeepCopy() + dryRunCopy := dryRunObject.DeepCopy() + if err := removeIgnoredFields(dryRunObject, existingCopy, compiled); err != nil { + return false, err + } + if err := removeIgnoredFields(dryRunObject, dryRunCopy, compiled); err != nil { + return false, err + } + return m.hasDrifted(existingCopy, dryRunCopy), nil +} diff --git a/ssa/manager_apply_ignore_test.go b/ssa/manager_apply_ignore_test.go new file mode 100644 index 000000000..af971724d --- /dev/null +++ b/ssa/manager_apply_ignore_test.go @@ -0,0 +1,3000 @@ +/* +Copyright 2026 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package ssa + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/fluxcd/pkg/ssa/jsondiff" + "github.com/fluxcd/pkg/ssa/normalize" +) + +func TestApply_DriftIgnoreRules_OptionalFields(t *testing.T) { + timeout := 30 * time.Second + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + id := generateName("drift-opt") + objects, err := readManifest("testdata/test2.yaml", id) + if err != nil { + t.Fatal(err) + } + + manager.SetOwnerLabels(objects, "app1", "default") + + if err := normalize.UnstructuredList(objects); err != nil { + t.Fatal(err) + } + + _, deployObject := getFirstObject(objects, "Deployment", id) + + // Define ignore rules for two optional mutable fields upfront. + opts := DefaultApplyOptions() + opts.DriftIgnoreRules = []jsondiff.IgnoreRule{ + { + Paths: []string{"/spec/replicas"}, + Selector: &jsondiff.Selector{ + Kind: "Deployment", + }, + }, + { + Paths: []string{"/spec/template/metadata/annotations"}, + Selector: &jsondiff.Selector{ + Kind: "Deployment", + }, + }, + } + + t.Run("creates objects with ignore rules present", func(t *testing.T) { + // Set replicas so it's explicit in the desired state. + err := unstructured.SetNestedField(deployObject.Object, int64(2), "spec", "replicas") + if err != nil { + t.Fatal(err) + } + + changeSet, err := manager.ApplyAllStaged(ctx, objects, opts) + if err != nil { + t.Fatal(err) + } + for _, entry := range changeSet.Entries { + if diff := cmp.Diff(CreatedAction, entry.Action); diff != "" { + t.Errorf("Mismatch from expected value (-want +got):\n%s", diff) + } + } + + // On create, ignore rules are skipped, so Flux should own all fields + // including replicas and annotations. + existing := deployObject.DeepCopy() + if err := manager.client.Get(ctx, client.ObjectKeyFromObject(existing), existing); err != nil { + t.Fatal(err) + } + replicas, found, _ := unstructured.NestedInt64(existing.Object, "spec", "replicas") + if !found || replicas != 2 { + t.Fatalf("expected spec.replicas=2 after create, got %d (found=%v)", replicas, found) + } + + // Verify Flux is the field manager and owns both replicas and annotations. + fluxFound := false + for _, entry := range existing.GetManagedFields() { + if entry.Manager == manager.owner.Field && entry.Operation == metav1.ManagedFieldsOperationApply { + fluxFound = true + if entry.FieldsV1 != nil { + fieldsJSON := string(entry.FieldsV1.Raw) + if !strings.Contains(fieldsJSON, "f:replicas") { + t.Errorf("expected Flux to own spec.replicas after create, but it does not") + } + if !strings.Contains(fieldsJSON, "f:prometheus.io/scrape") { + t.Errorf("expected Flux to own template annotations after create, but it does not") + } + } + } + } + if !fluxFound { + t.Errorf("expected to find field manager %q with Apply operation", manager.owner.Field) + } + }) + + t.Run("other controllers claim ignored fields", func(t *testing.T) { + // VPA controller claims spec.replicas via ForceOwnership. + vpaObj := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": map[string]interface{}{ + "name": id, + "namespace": id, + }, + "spec": map[string]interface{}{ + "replicas": int64(5), + "selector": map[string]interface{}{ + "matchLabels": map[string]interface{}{ + "app": id, + }, + }, + "template": map[string]interface{}{ + "metadata": map[string]interface{}{ + "labels": map[string]interface{}{ + "app": id, + }, + }, + "spec": map[string]interface{}{ + "containers": []interface{}{ + map[string]interface{}{ + "name": "podinfod", + "image": "ghcr.io/stefanprodan/podinfo:6.0.0", + }, + }, + }, + }, + }, + }, + } + err := manager.client.Patch(ctx, vpaObj, client.Apply, + client.FieldOwner("vpa-controller"), client.ForceOwnership) + if err != nil { + t.Fatal(err) + } + + // Monitoring controller claims template annotations via ForceOwnership + // with different values to introduce drift in the ignored field. + monObj := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": map[string]interface{}{ + "name": id, + "namespace": id, + }, + "spec": map[string]interface{}{ + "selector": map[string]interface{}{ + "matchLabels": map[string]interface{}{ + "app": id, + }, + }, + "template": map[string]interface{}{ + "metadata": map[string]interface{}{ + "annotations": map[string]interface{}{ + "prometheus.io/scrape": "false", + "prometheus.io/port": "8080", + }, + "labels": map[string]interface{}{ + "app": id, + }, + }, + "spec": map[string]interface{}{ + "containers": []interface{}{ + map[string]interface{}{ + "name": "podinfod", + "image": "ghcr.io/stefanprodan/podinfo:6.0.0", + }, + }, + }, + }, + }, + }, + } + err = manager.client.Patch(ctx, monObj, client.Apply, + client.FieldOwner("monitoring-controller"), client.ForceOwnership) + if err != nil { + t.Fatal(err) + } + + // Verify the other controllers' values are in-cluster. + existing := deployObject.DeepCopy() + if err := manager.client.Get(ctx, client.ObjectKeyFromObject(existing), existing); err != nil { + t.Fatal(err) + } + replicas, _, _ := unstructured.NestedInt64(existing.Object, "spec", "replicas") + if replicas != 5 { + t.Fatalf("expected spec.replicas=5 after VPA claim, got %d", replicas) + } + + // Verify field ownership transferred to the third-party controllers. + vpaOwnsReplicas := false + monOwnsAnnotations := false + for _, entry := range existing.GetManagedFields() { + if entry.Manager == "vpa-controller" && entry.Operation == metav1.ManagedFieldsOperationApply { + if entry.FieldsV1 != nil { + fieldsJSON := string(entry.FieldsV1.Raw) + if strings.Contains(fieldsJSON, "f:replicas") { + vpaOwnsReplicas = true + } + } + } + if entry.Manager == "monitoring-controller" && entry.Operation == metav1.ManagedFieldsOperationApply { + if entry.FieldsV1 != nil { + fieldsJSON := string(entry.FieldsV1.Raw) + if strings.Contains(fieldsJSON, "f:prometheus.io/scrape") { + monOwnsAnnotations = true + } + } + } + } + if !vpaOwnsReplicas { + t.Errorf("expected vpa-controller to own spec.replicas after ForceOwnership claim") + } + if !monOwnsAnnotations { + t.Errorf("expected monitoring-controller to own template annotations after ForceOwnership claim") + } + }) + + t.Run("flux apply releases ownership of ignored fields", func(t *testing.T) { + // Trigger drift by changing a non-ignored field. + err := unstructured.SetNestedField(deployObject.Object, int64(10), "spec", "minReadySeconds") + if err != nil { + t.Fatal(err) + } + + entry, err := manager.Apply(ctx, deployObject, opts) + if err != nil { + t.Fatal(err) + } + + if entry.Action != ConfiguredAction { + t.Errorf("expected ConfiguredAction, got %s", entry.Action) + } + + existing := deployObject.DeepCopy() + if err := manager.client.Get(ctx, client.ObjectKeyFromObject(existing), existing); err != nil { + t.Fatal(err) + } + + // VPA's replicas value should be preserved. + replicas, found, _ := unstructured.NestedInt64(existing.Object, "spec", "replicas") + if !found || replicas != 5 { + t.Errorf("expected spec.replicas=5 (VPA value preserved), got %d", replicas) + } + + // Verify Flux no longer owns the ignored fields. + for _, mf := range existing.GetManagedFields() { + if mf.Manager == manager.owner.Field && mf.Operation == metav1.ManagedFieldsOperationApply { + if mf.FieldsV1 != nil { + fieldsJSON := string(mf.FieldsV1.Raw) + if strings.Contains(fieldsJSON, "f:replicas") { + t.Errorf("expected Flux to no longer own spec.replicas") + } + if strings.Contains(fieldsJSON, "f:prometheus.io/scrape") { + t.Errorf("expected Flux to no longer own prometheus.io/scrape") + } + if strings.Contains(fieldsJSON, "f:prometheus.io/port") { + t.Errorf("expected Flux to no longer own prometheus.io/port") + } + } + } + } + }) + + t.Run("other controller orphans ignored field and flux adopts in-cluster value", func(t *testing.T) { + // VPA applies WITHOUT spec.replicas, dropping its ownership. + // Since Flux also doesn't own it anymore (released in previous subtest), + // the field becomes orphaned — no Apply manager owns it. + // With adopt behavior, Flux adopts the in-cluster value into its payload, + // preserving the value while retaining ownership. + vpaObjNoReplicas := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": map[string]interface{}{ + "name": id, + "namespace": id, + }, + "spec": map[string]interface{}{ + "selector": map[string]interface{}{ + "matchLabels": map[string]interface{}{ + "app": id, + }, + }, + "template": map[string]interface{}{ + "metadata": map[string]interface{}{ + "labels": map[string]interface{}{ + "app": id, + }, + }, + "spec": map[string]interface{}{ + "containers": []interface{}{ + map[string]interface{}{ + "name": "podinfod", + "image": "ghcr.io/stefanprodan/podinfo:6.0.0", + }, + }, + }, + }, + }, + }, + } + err := manager.client.Patch(ctx, vpaObjNoReplicas, client.Apply, + client.FieldOwner("vpa-controller")) + if err != nil { + t.Fatal(err) + } + + // Verify replicas is still in-cluster but orphaned (no manager owns it). + existing := deployObject.DeepCopy() + if err := manager.client.Get(ctx, client.ObjectKeyFromObject(existing), existing); err != nil { + t.Fatal(err) + } + replicas, found, _ := unstructured.NestedInt64(existing.Object, "spec", "replicas") + if !found { + t.Fatal("expected spec.replicas to still exist in-cluster after VPA dropped ownership") + } + t.Logf("spec.replicas=%d is now orphaned (value persists, no manager owns it)", replicas) + + // Flux re-apply with ignore rule should adopt the in-cluster value + // since no other Apply manager owns it at the field level. + err = unstructured.SetNestedField(deployObject.Object, int64(11), "spec", "minReadySeconds") + if err != nil { + t.Fatal(err) + } + + entry, err := manager.Apply(ctx, deployObject, opts) + if err != nil { + t.Fatal(err) + } + + if entry.Action != ConfiguredAction { + t.Errorf("expected ConfiguredAction, got %s", entry.Action) + } + + // Verify the in-cluster value was preserved and Flux now owns the field. + existing = deployObject.DeepCopy() + if err := manager.client.Get(ctx, client.ObjectKeyFromObject(existing), existing); err != nil { + t.Fatal(err) + } + adoptedReplicas, found, _ := unstructured.NestedInt64(existing.Object, "spec", "replicas") + if !found { + t.Fatal("expected spec.replicas to exist after adopt") + } + if adoptedReplicas != replicas { + t.Errorf("expected spec.replicas=%d (in-cluster value adopted), got %d", replicas, adoptedReplicas) + } + }) + + t.Run("re-apply with no changes returns unchanged", func(t *testing.T) { + // After ownership has been released and no fields have changed, + // re-applying should return UnchangedAction. The only difference + // between desired and in-cluster is in ignored fields (replicas), + // which should not trigger an apply. + entry, err := manager.Apply(ctx, deployObject, opts) + if err != nil { + t.Fatal(err) + } + + if entry.Action != UnchangedAction { + t.Errorf("expected UnchangedAction when only ignored fields differ, got %s", entry.Action) + } + }) +} + +func TestApply_DriftIgnoreRules_ImmutableField(t *testing.T) { + timeout := 30 * time.Second + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + id := generateName("drift-imm") + objects, err := readManifest("testdata/test2.yaml", id) + if err != nil { + t.Fatal(err) + } + + manager.SetOwnerLabels(objects, "app1", "default") + + if err := normalize.UnstructuredList(objects); err != nil { + t.Fatal(err) + } + + _, deployObject := getFirstObject(objects, "Deployment", id) + + // Define ignore rule for the immutable spec.selector field upfront. + opts := DefaultApplyOptions() + opts.DriftIgnoreRules = []jsondiff.IgnoreRule{ + { + Paths: []string{"/spec/selector"}, + Selector: &jsondiff.Selector{ + Kind: "Deployment", + }, + }, + } + + t.Run("creates objects with ignore rules present", func(t *testing.T) { + changeSet, err := manager.ApplyAllStaged(ctx, objects, opts) + if err != nil { + t.Fatal(err) + } + for _, entry := range changeSet.Entries { + if diff := cmp.Diff(CreatedAction, entry.Action); diff != "" { + t.Errorf("Mismatch from expected value (-want +got):\n%s", diff) + } + } + }) + + t.Run("non-drifted immutable field stays in payload", func(t *testing.T) { + // When Flux is the sole owner of spec.selector and the ignore rule is present, + // but the selector value has NOT drifted (same in existing and dry-run), + // the field should NOT be stripped from the payload. The apply succeeds + // because the full object including selector is sent. + err := unstructured.SetNestedField(deployObject.Object, int64(7), "spec", "minReadySeconds") + if err != nil { + t.Fatal(err) + } + + entry, err := manager.Apply(ctx, deployObject, opts) + if err != nil { + t.Fatalf("expected apply to succeed when ignored immutable field has not drifted, got: %v", err) + } + + if entry.Action != ConfiguredAction { + t.Errorf("expected ConfiguredAction, got %s", entry.Action) + } + + // Verify the Deployment is still intact and Flux still owns spec.selector. + existing := deployObject.DeepCopy() + if err := manager.client.Get(ctx, client.ObjectKeyFromObject(existing), existing); err != nil { + t.Fatal(err) + } + _, found, _ := unstructured.NestedMap(existing.Object, "spec", "selector") + if !found { + t.Fatal("expected spec.selector to still exist in-cluster") + } + + fluxOwnsSelector := false + for _, mf := range existing.GetManagedFields() { + if mf.Manager == manager.owner.Field && mf.Operation == metav1.ManagedFieldsOperationApply { + if mf.FieldsV1 != nil { + fieldsJSON := string(mf.FieldsV1.Raw) + if strings.Contains(fieldsJSON, "f:selector") { + fluxOwnsSelector = true + } + } + } + } + if !fluxOwnsSelector { + t.Errorf("expected Flux to still own spec.selector since it has not drifted") + } + }) + + t.Run("other controller co-owns immutable field", func(t *testing.T) { + // Another controller applies with the same selector value. Since the value + // matches, both Flux and selector-controller co-own spec.selector. + otherObj := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": map[string]interface{}{ + "name": id, + "namespace": id, + }, + "spec": map[string]interface{}{ + "selector": map[string]interface{}{ + "matchLabels": map[string]interface{}{ + "app": id, + }, + }, + "template": map[string]interface{}{ + "metadata": map[string]interface{}{ + "labels": map[string]interface{}{ + "app": id, + }, + }, + "spec": map[string]interface{}{ + "containers": []interface{}{ + map[string]interface{}{ + "name": "podinfod", + "image": "ghcr.io/stefanprodan/podinfo:6.0.0", + }, + }, + }, + }, + }, + }, + } + err := manager.client.Patch(ctx, otherObj, client.Apply, + client.FieldOwner("selector-controller")) + if err != nil { + t.Fatal(err) + } + + // Verify both Flux and selector-controller co-own spec.selector. + existing := deployObject.DeepCopy() + if err := manager.client.Get(ctx, client.ObjectKeyFromObject(existing), existing); err != nil { + t.Fatal(err) + } + selectorControllerOwns := false + fluxOwnsSelector := false + for _, entry := range existing.GetManagedFields() { + if entry.FieldsV1 != nil { + fieldsJSON := string(entry.FieldsV1.Raw) + if strings.Contains(fieldsJSON, "f:selector") { + if entry.Manager == "selector-controller" && entry.Operation == metav1.ManagedFieldsOperationApply { + selectorControllerOwns = true + } + if entry.Manager == manager.owner.Field && entry.Operation == metav1.ManagedFieldsOperationApply { + fluxOwnsSelector = true + } + } + } + } + if !selectorControllerOwns { + t.Errorf("expected selector-controller to co-own spec.selector") + } + if !fluxOwnsSelector { + t.Errorf("expected Flux to still co-own spec.selector before ignore-rule apply") + } + }) + + t.Run("co-owned non-drifted immutable field stays in payload", func(t *testing.T) { + // Now that selector-controller co-owns spec.selector, Flux applies with + // the ignore rule present. Since spec.selector is immutable and has the + // same value on both sides (it cannot change), it is NOT stripped from + // the payload. Flux retains its co-ownership. + err := unstructured.SetNestedField(deployObject.Object, int64(8), "spec", "minReadySeconds") + if err != nil { + t.Fatal(err) + } + + entry, err := manager.Apply(ctx, deployObject, opts) + if err != nil { + t.Fatalf("expected apply to succeed when ignoring co-owned non-drifted immutable field, got: %v", err) + } + + if entry.Action != ConfiguredAction { + t.Errorf("expected ConfiguredAction, got %s", entry.Action) + } + + // Verify the Deployment is intact and spec.selector is preserved. + existing := deployObject.DeepCopy() + if err := manager.client.Get(ctx, client.ObjectKeyFromObject(existing), existing); err != nil { + t.Fatal(err) + } + _, found, _ := unstructured.NestedMap(existing.Object, "spec", "selector") + if !found { + t.Fatal("expected spec.selector to still exist in-cluster after apply") + } + + // Verify Flux still co-owns spec.selector (not stripped because value hasn't drifted). + fluxOwnsSelector := false + selectorControllerOwns := false + for _, mf := range existing.GetManagedFields() { + if mf.FieldsV1 != nil { + fieldsJSON := string(mf.FieldsV1.Raw) + if strings.Contains(fieldsJSON, "f:selector") { + if mf.Manager == manager.owner.Field && mf.Operation == metav1.ManagedFieldsOperationApply { + fluxOwnsSelector = true + } + if mf.Manager == "selector-controller" && mf.Operation == metav1.ManagedFieldsOperationApply { + selectorControllerOwns = true + } + } + } + } + if !fluxOwnsSelector { + t.Errorf("expected Flux to still co-own spec.selector since it has not drifted") + } + if !selectorControllerOwns { + t.Errorf("expected selector-controller to still own spec.selector") + } + }) + + t.Run("immutable required field cannot be orphaned by other controller", func(t *testing.T) { + // spec.selector is both immutable and required on Deployments. + // Since Flux still co-owns spec.selector (it was not stripped because it + // has not drifted), selector-controller applying without spec.selector + // will succeed because Flux's co-ownership preserves the field. + // Verify that the field is preserved and both managers maintain their state. + selectorObjNoSelector := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": map[string]interface{}{ + "name": id, + "namespace": id, + }, + "spec": map[string]interface{}{ + "template": map[string]interface{}{ + "metadata": map[string]interface{}{ + "labels": map[string]interface{}{ + "app": id, + }, + }, + "spec": map[string]interface{}{ + "containers": []interface{}{ + map[string]interface{}{ + "name": "podinfod", + "image": "ghcr.io/stefanprodan/podinfo:6.0.0", + }, + }, + }, + }, + }, + }, + } + err := manager.client.Patch(ctx, selectorObjNoSelector, client.Apply, + client.FieldOwner("selector-controller")) + if err != nil { + // If Flux co-owns selector, this apply succeeds (selector-controller + // drops its selector ownership but Flux preserves the field). + // If it fails, that's also acceptable — the API server protects the field. + t.Logf("selector-controller apply without selector: %v", err) + } + + // Verify the Deployment is still intact and spec.selector is preserved. + existing := deployObject.DeepCopy() + if err := manager.client.Get(ctx, client.ObjectKeyFromObject(existing), existing); err != nil { + t.Fatal(err) + } + _, found, _ := unstructured.NestedMap(existing.Object, "spec", "selector") + if !found { + t.Fatal("expected spec.selector to still exist in-cluster") + } + }) +} + +func TestApply_DriftIgnoreRules_RequiredMutableField(t *testing.T) { + timeout := 30 * time.Second + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + id := generateName("drift-mut") + objects, err := readManifest("testdata/test2.yaml", id) + if err != nil { + t.Fatal(err) + } + + manager.SetOwnerLabels(objects, "app1", "default") + + if err := normalize.UnstructuredList(objects); err != nil { + t.Fatal(err) + } + + _, deployObject := getFirstObject(objects, "Deployment", id) + + // Define ignore rule for the container image (required but mutable) upfront. + // The path targets the image field of the first container. + opts := DefaultApplyOptions() + opts.DriftIgnoreRules = []jsondiff.IgnoreRule{ + { + Paths: []string{"/spec/template/spec/containers/0/image"}, + Selector: &jsondiff.Selector{ + Kind: "Deployment", + }, + }, + } + + t.Run("creates objects with ignore rules present", func(t *testing.T) { + changeSet, err := manager.ApplyAllStaged(ctx, objects, opts) + if err != nil { + t.Fatal(err) + } + for _, entry := range changeSet.Entries { + if diff := cmp.Diff(CreatedAction, entry.Action); diff != "" { + t.Errorf("Mismatch from expected value (-want +got):\n%s", diff) + } + } + + // On create, ignore rules are skipped, so the image should be present. + existing := deployObject.DeepCopy() + if err := manager.client.Get(ctx, client.ObjectKeyFromObject(existing), existing); err != nil { + t.Fatal(err) + } + containers, found, _ := unstructured.NestedSlice(existing.Object, "spec", "template", "spec", "containers") + if !found || len(containers) == 0 { + t.Fatal("expected containers to exist after create") + } + c0 := containers[0].(map[string]interface{}) + if c0["image"] != "ghcr.io/stefanprodan/podinfo:6.0.0" { + t.Fatalf("expected image 6.0.0 after create, got %v", c0["image"]) + } + }) + + t.Run("image policy controller claims container image", func(t *testing.T) { + // An image policy controller updates the container image and takes ownership. + imgObj := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": map[string]interface{}{ + "name": id, + "namespace": id, + }, + "spec": map[string]interface{}{ + "selector": map[string]interface{}{ + "matchLabels": map[string]interface{}{ + "app": id, + }, + }, + "template": map[string]interface{}{ + "metadata": map[string]interface{}{ + "labels": map[string]interface{}{ + "app": id, + }, + }, + "spec": map[string]interface{}{ + "containers": []interface{}{ + map[string]interface{}{ + "name": "podinfod", + "image": "ghcr.io/stefanprodan/podinfo:6.2.0", + }, + }, + }, + }, + }, + }, + } + err := manager.client.Patch(ctx, imgObj, client.Apply, + client.FieldOwner("image-policy-controller"), client.ForceOwnership) + if err != nil { + t.Fatal(err) + } + + // Verify the image was updated. + existing := deployObject.DeepCopy() + if err := manager.client.Get(ctx, client.ObjectKeyFromObject(existing), existing); err != nil { + t.Fatal(err) + } + containers, _, _ := unstructured.NestedSlice(existing.Object, "spec", "template", "spec", "containers") + c0 := containers[0].(map[string]interface{}) + if c0["image"] != "ghcr.io/stefanprodan/podinfo:6.2.0" { + t.Fatalf("expected image 6.2.0 after image-policy claim, got %v", c0["image"]) + } + + // Verify image-policy-controller owns the image field and Flux does not. + imgControllerOwnsImage := false + fluxOwnsImage := false + for _, entry := range existing.GetManagedFields() { + if entry.FieldsV1 != nil { + fieldsJSON := string(entry.FieldsV1.Raw) + if strings.Contains(fieldsJSON, "\"f:image\":") { + if entry.Manager == "image-policy-controller" && entry.Operation == metav1.ManagedFieldsOperationApply { + imgControllerOwnsImage = true + } + if entry.Manager == manager.owner.Field && entry.Operation == metav1.ManagedFieldsOperationApply { + fluxOwnsImage = true + } + } + } + } + if !imgControllerOwnsImage { + t.Errorf("expected image-policy-controller to own container image after ForceOwnership claim") + } + if fluxOwnsImage { + t.Errorf("expected Flux to no longer own container image after ForceOwnership takeover, but it still does") + } + }) + + t.Run("flux apply releases image ownership and preserves other controller value", func(t *testing.T) { + // Trigger drift by changing a non-ignored field. + err := unstructured.SetNestedField(deployObject.Object, int64(12), "spec", "minReadySeconds") + if err != nil { + t.Fatal(err) + } + + entry, err := manager.Apply(ctx, deployObject, opts) + if err != nil { + t.Fatal(err) + } + + if entry.Action != ConfiguredAction { + t.Errorf("expected ConfiguredAction, got %s", entry.Action) + } + + existing := deployObject.DeepCopy() + if err := manager.client.Get(ctx, client.ObjectKeyFromObject(existing), existing); err != nil { + t.Fatal(err) + } + + // The image-policy-controller's value should be preserved. + containers, found, _ := unstructured.NestedSlice(existing.Object, "spec", "template", "spec", "containers") + if !found || len(containers) == 0 { + t.Fatal("expected containers to still exist") + } + c0 := containers[0].(map[string]interface{}) + if c0["image"] != "ghcr.io/stefanprodan/podinfo:6.2.0" { + t.Errorf("expected image-policy-controller's image 6.2.0 to be preserved, got %v", c0["image"]) + } + + // Verify Flux no longer owns the container image field. + for _, mf := range existing.GetManagedFields() { + if mf.Manager == manager.owner.Field && mf.Operation == metav1.ManagedFieldsOperationApply { + if mf.FieldsV1 != nil { + fieldsJSON := string(mf.FieldsV1.Raw) + if strings.Contains(fieldsJSON, "\"f:image\":") { + t.Errorf("expected Flux to no longer own container image, but it does") + } + } + } + } + + // Verify image-policy-controller still owns the image. + imgControllerOwnsImage := false + for _, mf := range existing.GetManagedFields() { + if mf.Manager == "image-policy-controller" && mf.Operation == metav1.ManagedFieldsOperationApply { + if mf.FieldsV1 != nil { + fieldsJSON := string(mf.FieldsV1.Raw) + if strings.Contains(fieldsJSON, "\"f:image\":") { + imgControllerOwnsImage = true + } + } + } + } + if !imgControllerOwnsImage { + t.Errorf("expected image-policy-controller to still own container image") + } + }) + + t.Run("required field cannot be orphaned by other controller", func(t *testing.T) { + // Unlike optional fields like spec.replicas, the container image is a + // required field. The API server rejects an apply payload that omits it. + // Verify that image-policy-controller cannot drop the image field. + imgObjNoImage := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": map[string]interface{}{ + "name": id, + "namespace": id, + }, + "spec": map[string]interface{}{ + "selector": map[string]interface{}{ + "matchLabels": map[string]interface{}{ + "app": id, + }, + }, + "template": map[string]interface{}{ + "metadata": map[string]interface{}{ + "labels": map[string]interface{}{ + "app": id, + }, + }, + "spec": map[string]interface{}{ + "containers": []interface{}{ + map[string]interface{}{ + "name": "podinfod", + }, + }, + }, + }, + }, + }, + } + err := manager.client.Patch(ctx, imgObjNoImage, client.Apply, + client.FieldOwner("image-policy-controller")) + if err == nil { + t.Fatal("expected API server to reject apply without required image field, but got no error") + } + if !strings.Contains(err.Error(), "Required") { + t.Errorf("expected error to mention required field, got: %v", err) + } + + // Verify the Deployment is still intact and image-policy-controller still owns the image. + existing := deployObject.DeepCopy() + if err := manager.client.Get(ctx, client.ObjectKeyFromObject(existing), existing); err != nil { + t.Fatal(err) + } + containers, found, _ := unstructured.NestedSlice(existing.Object, "spec", "template", "spec", "containers") + if !found || len(containers) == 0 { + t.Fatal("expected containers to still exist after rejected apply") + } + c0 := containers[0].(map[string]interface{}) + if c0["image"] != "ghcr.io/stefanprodan/podinfo:6.2.0" { + t.Errorf("expected image 6.2.0 to be preserved after rejected apply, got %v", c0["image"]) + } + + imgControllerOwnsImage := false + for _, mf := range existing.GetManagedFields() { + if mf.Manager == "image-policy-controller" && mf.Operation == metav1.ManagedFieldsOperationApply { + if mf.FieldsV1 != nil { + fieldsJSON := string(mf.FieldsV1.Raw) + if strings.Contains(fieldsJSON, "\"f:image\":") { + imgControllerOwnsImage = true + } + } + } + } + if !imgControllerOwnsImage { + t.Errorf("expected image-policy-controller to still own container image after rejected apply") + } + }) + + t.Run("re-apply with no changes returns unchanged after ownership released", func(t *testing.T) { + // After Flux released ownership of the image field in the prior subtest, + // re-applying the same object with no changes should return UnchangedAction. + // The only difference is in the ignored field (image 6.0.0 vs 6.2.0), + // which should not trigger an apply. + entry, err := manager.Apply(ctx, deployObject, opts) + if err != nil { + t.Fatal(err) + } + + if entry.Action != UnchangedAction { + t.Errorf("expected UnchangedAction when only ignored fields differ, got %s", entry.Action) + } + }) +} + +func TestApply_DriftIgnoreRules_SelectorAndPaths(t *testing.T) { + timeout := 30 * time.Second + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + id := generateName("drift-sel") + objects, err := readManifest("testdata/test2.yaml", id) + if err != nil { + t.Fatal(err) + } + + manager.SetOwnerLabels(objects, "app1", "default") + + if err := normalize.UnstructuredList(objects); err != nil { + t.Fatal(err) + } + + _, deployObject := getFirstObject(objects, "Deployment", id) + _, svcObject := getFirstObject(objects, "Service", id) + + t.Run("creates objects initially", func(t *testing.T) { + changeSet, err := manager.ApplyAllStaged(ctx, objects, DefaultApplyOptions()) + if err != nil { + t.Fatal(err) + } + for _, entry := range changeSet.Entries { + if diff := cmp.Diff(CreatedAction, entry.Action); diff != "" { + t.Errorf("Mismatch from expected value (-want +got):\n%s", diff) + } + } + }) + + t.Run("non-matching selector does not strip field", func(t *testing.T) { + // An ignore rule targeting kind=Service should NOT affect a Deployment. + // The Deployment's spec.minReadySeconds should be applied normally. + opts := DefaultApplyOptions() + opts.DriftIgnoreRules = []jsondiff.IgnoreRule{ + { + Paths: []string{"/spec/minReadySeconds"}, + Selector: &jsondiff.Selector{ + Kind: "Service", + }, + }, + } + + err := unstructured.SetNestedField(deployObject.Object, int64(20), "spec", "minReadySeconds") + if err != nil { + t.Fatal(err) + } + + entry, err := manager.Apply(ctx, deployObject, opts) + if err != nil { + t.Fatal(err) + } + + if entry.Action != ConfiguredAction { + t.Errorf("expected ConfiguredAction, got %s", entry.Action) + } + + // Verify the field was applied (not ignored). + existing := deployObject.DeepCopy() + if err := manager.client.Get(ctx, client.ObjectKeyFromObject(existing), existing); err != nil { + t.Fatal(err) + } + val, found, _ := unstructured.NestedInt64(existing.Object, "spec", "minReadySeconds") + if !found || val != 20 { + t.Errorf("expected spec.minReadySeconds=20 (not ignored), got %d (found=%v)", val, found) + } + }) + + t.Run("name selector matches only named object", func(t *testing.T) { + // An ignore rule targeting kind=Deployment, name=nonexistent should + // NOT match the actual Deployment. The field should be applied normally. + opts := DefaultApplyOptions() + opts.DriftIgnoreRules = []jsondiff.IgnoreRule{ + { + Paths: []string{"/spec/minReadySeconds"}, + Selector: &jsondiff.Selector{ + Kind: "Deployment", + Name: "nonexistent", + }, + }, + } + + err := unstructured.SetNestedField(deployObject.Object, int64(22), "spec", "minReadySeconds") + if err != nil { + t.Fatal(err) + } + + entry, err := manager.Apply(ctx, deployObject, opts) + if err != nil { + t.Fatal(err) + } + + if entry.Action != ConfiguredAction { + t.Errorf("expected ConfiguredAction, got %s", entry.Action) + } + + existing := deployObject.DeepCopy() + if err := manager.client.Get(ctx, client.ObjectKeyFromObject(existing), existing); err != nil { + t.Fatal(err) + } + val, _, _ := unstructured.NestedInt64(existing.Object, "spec", "minReadySeconds") + if val != 22 { + t.Errorf("expected spec.minReadySeconds=22 (selector didn't match), got %d", val) + } + }) + + t.Run("multiple paths in single rule", func(t *testing.T) { + // A single IgnoreRule with multiple Paths strips only the drifted fields. + err := unstructured.SetNestedField(deployObject.Object, int64(3), "spec", "replicas") + if err != nil { + t.Fatal(err) + } + + // First apply to claim ownership of replicas. + _, err = manager.Apply(ctx, deployObject, DefaultApplyOptions()) + if err != nil { + t.Fatal(err) + } + + // VPA claims replicas via ForceOwnership to introduce drift. + vpaObj := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": map[string]interface{}{ + "name": id, + "namespace": id, + }, + "spec": map[string]interface{}{ + "replicas": int64(7), + "selector": map[string]interface{}{ + "matchLabels": map[string]interface{}{ + "app": id, + }, + }, + "template": map[string]interface{}{ + "metadata": map[string]interface{}{ + "labels": map[string]interface{}{ + "app": id, + }, + }, + "spec": map[string]interface{}{ + "containers": []interface{}{ + map[string]interface{}{ + "name": "podinfod", + "image": "ghcr.io/stefanprodan/podinfo:6.0.0", + }, + }, + }, + }, + }, + }, + } + err = manager.client.Patch(ctx, vpaObj, client.Apply, + client.FieldOwner("vpa-controller"), client.ForceOwnership) + if err != nil { + t.Fatal(err) + } + + // Monitoring controller claims annotations via ForceOwnership. + monObj := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": map[string]interface{}{ + "name": id, + "namespace": id, + }, + "spec": map[string]interface{}{ + "selector": map[string]interface{}{ + "matchLabels": map[string]interface{}{ + "app": id, + }, + }, + "template": map[string]interface{}{ + "metadata": map[string]interface{}{ + "annotations": map[string]interface{}{ + "prometheus.io/scrape": "false", + }, + "labels": map[string]interface{}{ + "app": id, + }, + }, + "spec": map[string]interface{}{ + "containers": []interface{}{ + map[string]interface{}{ + "name": "podinfod", + "image": "ghcr.io/stefanprodan/podinfo:6.0.0", + }, + }, + }, + }, + }, + }, + } + err = manager.client.Patch(ctx, monObj, client.Apply, + client.FieldOwner("monitoring-controller"), client.ForceOwnership) + if err != nil { + t.Fatal(err) + } + + opts := DefaultApplyOptions() + opts.DriftIgnoreRules = []jsondiff.IgnoreRule{ + { + Paths: []string{"/spec/replicas", "/spec/template/metadata/annotations"}, + Selector: &jsondiff.Selector{ + Kind: "Deployment", + }, + }, + } + + // Trigger drift in a non-ignored field. + err = unstructured.SetNestedField(deployObject.Object, int64(25), "spec", "minReadySeconds") + if err != nil { + t.Fatal(err) + } + + entry, err := manager.Apply(ctx, deployObject, opts) + if err != nil { + t.Fatal(err) + } + + if entry.Action != ConfiguredAction { + t.Errorf("expected ConfiguredAction, got %s", entry.Action) + } + + // Verify Flux no longer owns either drifted field. + existing := deployObject.DeepCopy() + if err := manager.client.Get(ctx, client.ObjectKeyFromObject(existing), existing); err != nil { + t.Fatal(err) + } + for _, mf := range existing.GetManagedFields() { + if mf.Manager == manager.owner.Field && mf.Operation == metav1.ManagedFieldsOperationApply { + if mf.FieldsV1 != nil { + fieldsJSON := string(mf.FieldsV1.Raw) + if strings.Contains(fieldsJSON, "f:replicas") { + t.Errorf("expected Flux to not own spec.replicas") + } + if strings.Contains(fieldsJSON, "f:prometheus.io/scrape") { + t.Errorf("expected Flux to not own prometheus annotation") + } + } + } + } + }) + + t.Run("non-existent path is a no-op", func(t *testing.T) { + // Ignoring a path that doesn't exist in the object should not error. + opts := DefaultApplyOptions() + opts.DriftIgnoreRules = []jsondiff.IgnoreRule{ + { + Paths: []string{"/spec/nonExistentField"}, + Selector: &jsondiff.Selector{ + Kind: "Deployment", + }, + }, + } + + err := unstructured.SetNestedField(deployObject.Object, int64(27), "spec", "minReadySeconds") + if err != nil { + t.Fatal(err) + } + + entry, err := manager.Apply(ctx, deployObject, opts) + if err != nil { + t.Fatal(err) + } + + if entry.Action != ConfiguredAction { + t.Errorf("expected ConfiguredAction, got %s", entry.Action) + } + }) + + t.Run("malformed pointer path is a no-op", func(t *testing.T) { + // A malformed pointer like /spec/replicas/invalid (where replicas is an int) + // cannot be resolved. lookupJSONPointer treats unresolvable paths as + // "not present" on both sides, so the field is not considered drifted + // and is not stripped from the payload. + opts := DefaultApplyOptions() + opts.DriftIgnoreRules = []jsondiff.IgnoreRule{ + { + Paths: []string{"/spec/replicas/invalid"}, + Selector: &jsondiff.Selector{ + Kind: "Deployment", + }, + }, + } + + err := unstructured.SetNestedField(deployObject.Object, int64(28), "spec", "minReadySeconds") + if err != nil { + t.Fatal(err) + } + + entry, err := manager.Apply(ctx, deployObject, opts) + if err != nil { + t.Fatal(err) + } + + if entry.Action != ConfiguredAction { + t.Errorf("expected ConfiguredAction, got %s", entry.Action) + } + }) + + t.Run("multiple paths strips only drifted ones", func(t *testing.T) { + // A rule with two paths: /spec/replicas (drifted by VPA) and + // /spec/minReadySeconds (not drifted — same value on both sides). + // Only replicas should be stripped; minReadySeconds should be kept. + err := unstructured.SetNestedField(deployObject.Object, int64(4), "spec", "replicas") + if err != nil { + t.Fatal(err) + } + + // Apply to claim ownership of both fields. + _, err = manager.Apply(ctx, deployObject, DefaultApplyOptions()) + if err != nil { + t.Fatal(err) + } + + // VPA claims replicas, introducing drift. + vpaObj := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": map[string]interface{}{ + "name": id, + "namespace": id, + }, + "spec": map[string]interface{}{ + "replicas": int64(8), + "selector": map[string]interface{}{ + "matchLabels": map[string]interface{}{ + "app": id, + }, + }, + "template": map[string]interface{}{ + "metadata": map[string]interface{}{ + "labels": map[string]interface{}{ + "app": id, + }, + }, + "spec": map[string]interface{}{ + "containers": []interface{}{ + map[string]interface{}{ + "name": "podinfod", + "image": "ghcr.io/stefanprodan/podinfo:6.0.0", + }, + }, + }, + }, + }, + }, + } + err = manager.client.Patch(ctx, vpaObj, client.Apply, + client.FieldOwner("vpa-controller"), client.ForceOwnership) + if err != nil { + t.Fatal(err) + } + + opts := DefaultApplyOptions() + opts.DriftIgnoreRules = []jsondiff.IgnoreRule{ + { + Paths: []string{"/spec/replicas", "/spec/minReadySeconds"}, + Selector: &jsondiff.Selector{ + Kind: "Deployment", + }, + }, + } + + // Change a non-ignored field to trigger the apply. + annotations := deployObject.GetAnnotations() + if annotations == nil { + annotations = map[string]string{} + } + annotations["example.com/trigger"] = "true" + deployObject.SetAnnotations(annotations) + + entry, err := manager.Apply(ctx, deployObject, opts) + if err != nil { + t.Fatal(err) + } + + if entry.Action != ConfiguredAction { + t.Errorf("expected ConfiguredAction, got %s", entry.Action) + } + + // Verify Flux no longer owns replicas (drifted → stripped). + existing := deployObject.DeepCopy() + if err := manager.client.Get(ctx, client.ObjectKeyFromObject(existing), existing); err != nil { + t.Fatal(err) + } + for _, mf := range existing.GetManagedFields() { + if mf.Manager == manager.owner.Field && mf.Operation == metav1.ManagedFieldsOperationApply { + if mf.FieldsV1 != nil { + fieldsJSON := string(mf.FieldsV1.Raw) + if strings.Contains(fieldsJSON, "f:replicas") { + t.Errorf("expected Flux to not own spec.replicas (drifted, should be stripped)") + } + // minReadySeconds was NOT drifted, so Flux should still own it. + if !strings.Contains(fieldsJSON, "f:minReadySeconds") { + t.Errorf("expected Flux to still own spec.minReadySeconds (not drifted, should be kept)") + } + } + } + } + }) + + t.Run("ApplyAll applies ignore rules selectively", func(t *testing.T) { + // An ignore rule targeting kind=Deployment should strip drifted fields from the + // Deployment but NOT from the Service. + err := unstructured.SetNestedField(deployObject.Object, int64(3), "spec", "replicas") + if err != nil { + t.Fatal(err) + } + _, err = manager.Apply(ctx, deployObject, DefaultApplyOptions()) + if err != nil { + t.Fatal(err) + } + + // VPA claims replicas via ForceOwnership to introduce drift in the ignored field. + vpaObj := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": map[string]interface{}{ + "name": id, + "namespace": id, + }, + "spec": map[string]interface{}{ + "replicas": int64(9), + "selector": map[string]interface{}{ + "matchLabels": map[string]interface{}{ + "app": id, + }, + }, + "template": map[string]interface{}{ + "metadata": map[string]interface{}{ + "labels": map[string]interface{}{ + "app": id, + }, + }, + "spec": map[string]interface{}{ + "containers": []interface{}{ + map[string]interface{}{ + "name": "podinfod", + "image": "ghcr.io/stefanprodan/podinfo:6.0.0", + }, + }, + }, + }, + }, + }, + } + err = manager.client.Patch(ctx, vpaObj, client.Apply, + client.FieldOwner("vpa-controller"), client.ForceOwnership) + if err != nil { + t.Fatal(err) + } + + opts := DefaultApplyOptions() + opts.DriftIgnoreRules = []jsondiff.IgnoreRule{ + { + Paths: []string{"/spec/replicas"}, + Selector: &jsondiff.Selector{ + Kind: "Deployment", + }, + }, + } + + // Trigger drift on both the Deployment and the Service. + err = unstructured.SetNestedField(deployObject.Object, int64(30), "spec", "minReadySeconds") + if err != nil { + t.Fatal(err) + } + err = unstructured.SetNestedField(svcObject.Object, "ClientIP", "spec", "sessionAffinity") + if err != nil { + t.Fatal(err) + } + + changeSet, err := manager.ApplyAll(ctx, objects, opts) + if err != nil { + t.Fatal(err) + } + + // Verify we got a change set back. + if len(changeSet.Entries) == 0 { + t.Fatal("expected non-empty change set from ApplyAll") + } + + // Verify Flux no longer owns Deployment's spec.replicas. + existingDeploy := deployObject.DeepCopy() + if err := manager.client.Get(ctx, client.ObjectKeyFromObject(existingDeploy), existingDeploy); err != nil { + t.Fatal(err) + } + for _, mf := range existingDeploy.GetManagedFields() { + if mf.Manager == manager.owner.Field && mf.Operation == metav1.ManagedFieldsOperationApply { + if mf.FieldsV1 != nil { + fieldsJSON := string(mf.FieldsV1.Raw) + if strings.Contains(fieldsJSON, "f:replicas") { + t.Errorf("expected Flux to not own Deployment spec.replicas after ApplyAll") + } + } + } + } + + // Verify Service was NOT affected by the Deployment-targeted rule. + existingSvc := svcObject.DeepCopy() + if err := manager.client.Get(ctx, client.ObjectKeyFromObject(existingSvc), existingSvc); err != nil { + t.Fatal(err) + } + svcOwnedByFlux := false + for _, mf := range existingSvc.GetManagedFields() { + if mf.Manager == manager.owner.Field && mf.Operation == metav1.ManagedFieldsOperationApply { + svcOwnedByFlux = true + } + } + if !svcOwnedByFlux { + t.Errorf("expected Flux to still own the Service (rule shouldn't affect it)") + } + }) +} + +func TestApply_DriftIgnoreRules_CreateSkipsIgnore(t *testing.T) { + timeout := 30 * time.Second + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + id := generateName("drift-create") + + // Build a minimal ConfigMap inline to test create behavior in isolation. + ns := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "Namespace", + "metadata": map[string]interface{}{ + "name": id, + }, + }, + } + if _, err := manager.Apply(ctx, ns, DefaultApplyOptions()); err != nil { + t.Fatal(err) + } + + cm := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]interface{}{ + "name": id, + "namespace": id, + }, + "data": map[string]interface{}{ + "key1": "value1", + "key2": "value2", + }, + }, + } + + t.Run("create includes all fields despite ignore rules", func(t *testing.T) { + // When creating a new object, ignore rules should NOT strip fields + // because the guard `existingObject.GetResourceVersion() != ""` is false. + opts := DefaultApplyOptions() + opts.DriftIgnoreRules = []jsondiff.IgnoreRule{ + { + Paths: []string{"/data/key1"}, + Selector: &jsondiff.Selector{ + Kind: "ConfigMap", + }, + }, + } + + entry, err := manager.Apply(ctx, cm, opts) + if err != nil { + t.Fatal(err) + } + + if entry.Action != CreatedAction { + t.Errorf("expected CreatedAction, got %s", entry.Action) + } + + // Verify key1 IS present in-cluster (not stripped on create). + existing := cm.DeepCopy() + if err := manager.client.Get(ctx, client.ObjectKeyFromObject(existing), existing); err != nil { + t.Fatal(err) + } + val, found, _ := unstructured.NestedString(existing.Object, "data", "key1") + if !found || val != "value1" { + t.Errorf("expected data.key1=value1 on create (ignore rules should not apply), got %q (found=%v)", val, found) + } + }) + + t.Run("subsequent update adopts in-cluster value when Flux is sole owner", func(t *testing.T) { + // On update, the ignore rule should take effect only for drifted fields. + // First, have another controller change key1 to introduce drift via + // client-side edit (no SSA manager). Since Flux is the sole Apply owner, + // the in-cluster value should be adopted into the payload. + cmClone := cm.DeepCopy() + if err := manager.client.Get(ctx, client.ObjectKeyFromObject(cmClone), cmClone); err != nil { + t.Fatal(err) + } + err := unstructured.SetNestedField(cmClone.Object, "changed-by-other", "data", "key1") + if err != nil { + t.Fatal(err) + } + if err := manager.client.Update(ctx, cmClone); err != nil { + t.Fatal(err) + } + + opts := DefaultApplyOptions() + opts.DriftIgnoreRules = []jsondiff.IgnoreRule{ + { + Paths: []string{"/data/key1"}, + Selector: &jsondiff.Selector{ + Kind: "ConfigMap", + }, + }, + } + + // Change a non-ignored field to trigger drift. + err = unstructured.SetNestedField(cm.Object, "value2-updated", "data", "key2") + if err != nil { + t.Fatal(err) + } + + entry, err := manager.Apply(ctx, cm, opts) + if err != nil { + t.Fatal(err) + } + + if entry.Action != ConfiguredAction { + t.Errorf("expected ConfiguredAction, got %s", entry.Action) + } + + // Flux should still own data.key1 (adopted, not stripped) and the + // in-cluster value should be preserved. + existing := cm.DeepCopy() + if err := manager.client.Get(ctx, client.ObjectKeyFromObject(existing), existing); err != nil { + t.Fatal(err) + } + val, found, _ := unstructured.NestedString(existing.Object, "data", "key1") + if !found || val != "changed-by-other" { + t.Errorf("expected data.key1=%q (in-cluster value adopted), got %q (found=%v)", "changed-by-other", val, found) + } + }) +} + +// driftIgnoreTestDeployment creates a minimal Deployment for drift ignore rule testing. +func driftIgnoreTestDeployment(id string) *unstructured.Unstructured { + return &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": map[string]interface{}{ + "name": id, + "namespace": id, + }, + "spec": map[string]interface{}{ + "replicas": int64(2), + "selector": map[string]interface{}{ + "matchLabels": map[string]interface{}{ + "app": id, + }, + }, + "template": map[string]interface{}{ + "metadata": map[string]interface{}{ + "annotations": map[string]interface{}{ + "prometheus.io/scrape": "true", + }, + "labels": map[string]interface{}{ + "app": id, + }, + }, + "spec": map[string]interface{}{ + "containers": []interface{}{ + map[string]interface{}{ + "name": "podinfod", + "image": "ghcr.io/stefanprodan/podinfo:6.0.0", + }, + }, + }, + }, + }, + }, + } +} + +// vpaClaimReplicas simulates VPA claiming spec.replicas via server-side apply with ForceOwnership. +func vpaClaimReplicas(ctx context.Context, t *testing.T, id string, replicas int64) { + t.Helper() + vpaObj := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": map[string]interface{}{ + "name": id, + "namespace": id, + }, + "spec": map[string]interface{}{ + "replicas": replicas, + "selector": map[string]interface{}{ + "matchLabels": map[string]interface{}{ + "app": id, + }, + }, + "template": map[string]interface{}{ + "metadata": map[string]interface{}{ + "labels": map[string]interface{}{ + "app": id, + }, + }, + "spec": map[string]interface{}{ + "containers": []interface{}{ + map[string]interface{}{ + "name": "podinfod", + "image": "ghcr.io/stefanprodan/podinfo:6.0.0", + }, + }, + }, + }, + }, + }, + } + err := manager.client.Patch(ctx, vpaObj, client.Apply, + client.FieldOwner("vpa-controller"), client.ForceOwnership) + if err != nil { + t.Fatal(err) + } +} + +func TestApply_DriftIgnoreRules_NonIgnoredFieldDrift(t *testing.T) { + timeout := 60 * time.Second + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + ignoreReplicasOpts := DefaultApplyOptions() + ignoreReplicasOpts.DriftIgnoreRules = []jsondiff.IgnoreRule{ + { + Paths: []string{"/spec/replicas"}, + Selector: &jsondiff.Selector{ + Kind: "Deployment", + }, + }, + } + + t.Run("VPA claimed ignored field", func(t *testing.T) { + id := generateName("drift-vpa") + ns := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "Namespace", + "metadata": map[string]interface{}{"name": id}, + }, + } + if _, err := manager.Apply(ctx, ns, DefaultApplyOptions()); err != nil { + t.Fatal(err) + } + + deploy := driftIgnoreTestDeployment(id) + if _, err := manager.Apply(ctx, deploy, DefaultApplyOptions()); err != nil { + t.Fatal(err) + } + + // VPA claims replicas via ForceOwnership. + vpaClaimReplicas(ctx, t, id, 5) + + t.Run("mutate non-ignored field", func(t *testing.T) { + err := unstructured.SetNestedField(deploy.Object, int64(10), "spec", "minReadySeconds") + if err != nil { + t.Fatal(err) + } + + entry, err := manager.Apply(ctx, deploy, ignoreReplicasOpts) + if err != nil { + t.Fatal(err) + } + + if entry.Action != ConfiguredAction { + t.Errorf("expected ConfiguredAction, got %s", entry.Action) + } + + existing := deploy.DeepCopy() + if err := manager.client.Get(ctx, client.ObjectKeyFromObject(existing), existing); err != nil { + t.Fatal(err) + } + + // VPA's replicas value should be preserved. + replicas, _, _ := unstructured.NestedInt64(existing.Object, "spec", "replicas") + if replicas != 5 { + t.Errorf("expected spec.replicas=5 (VPA value), got %d", replicas) + } + + // Flux should no longer own replicas. + for _, mf := range existing.GetManagedFields() { + if mf.Manager == manager.owner.Field && mf.Operation == metav1.ManagedFieldsOperationApply { + if mf.FieldsV1 != nil && strings.Contains(string(mf.FieldsV1.Raw), "f:replicas") { + t.Errorf("expected Flux to not own spec.replicas") + } + } + } + }) + + t.Run("add non-ignored field", func(t *testing.T) { + // Re-claim replicas for the next subtest. + vpaClaimReplicas(ctx, t, id, 5) + + ann := deploy.GetAnnotations() + if ann == nil { + ann = map[string]string{} + } + ann["example.com/new-field"] = "added" + deploy.SetAnnotations(ann) + + entry, err := manager.Apply(ctx, deploy, ignoreReplicasOpts) + if err != nil { + t.Fatal(err) + } + + if entry.Action != ConfiguredAction { + t.Errorf("expected ConfiguredAction, got %s", entry.Action) + } + + existing := deploy.DeepCopy() + if err := manager.client.Get(ctx, client.ObjectKeyFromObject(existing), existing); err != nil { + t.Fatal(err) + } + val, ok := existing.GetAnnotations()["example.com/new-field"] + if !ok || val != "added" { + t.Errorf("expected annotation example.com/new-field=added, got %q", val) + } + }) + + t.Run("remove non-ignored field", func(t *testing.T) { + vpaClaimReplicas(ctx, t, id, 5) + + ann := deploy.GetAnnotations() + delete(ann, "example.com/new-field") + deploy.SetAnnotations(ann) + + entry, err := manager.Apply(ctx, deploy, ignoreReplicasOpts) + if err != nil { + t.Fatal(err) + } + + if entry.Action != ConfiguredAction { + t.Errorf("expected ConfiguredAction, got %s", entry.Action) + } + }) + + t.Run("no change to non-ignored fields returns unchanged", func(t *testing.T) { + vpaClaimReplicas(ctx, t, id, 5) + + entry, err := manager.Apply(ctx, deploy, ignoreReplicasOpts) + if err != nil { + t.Fatal(err) + } + + if entry.Action != UnchangedAction { + t.Errorf("expected UnchangedAction when only ignored fields differ, got %s", entry.Action) + } + }) + }) + + t.Run("client-side edit of ignored field", func(t *testing.T) { + id := generateName("drift-cse") + ns := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "Namespace", + "metadata": map[string]interface{}{"name": id}, + }, + } + if _, err := manager.Apply(ctx, ns, DefaultApplyOptions()); err != nil { + t.Fatal(err) + } + + deploy := driftIgnoreTestDeployment(id) + if _, err := manager.Apply(ctx, deploy, DefaultApplyOptions()); err != nil { + t.Fatal(err) + } + + // Simulate client-side edit (kubectl edit) — Update operation, no SSA manager. + existing := deploy.DeepCopy() + if err := manager.client.Get(ctx, client.ObjectKeyFromObject(existing), existing); err != nil { + t.Fatal(err) + } + if err := unstructured.SetNestedField(existing.Object, int64(10), "spec", "replicas"); err != nil { + t.Fatal(err) + } + if err := manager.client.Update(ctx, existing); err != nil { + t.Fatal(err) + } + + t.Run("mutate non-ignored field", func(t *testing.T) { + err := unstructured.SetNestedField(deploy.Object, int64(15), "spec", "minReadySeconds") + if err != nil { + t.Fatal(err) + } + + entry, err := manager.Apply(ctx, deploy, ignoreReplicasOpts) + if err != nil { + t.Fatal(err) + } + + if entry.Action != ConfiguredAction { + t.Errorf("expected ConfiguredAction, got %s", entry.Action) + } + }) + + t.Run("add non-ignored field", func(t *testing.T) { + // Re-introduce client-side drift. + existing := deploy.DeepCopy() + if err := manager.client.Get(ctx, client.ObjectKeyFromObject(existing), existing); err != nil { + t.Fatal(err) + } + if err := unstructured.SetNestedField(existing.Object, int64(10), "spec", "replicas"); err != nil { + t.Fatal(err) + } + if err := manager.client.Update(ctx, existing); err != nil { + t.Fatal(err) + } + + ann := deploy.GetAnnotations() + if ann == nil { + ann = map[string]string{} + } + ann["example.com/cse-add"] = "true" + deploy.SetAnnotations(ann) + + entry, err := manager.Apply(ctx, deploy, ignoreReplicasOpts) + if err != nil { + t.Fatal(err) + } + + if entry.Action != ConfiguredAction { + t.Errorf("expected ConfiguredAction, got %s", entry.Action) + } + }) + + t.Run("remove non-ignored field", func(t *testing.T) { + existing := deploy.DeepCopy() + if err := manager.client.Get(ctx, client.ObjectKeyFromObject(existing), existing); err != nil { + t.Fatal(err) + } + if err := unstructured.SetNestedField(existing.Object, int64(10), "spec", "replicas"); err != nil { + t.Fatal(err) + } + if err := manager.client.Update(ctx, existing); err != nil { + t.Fatal(err) + } + + ann := deploy.GetAnnotations() + delete(ann, "example.com/cse-add") + deploy.SetAnnotations(ann) + + entry, err := manager.Apply(ctx, deploy, ignoreReplicasOpts) + if err != nil { + t.Fatal(err) + } + + if entry.Action != ConfiguredAction { + t.Errorf("expected ConfiguredAction, got %s", entry.Action) + } + }) + + t.Run("no change to non-ignored fields returns unchanged", func(t *testing.T) { + existing := deploy.DeepCopy() + if err := manager.client.Get(ctx, client.ObjectKeyFromObject(existing), existing); err != nil { + t.Fatal(err) + } + if err := unstructured.SetNestedField(existing.Object, int64(10), "spec", "replicas"); err != nil { + t.Fatal(err) + } + if err := manager.client.Update(ctx, existing); err != nil { + t.Fatal(err) + } + + entry, err := manager.Apply(ctx, deploy, ignoreReplicasOpts) + if err != nil { + t.Fatal(err) + } + + if entry.Action != UnchangedAction { + t.Errorf("expected UnchangedAction when only ignored fields differ (client-side), got %s", entry.Action) + } + }) + }) + + t.Run("no drift in ignored field", func(t *testing.T) { + id := generateName("drift-none") + ns := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "Namespace", + "metadata": map[string]interface{}{"name": id}, + }, + } + if _, err := manager.Apply(ctx, ns, DefaultApplyOptions()); err != nil { + t.Fatal(err) + } + + deploy := driftIgnoreTestDeployment(id) + if _, err := manager.Apply(ctx, deploy, DefaultApplyOptions()); err != nil { + t.Fatal(err) + } + + // No one changes replicas — it stays at Flux's value of 2. + + t.Run("mutate non-ignored field", func(t *testing.T) { + err := unstructured.SetNestedField(deploy.Object, int64(20), "spec", "minReadySeconds") + if err != nil { + t.Fatal(err) + } + + entry, err := manager.Apply(ctx, deploy, ignoreReplicasOpts) + if err != nil { + t.Fatal(err) + } + + if entry.Action != ConfiguredAction { + t.Errorf("expected ConfiguredAction, got %s", entry.Action) + } + + // Flux should STILL own replicas (not drifted, not stripped). + existing := deploy.DeepCopy() + if err := manager.client.Get(ctx, client.ObjectKeyFromObject(existing), existing); err != nil { + t.Fatal(err) + } + fluxOwnsReplicas := false + for _, mf := range existing.GetManagedFields() { + if mf.Manager == manager.owner.Field && mf.Operation == metav1.ManagedFieldsOperationApply { + if mf.FieldsV1 != nil && strings.Contains(string(mf.FieldsV1.Raw), "f:replicas") { + fluxOwnsReplicas = true + } + } + } + if !fluxOwnsReplicas { + t.Errorf("expected Flux to still own spec.replicas (not drifted, should be kept)") + } + }) + + t.Run("add non-ignored field", func(t *testing.T) { + ann := deploy.GetAnnotations() + if ann == nil { + ann = map[string]string{} + } + ann["example.com/nodrift-add"] = "true" + deploy.SetAnnotations(ann) + + entry, err := manager.Apply(ctx, deploy, ignoreReplicasOpts) + if err != nil { + t.Fatal(err) + } + + if entry.Action != ConfiguredAction { + t.Errorf("expected ConfiguredAction, got %s", entry.Action) + } + + // Flux should still own replicas. + existing := deploy.DeepCopy() + if err := manager.client.Get(ctx, client.ObjectKeyFromObject(existing), existing); err != nil { + t.Fatal(err) + } + for _, mf := range existing.GetManagedFields() { + if mf.Manager == manager.owner.Field && mf.Operation == metav1.ManagedFieldsOperationApply { + if mf.FieldsV1 != nil && !strings.Contains(string(mf.FieldsV1.Raw), "f:replicas") { + t.Errorf("expected Flux to still own spec.replicas") + } + } + } + }) + + t.Run("remove non-ignored field", func(t *testing.T) { + ann := deploy.GetAnnotations() + delete(ann, "example.com/nodrift-add") + deploy.SetAnnotations(ann) + + entry, err := manager.Apply(ctx, deploy, ignoreReplicasOpts) + if err != nil { + t.Fatal(err) + } + + if entry.Action != ConfiguredAction { + t.Errorf("expected ConfiguredAction, got %s", entry.Action) + } + + // Flux should still own replicas. + existing := deploy.DeepCopy() + if err := manager.client.Get(ctx, client.ObjectKeyFromObject(existing), existing); err != nil { + t.Fatal(err) + } + for _, mf := range existing.GetManagedFields() { + if mf.Manager == manager.owner.Field && mf.Operation == metav1.ManagedFieldsOperationApply { + if mf.FieldsV1 != nil && !strings.Contains(string(mf.FieldsV1.Raw), "f:replicas") { + t.Errorf("expected Flux to still own spec.replicas") + } + } + } + }) + + t.Run("no change to non-ignored fields returns unchanged", func(t *testing.T) { + entry, err := manager.Apply(ctx, deploy, ignoreReplicasOpts) + if err != nil { + t.Fatal(err) + } + + if entry.Action != UnchangedAction { + t.Errorf("expected UnchangedAction, got %s", entry.Action) + } + }) + }) + + t.Run("ApplyAll variants", func(t *testing.T) { + id := generateName("drift-aa") + objects, err := readManifest("testdata/test2.yaml", id) + if err != nil { + t.Fatal(err) + } + + manager.SetOwnerLabels(objects, "app1", "default") + if err := normalize.UnstructuredList(objects); err != nil { + t.Fatal(err) + } + + _, deployObject := getFirstObject(objects, "Deployment", id) + + // Create all objects first. + changeSet, err := manager.ApplyAllStaged(ctx, objects, DefaultApplyOptions()) + if err != nil { + t.Fatal(err) + } + for _, entry := range changeSet.Entries { + if diff := cmp.Diff(CreatedAction, entry.Action); diff != "" { + t.Errorf("Mismatch from expected value (-want +got):\n%s", diff) + } + } + + t.Run("VPA claimed and mutate via ApplyAll", func(t *testing.T) { + vpaClaimReplicas(ctx, t, id, 5) + + err := unstructured.SetNestedField(deployObject.Object, int64(40), "spec", "minReadySeconds") + if err != nil { + t.Fatal(err) + } + + cs, err := manager.ApplyAll(ctx, objects, ignoreReplicasOpts) + if err != nil { + t.Fatal(err) + } + + if len(cs.Entries) == 0 { + t.Fatal("expected non-empty change set") + } + + // Verify Flux no longer owns replicas on the Deployment. + existing := deployObject.DeepCopy() + if err := manager.client.Get(ctx, client.ObjectKeyFromObject(existing), existing); err != nil { + t.Fatal(err) + } + for _, mf := range existing.GetManagedFields() { + if mf.Manager == manager.owner.Field && mf.Operation == metav1.ManagedFieldsOperationApply { + if mf.FieldsV1 != nil && strings.Contains(string(mf.FieldsV1.Raw), "f:replicas") { + t.Errorf("expected Flux to not own spec.replicas after ApplyAll") + } + } + } + }) + + t.Run("VPA claimed and no change via ApplyAll returns unchanged", func(t *testing.T) { + vpaClaimReplicas(ctx, t, id, 5) + + cs, err := manager.ApplyAll(ctx, objects, ignoreReplicasOpts) + if err != nil { + t.Fatal(err) + } + + for _, entry := range cs.Entries { + if entry.Action != UnchangedAction { + t.Errorf("expected UnchangedAction for %s, got %s", entry.Subject, entry.Action) + } + } + }) + + t.Run("no drift in ignored field and mutate via ApplyAll", func(t *testing.T) { + // Re-apply without VPA drift (Flux owns replicas from previous applies). + err := unstructured.SetNestedField(deployObject.Object, int64(2), "spec", "replicas") + if err != nil { + t.Fatal(err) + } + _, err = manager.Apply(ctx, deployObject, DefaultApplyOptions()) + if err != nil { + t.Fatal(err) + } + + // Now apply via ApplyAll with a non-ignored change. Replicas has not drifted. + err = unstructured.SetNestedField(deployObject.Object, int64(42), "spec", "minReadySeconds") + if err != nil { + t.Fatal(err) + } + + cs, err := manager.ApplyAll(ctx, objects, ignoreReplicasOpts) + if err != nil { + t.Fatal(err) + } + + if len(cs.Entries) == 0 { + t.Fatal("expected non-empty change set") + } + + // Flux should still own replicas (not drifted → not stripped). + existing := deployObject.DeepCopy() + if err := manager.client.Get(ctx, client.ObjectKeyFromObject(existing), existing); err != nil { + t.Fatal(err) + } + fluxOwnsReplicas := false + for _, mf := range existing.GetManagedFields() { + if mf.Manager == manager.owner.Field && mf.Operation == metav1.ManagedFieldsOperationApply { + if mf.FieldsV1 != nil && strings.Contains(string(mf.FieldsV1.Raw), "f:replicas") { + fluxOwnsReplicas = true + } + } + } + if !fluxOwnsReplicas { + t.Errorf("expected Flux to still own spec.replicas via ApplyAll (not drifted)") + } + }) + }) +} + +func TestApply_DriftIgnoreRules_TwoPhaseOwnershipLifecycle(t *testing.T) { + timeout := 60 * time.Second + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + id := generateName("drift-2ph") + ns := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "Namespace", + "metadata": map[string]interface{}{"name": id}, + }, + } + if _, err := manager.Apply(ctx, ns, DefaultApplyOptions()); err != nil { + t.Fatal(err) + } + + deploy := driftIgnoreTestDeployment(id) + + ignoreReplicasOpts := DefaultApplyOptions() + ignoreReplicasOpts.DriftIgnoreRules = []jsondiff.IgnoreRule{ + { + Paths: []string{"/spec/replicas"}, + Selector: &jsondiff.Selector{ + Kind: "Deployment", + }, + }, + } + + t.Run("creates deployment", func(t *testing.T) { + entry, err := manager.Apply(ctx, deploy, DefaultApplyOptions()) + if err != nil { + t.Fatal(err) + } + if entry.Action != CreatedAction { + t.Errorf("expected CreatedAction, got %s", entry.Action) + } + + existing := deploy.DeepCopy() + if err := manager.client.Get(ctx, client.ObjectKeyFromObject(existing), existing); err != nil { + t.Fatal(err) + } + fluxOwnsReplicas := false + for _, mf := range existing.GetManagedFields() { + if mf.Manager == manager.owner.Field && mf.Operation == metav1.ManagedFieldsOperationApply { + if mf.FieldsV1 != nil && strings.Contains(string(mf.FieldsV1.Raw), "f:replicas") { + fluxOwnsReplicas = true + } + } + } + if !fluxOwnsReplicas { + t.Errorf("expected Flux to own spec.replicas after create") + } + }) + + t.Run("HPA co-owns replicas without force", func(t *testing.T) { + // HPA applies with replicas=2 (same value as Flux) without ForceOwnership. + // SSA allows this because the values agree — both managers co-own the field. + hpaObj := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": map[string]interface{}{ + "name": id, + "namespace": id, + }, + "spec": map[string]interface{}{ + "replicas": int64(2), + "selector": map[string]interface{}{ + "matchLabels": map[string]interface{}{ + "app": id, + }, + }, + "template": map[string]interface{}{ + "metadata": map[string]interface{}{ + "labels": map[string]interface{}{ + "app": id, + }, + }, + "spec": map[string]interface{}{ + "containers": []interface{}{ + map[string]interface{}{ + "name": "podinfod", + "image": "ghcr.io/stefanprodan/podinfo:6.0.0", + }, + }, + }, + }, + }, + }, + } + err := manager.client.Patch(ctx, hpaObj, client.Apply, + client.FieldOwner("hpa-controller")) + if err != nil { + t.Fatal(err) + } + + existing := deploy.DeepCopy() + if err := manager.client.Get(ctx, client.ObjectKeyFromObject(existing), existing); err != nil { + t.Fatal(err) + } + fluxOwns := false + hpaOwns := false + for _, mf := range existing.GetManagedFields() { + if mf.FieldsV1 != nil && strings.Contains(string(mf.FieldsV1.Raw), "f:replicas") { + if mf.Manager == manager.owner.Field && mf.Operation == metav1.ManagedFieldsOperationApply { + fluxOwns = true + } + if mf.Manager == "hpa-controller" && mf.Operation == metav1.ManagedFieldsOperationApply { + hpaOwns = true + } + } + } + if !fluxOwns { + t.Errorf("expected Flux to co-own spec.replicas") + } + if !hpaOwns { + t.Errorf("expected hpa-controller to co-own spec.replicas") + } + }) + + t.Run("unchanged when no non-ignored fields changed", func(t *testing.T) { + // Both Flux and HPA co-own replicas with the same value. + // Flux applies with ignore rule and no non-ignored changes → UnchangedAction. + entry, err := manager.Apply(ctx, deploy, ignoreReplicasOpts) + if err != nil { + t.Fatal(err) + } + if entry.Action != UnchangedAction { + t.Errorf("expected UnchangedAction, got %s", entry.Action) + } + }) + + t.Run("flux still co-owns ignored field after unchanged", func(t *testing.T) { + // Since no apply was sent to the API server, Flux's co-ownership + // of spec.replicas should be preserved. + existing := deploy.DeepCopy() + if err := manager.client.Get(ctx, client.ObjectKeyFromObject(existing), existing); err != nil { + t.Fatal(err) + } + fluxOwns := false + hpaOwns := false + for _, mf := range existing.GetManagedFields() { + if mf.FieldsV1 != nil && strings.Contains(string(mf.FieldsV1.Raw), "f:replicas") { + if mf.Manager == manager.owner.Field && mf.Operation == metav1.ManagedFieldsOperationApply { + fluxOwns = true + } + if mf.Manager == "hpa-controller" && mf.Operation == metav1.ManagedFieldsOperationApply { + hpaOwns = true + } + } + } + if !fluxOwns { + t.Errorf("expected Flux to still co-own spec.replicas after UnchangedAction (no apply sent to API server)") + } + if !hpaOwns { + t.Errorf("expected hpa-controller to still co-own spec.replicas") + } + }) + + t.Run("HPA force-claims replicas introducing drift", func(t *testing.T) { + // HPA changes replicas to 5 via ForceOwnership. This introduces + // real drift and steals sole ownership from Flux. + vpaClaimReplicas(ctx, t, id, 5) + + existing := deploy.DeepCopy() + if err := manager.client.Get(ctx, client.ObjectKeyFromObject(existing), existing); err != nil { + t.Fatal(err) + } + replicas, _, _ := unstructured.NestedInt64(existing.Object, "spec", "replicas") + if replicas != 5 { + t.Fatalf("expected spec.replicas=5 after force claim, got %d", replicas) + } + }) + + t.Run("non-ignored change triggers apply and drops ownership", func(t *testing.T) { + err := unstructured.SetNestedField(deploy.Object, int64(50), "spec", "minReadySeconds") + if err != nil { + t.Fatal(err) + } + + entry, err := manager.Apply(ctx, deploy, ignoreReplicasOpts) + if err != nil { + t.Fatal(err) + } + if entry.Action != ConfiguredAction { + t.Errorf("expected ConfiguredAction, got %s", entry.Action) + } + + existing := deploy.DeepCopy() + if err := manager.client.Get(ctx, client.ObjectKeyFromObject(existing), existing); err != nil { + t.Fatal(err) + } + + // VPA's value should be preserved. + replicas, _, _ := unstructured.NestedInt64(existing.Object, "spec", "replicas") + if replicas != 5 { + t.Errorf("expected spec.replicas=5 (VPA value), got %d", replicas) + } + + // Flux should no longer own replicas. + for _, mf := range existing.GetManagedFields() { + if mf.Manager == manager.owner.Field && mf.Operation == metav1.ManagedFieldsOperationApply { + if mf.FieldsV1 != nil && strings.Contains(string(mf.FieldsV1.Raw), "f:replicas") { + t.Errorf("expected Flux to no longer own spec.replicas after real apply") + } + } + } + }) + + t.Run("subsequent unchanged confirms ownership dropped", func(t *testing.T) { + entry, err := manager.Apply(ctx, deploy, ignoreReplicasOpts) + if err != nil { + t.Fatal(err) + } + if entry.Action != UnchangedAction { + t.Errorf("expected UnchangedAction, got %s", entry.Action) + } + + existing := deploy.DeepCopy() + if err := manager.client.Get(ctx, client.ObjectKeyFromObject(existing), existing); err != nil { + t.Fatal(err) + } + for _, mf := range existing.GetManagedFields() { + if mf.Manager == manager.owner.Field && mf.Operation == metav1.ManagedFieldsOperationApply { + if mf.FieldsV1 != nil && strings.Contains(string(mf.FieldsV1.Raw), "f:replicas") { + t.Errorf("expected Flux to still not own spec.replicas") + } + } + } + }) +} + +func TestApply_DriftIgnoreRules_SharedMutableCoOwnership(t *testing.T) { + timeout := 60 * time.Second + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + id := generateName("drift-co") + ns := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "Namespace", + "metadata": map[string]interface{}{"name": id}, + }, + } + if _, err := manager.Apply(ctx, ns, DefaultApplyOptions()); err != nil { + t.Fatal(err) + } + + deploy := driftIgnoreTestDeployment(id) + + ignoreReplicasOpts := DefaultApplyOptions() + ignoreReplicasOpts.DriftIgnoreRules = []jsondiff.IgnoreRule{ + { + Paths: []string{"/spec/replicas"}, + Selector: &jsondiff.Selector{ + Kind: "Deployment", + }, + }, + } + + // Minimal Deployment for HPA SSA patches. + hpaObj := func(replicas int64) *unstructured.Unstructured { + return &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": map[string]interface{}{ + "name": id, + "namespace": id, + }, + "spec": map[string]interface{}{ + "replicas": replicas, + "selector": map[string]interface{}{ + "matchLabels": map[string]interface{}{ + "app": id, + }, + }, + "template": map[string]interface{}{ + "metadata": map[string]interface{}{ + "labels": map[string]interface{}{ + "app": id, + }, + }, + "spec": map[string]interface{}{ + "containers": []interface{}{ + map[string]interface{}{ + "name": "podinfod", + "image": "ghcr.io/stefanprodan/podinfo:6.0.0", + }, + }, + }, + }, + }, + }, + } + } + + t.Run("creates deployment", func(t *testing.T) { + entry, err := manager.Apply(ctx, deploy, DefaultApplyOptions()) + if err != nil { + t.Fatal(err) + } + if entry.Action != CreatedAction { + t.Errorf("expected CreatedAction, got %s", entry.Action) + } + }) + + t.Run("other controller co-owns replicas without force", func(t *testing.T) { + // HPA applies with replicas=2 (same value as Flux) without ForceOwnership. + // SSA allows this because the values agree — both managers co-own the field. + err := manager.client.Patch(ctx, hpaObj(2), client.Apply, + client.FieldOwner("hpa-controller")) + if err != nil { + t.Fatal(err) + } + + existing := deploy.DeepCopy() + if err := manager.client.Get(ctx, client.ObjectKeyFromObject(existing), existing); err != nil { + t.Fatal(err) + } + + fluxOwns := false + hpaOwns := false + for _, mf := range existing.GetManagedFields() { + if mf.FieldsV1 != nil && strings.Contains(string(mf.FieldsV1.Raw), "f:replicas") { + if mf.Manager == manager.owner.Field && mf.Operation == metav1.ManagedFieldsOperationApply { + fluxOwns = true + } + if mf.Manager == "hpa-controller" && mf.Operation == metav1.ManagedFieldsOperationApply { + hpaOwns = true + } + } + } + if !fluxOwns { + t.Errorf("expected Flux to co-own spec.replicas") + } + if !hpaOwns { + t.Errorf("expected hpa-controller to co-own spec.replicas") + } + }) + + t.Run("other controller changes replicas with force", func(t *testing.T) { + // HPA force-applies replicas=10 to introduce real drift and take sole ownership. + err := manager.client.Patch(ctx, hpaObj(10), client.Apply, + client.FieldOwner("hpa-controller"), client.ForceOwnership) + if err != nil { + t.Fatal(err) + } + + existing := deploy.DeepCopy() + if err := manager.client.Get(ctx, client.ObjectKeyFromObject(existing), existing); err != nil { + t.Fatal(err) + } + replicas, _, _ := unstructured.NestedInt64(existing.Object, "spec", "replicas") + if replicas != 10 { + t.Fatalf("expected spec.replicas=10 after HPA force claim, got %d", replicas) + } + }) + + t.Run("flux apply with ignore rule drops co-ownership", func(t *testing.T) { + err := unstructured.SetNestedField(deploy.Object, int64(55), "spec", "minReadySeconds") + if err != nil { + t.Fatal(err) + } + + entry, err := manager.Apply(ctx, deploy, ignoreReplicasOpts) + if err != nil { + t.Fatal(err) + } + if entry.Action != ConfiguredAction { + t.Errorf("expected ConfiguredAction, got %s", entry.Action) + } + + existing := deploy.DeepCopy() + if err := manager.client.Get(ctx, client.ObjectKeyFromObject(existing), existing); err != nil { + t.Fatal(err) + } + + // HPA value should be preserved. + replicas, _, _ := unstructured.NestedInt64(existing.Object, "spec", "replicas") + if replicas != 10 { + t.Errorf("expected spec.replicas=10 (HPA value preserved), got %d", replicas) + } + + // Flux should no longer own replicas; HPA should be the sole owner. + fluxOwns := false + hpaOwns := false + for _, mf := range existing.GetManagedFields() { + if mf.FieldsV1 != nil && strings.Contains(string(mf.FieldsV1.Raw), "f:replicas") { + if mf.Manager == manager.owner.Field && mf.Operation == metav1.ManagedFieldsOperationApply { + fluxOwns = true + } + if mf.Manager == "hpa-controller" && mf.Operation == metav1.ManagedFieldsOperationApply { + hpaOwns = true + } + } + } + if fluxOwns { + t.Errorf("expected Flux to no longer own spec.replicas") + } + if !hpaOwns { + t.Errorf("expected hpa-controller to retain sole ownership of spec.replicas") + } + }) +} + +func TestApply_DriftIgnoreRules_ClientSideEditConsequences(t *testing.T) { + timeout := 60 * time.Second + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + t.Run("optional field adopts in-cluster value when Flux is sole owner", func(t *testing.T) { + id := generateName("drift-cse-opt") + ns := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "Namespace", + "metadata": map[string]interface{}{"name": id}, + }, + } + if _, err := manager.Apply(ctx, ns, DefaultApplyOptions()); err != nil { + t.Fatal(err) + } + + deploy := driftIgnoreTestDeployment(id) + + // Create the deployment — Flux owns replicas=2. + entry, err := manager.Apply(ctx, deploy, DefaultApplyOptions()) + if err != nil { + t.Fatal(err) + } + if entry.Action != CreatedAction { + t.Fatalf("expected CreatedAction, got %s", entry.Action) + } + + // Client-side edit changes replicas to 10 (no SSA manager). + existing := deploy.DeepCopy() + if err := manager.client.Get(ctx, client.ObjectKeyFromObject(existing), existing); err != nil { + t.Fatal(err) + } + if err := unstructured.SetNestedField(existing.Object, int64(10), "spec", "replicas"); err != nil { + t.Fatal(err) + } + if err := manager.client.Update(ctx, existing); err != nil { + t.Fatal(err) + } + + // Flux applies with ignore rule and a non-ignored change. + ignoreReplicasOpts := DefaultApplyOptions() + ignoreReplicasOpts.DriftIgnoreRules = []jsondiff.IgnoreRule{ + { + Paths: []string{"/spec/replicas"}, + Selector: &jsondiff.Selector{ + Kind: "Deployment", + }, + }, + } + if err := unstructured.SetNestedField(deploy.Object, int64(60), "spec", "minReadySeconds"); err != nil { + t.Fatal(err) + } + + entry, err = manager.Apply(ctx, deploy, ignoreReplicasOpts) + if err != nil { + t.Fatal(err) + } + if entry.Action != ConfiguredAction { + t.Errorf("expected ConfiguredAction, got %s", entry.Action) + } + + // The in-cluster value (10) should be preserved via adopt, not reverted + // to Flux's desired (2) or the API server default (1). + existing = deploy.DeepCopy() + if err := manager.client.Get(ctx, client.ObjectKeyFromObject(existing), existing); err != nil { + t.Fatal(err) + } + replicas, found, _ := unstructured.NestedInt64(existing.Object, "spec", "replicas") + if !found { + t.Fatal("expected spec.replicas to exist") + } + if replicas != 10 { + t.Errorf("expected spec.replicas=10 (in-cluster value adopted), got %d", replicas) + } + + // Flux should still own replicas (adopted, not stripped). + fluxOwnsReplicas := false + for _, mf := range existing.GetManagedFields() { + if mf.Manager == manager.owner.Field && mf.Operation == metav1.ManagedFieldsOperationApply { + if mf.FieldsV1 != nil && strings.Contains(string(mf.FieldsV1.Raw), "f:replicas") { + fluxOwnsReplicas = true + } + } + } + if !fluxOwnsReplicas { + t.Errorf("expected Flux to still own spec.replicas (adopted, not stripped)") + } + }) + + t.Run("required field adopts in-cluster value when Flux is sole owner", func(t *testing.T) { + id := generateName("drift-cse-req") + ns := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "Namespace", + "metadata": map[string]interface{}{"name": id}, + }, + } + if _, err := manager.Apply(ctx, ns, DefaultApplyOptions()); err != nil { + t.Fatal(err) + } + + deploy := driftIgnoreTestDeployment(id) + + // Create the deployment — Flux owns the container image. + entry, err := manager.Apply(ctx, deploy, DefaultApplyOptions()) + if err != nil { + t.Fatal(err) + } + if entry.Action != CreatedAction { + t.Fatalf("expected CreatedAction, got %s", entry.Action) + } + + // Client-side edit changes the image (no SSA manager). + existing := deploy.DeepCopy() + if err := manager.client.Get(ctx, client.ObjectKeyFromObject(existing), existing); err != nil { + t.Fatal(err) + } + containers, _, _ := unstructured.NestedSlice(existing.Object, "spec", "template", "spec", "containers") + if len(containers) > 0 { + c0 := containers[0].(map[string]interface{}) + c0["image"] = "ghcr.io/stefanprodan/podinfo:6.5.0" + containers[0] = c0 + if err := unstructured.SetNestedSlice(existing.Object, containers, "spec", "template", "spec", "containers"); err != nil { + t.Fatal(err) + } + } + if err := manager.client.Update(ctx, existing); err != nil { + t.Fatal(err) + } + + // Flux applies with ignore rule for image and a non-ignored change. + ignoreImageOpts := DefaultApplyOptions() + ignoreImageOpts.DriftIgnoreRules = []jsondiff.IgnoreRule{ + { + Paths: []string{"/spec/template/spec/containers/0/image"}, + Selector: &jsondiff.Selector{ + Kind: "Deployment", + }, + }, + } + if err := unstructured.SetNestedField(deploy.Object, int64(70), "spec", "minReadySeconds"); err != nil { + t.Fatal(err) + } + + // With adopt behavior, the apply should always succeed — the in-cluster + // image value is copied into the payload, avoiding the missing-required-field error. + entry, err = manager.Apply(ctx, deploy, ignoreImageOpts) + if err != nil { + t.Fatalf("expected apply to succeed with adopt, got error: %v", err) + } + if entry.Action != ConfiguredAction { + t.Errorf("expected ConfiguredAction, got %s", entry.Action) + } + + // The in-cluster image (6.5.0) should be preserved. + existing = deploy.DeepCopy() + if err := manager.client.Get(ctx, client.ObjectKeyFromObject(existing), existing); err != nil { + t.Fatal(err) + } + containers, found, _ := unstructured.NestedSlice(existing.Object, "spec", "template", "spec", "containers") + if !found || len(containers) == 0 { + t.Fatal("expected containers to exist") + } + c0 := containers[0].(map[string]interface{}) + if c0["image"] != "ghcr.io/stefanprodan/podinfo:6.5.0" { + t.Errorf("expected client-side image 6.5.0 to be preserved via adopt, got %v", c0["image"]) + } + }) +} + +func TestApply_DriftIgnoreRules_FieldManagerCleanupInteraction(t *testing.T) { + timeout := 60 * time.Second + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + id := generateName("drift-cleanup") + ns := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "Namespace", + "metadata": map[string]interface{}{"name": id}, + }, + } + if _, err := manager.Apply(ctx, ns, DefaultApplyOptions()); err != nil { + t.Fatal(err) + } + + deploy := driftIgnoreTestDeployment(id) + + // Configure ignore rules from the start — replicas is always ignored. + ignoreReplicasOpts := DefaultApplyOptions() + ignoreReplicasOpts.DriftIgnoreRules = []jsondiff.IgnoreRule{ + { + Paths: []string{"/spec/replicas"}, + Selector: &jsondiff.Selector{ + Kind: "Deployment", + }, + }, + } + + // Create the deployment with ignore rules — Flux owns all fields including replicas=2. + if _, err := manager.Apply(ctx, deploy, ignoreReplicasOpts); err != nil { + t.Fatal(err) + } + + // VPA claims replicas via ForceOwnership. + vpaClaimReplicas(ctx, t, id, 5) + + // Verify VPA owns replicas in-cluster. + existing := deploy.DeepCopy() + if err := manager.client.Get(ctx, client.ObjectKeyFromObject(existing), existing); err != nil { + t.Fatal(err) + } + replicas, _, _ := unstructured.NestedInt64(existing.Object, "spec", "replicas") + if replicas != 5 { + t.Fatalf("expected spec.replicas=5 after VPA claim, got %d", replicas) + } + + // Now add FieldManagers cleanup to remove vpa-controller. + // This simulates: "ignore VPA's replicas, but also clean up VPA's managed fields entry" + opts := ignoreReplicasOpts + opts.Cleanup = ApplyCleanupOptions{ + FieldManagers: []FieldManager{ + { + Name: "vpa-controller", + OperationType: metav1.ManagedFieldsOperationApply, + }, + }, + } + + // Trigger a non-ignored change so the apply actually fires. + if err := unstructured.SetNestedField(deploy.Object, int64(80), "spec", "minReadySeconds"); err != nil { + t.Fatal(err) + } + + t.Run("cleanup removes manager then drift adopts in-cluster value", func(t *testing.T) { + // After cleanupMetadata removes VPA's managed fields entry on the server, + // computeDriftedPaths should see that no other Apply manager owns replicas + // and adopt the in-cluster value (5) instead of stripping it. + // Without the fix, existingObject is stale (still has VPA entry), + // so the code incorrectly strips replicas. + entry, err := manager.Apply(ctx, deploy, opts) + if err != nil { + t.Fatalf("expected apply to succeed, got error: %v", err) + } + if entry.Action != ConfiguredAction { + t.Errorf("expected ConfiguredAction, got %s", entry.Action) + } + + // The in-cluster replicas value (5) should be preserved via adopt, + // NOT stripped (which would orphan/default it). + existing := deploy.DeepCopy() + if err := manager.client.Get(ctx, client.ObjectKeyFromObject(existing), existing); err != nil { + t.Fatal(err) + } + gotReplicas, found, _ := unstructured.NestedInt64(existing.Object, "spec", "replicas") + if !found { + t.Fatal("expected spec.replicas to exist after apply") + } + if gotReplicas != 5 { + t.Errorf("expected spec.replicas=5 (in-cluster value adopted after manager cleanup), got %d", gotReplicas) + } + + // VPA's managed fields entry should be gone (cleaned up). + for _, mf := range existing.GetManagedFields() { + if mf.Manager == "vpa-controller" && mf.Operation == metav1.ManagedFieldsOperationApply { + t.Errorf("expected vpa-controller managed fields entry to be removed by cleanup") + } + } + + // Flux should own replicas (adopted into payload). + fluxOwnsReplicas := false + for _, mf := range existing.GetManagedFields() { + if mf.Manager == manager.owner.Field && mf.Operation == metav1.ManagedFieldsOperationApply { + if mf.FieldsV1 != nil && strings.Contains(string(mf.FieldsV1.Raw), "f:replicas") { + fluxOwnsReplicas = true + } + } + } + if !fluxOwnsReplicas { + t.Errorf("expected Flux to own spec.replicas (adopted after manager cleanup)") + } + }) +} + +// TestApply_DriftIgnoreRules_ContainerResources exercises the path-handling +// code with an ignore rule whose JSON pointer points to a non-leaf object +// inside a keyed list element — /spec/template/spec/containers/0/resources. +// This combination (array index + sub-object) is not covered elsewhere: +// existing tests use either a top-level leaf (/spec/replicas) or a leaf +// inside an array (/spec/template/spec/containers/0/image). The behavioral +// scenarios (release on other-manager claim, etc.) are already covered for +// those simpler paths; this test only adds the path shape that exercises +// lookupJSONPointer, GenerateRemovePatch + ApplyPatchToUnstructured, and +// isFieldOwnedByOtherApplyManager against an indexed sub-object claim under +// k:{"name":...} associative-list keys. +func TestApply_DriftIgnoreRules_ContainerResources(t *testing.T) { + timeout := 30 * time.Second + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + id := generateName("drift-resources") + objects, err := readManifest("testdata/test2.yaml", id) + if err != nil { + t.Fatal(err) + } + manager.SetOwnerLabels(objects, "app1", "default") + if err := normalize.UnstructuredList(objects); err != nil { + t.Fatal(err) + } + _, deployObject := getFirstObject(objects, "Deployment", id) + + opts := DefaultApplyOptions() + opts.DriftIgnoreRules = []jsondiff.IgnoreRule{ + { + Paths: []string{"/spec/template/spec/containers/0/resources"}, + Selector: &jsondiff.Selector{Kind: "Deployment"}, + }, + } + + // vpaPayload returns a minimal SSA payload whose podinfod container has + // the given resources block. Sending only name + resources for the + // container limits the manager's claim to those fields under + // containers[k:name=podinfod] in managedFields. + vpaPayload := func(resources map[string]interface{}) *unstructured.Unstructured { + container := map[string]interface{}{"name": "podinfod"} + if resources != nil { + container["resources"] = resources + } + return &unstructured.Unstructured{Object: map[string]interface{}{ + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": map[string]interface{}{ + "name": id, + "namespace": id, + }, + "spec": map[string]interface{}{ + "selector": map[string]interface{}{ + "matchLabels": map[string]interface{}{"app": id}, + }, + "template": map[string]interface{}{ + "metadata": map[string]interface{}{ + "labels": map[string]interface{}{"app": id}, + }, + "spec": map[string]interface{}{ + "containers": []interface{}{container}, + }, + }, + }, + }} + } + + containerResources := func(obj *unstructured.Unstructured) map[string]interface{} { + t.Helper() + containers, found, err := unstructured.NestedSlice(obj.Object, "spec", "template", "spec", "containers") + if err != nil || !found || len(containers) == 0 { + t.Fatalf("expected at least one container, got found=%v err=%v", found, err) + } + c0 := containers[0].(map[string]interface{}) + res, _ := c0["resources"].(map[string]interface{}) + return res + } + + // Initial create with Flux owning containers[0].resources from test2.yaml. + if _, err := manager.ApplyAllStaged(ctx, objects, opts); err != nil { + t.Fatal(err) + } + + // VPA force-claims containers[0].resources with different values. After + // this, Flux must release on the next reconcile because the path is in + // the ignore set and another Apply manager owns it. + vpaResources := map[string]interface{}{ + "limits": map[string]interface{}{"cpu": "4", "memory": "1Gi"}, + "requests": map[string]interface{}{"cpu": "2", "memory": "256Mi"}, + } + if err := manager.client.Patch(ctx, vpaPayload(vpaResources), client.Apply, + client.FieldOwner("vpa-controller"), client.ForceOwnership); err != nil { + t.Fatalf("VPA force-claim failed: %v", err) + } + + // Trigger a non-ignored drift so the apply path runs (and the strip + // logic exercises GenerateRemovePatch + ApplyPatchToUnstructured for + // the indexed sub-object pointer). isFieldOwnedByOtherApplyManager must + // walk vpa-controller's managedFields entry across the + // k:{"name":"podinfod"} associative key to detect the claim. + if err := unstructured.SetNestedField(deployObject.Object, int64(7), "spec", "minReadySeconds"); err != nil { + t.Fatal(err) + } + + entry, err := manager.Apply(ctx, deployObject, opts) + if err != nil { + t.Fatalf("apply failed: %v", err) + } + if entry.Action != ConfiguredAction { + t.Errorf("expected ConfiguredAction, got %s", entry.Action) + } + + existing := deployObject.DeepCopy() + if err := manager.client.Get(ctx, client.ObjectKeyFromObject(existing), existing); err != nil { + t.Fatal(err) + } + + // VPA's resource sub-object must be preserved end-to-end. + res := containerResources(existing) + if res == nil { + t.Fatal("expected containers[0].resources to be set") + } + limits, _ := res["limits"].(map[string]interface{}) + if limits["cpu"] != "4" || limits["memory"] != "1Gi" { + t.Errorf("expected VPA limits cpu=4 mem=1Gi to be preserved, got %v", limits) + } + requests, _ := res["requests"].(map[string]interface{}) + if requests["cpu"] != "2" || requests["memory"] != "256Mi" { + t.Errorf("expected VPA requests cpu=2 mem=256Mi to be preserved, got %v", requests) + } + + // Flux must no longer own resources under containers[k:name=podinfod]. + for _, mf := range existing.GetManagedFields() { + if mf.Manager != manager.owner.Field || mf.Operation != metav1.ManagedFieldsOperationApply || mf.FieldsV1 == nil { + continue + } + if strings.Contains(string(mf.FieldsV1.Raw), "\"f:resources\"") { + t.Errorf("expected Flux to release containers[0].resources to vpa-controller") + } + } +}