-
Notifications
You must be signed in to change notification settings - Fork 37
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(core): track and apply resource changes through deep diffing
Up until now, we weren't really doing proper diffing when deciding whether to update managed resources - we just marked everything as "SYNCED". This patch adds a new delta package that does a proper deep comparison between desired and observed resource states, being careful about all the k8s metadata fields that we should ignore during comparisons (e.g `metadata.generation`, `metadta.revision` etc...) A key design aspect of this implementation is that `kro` only diffs fields that are explicitly defined in `ResourceGroup` - any field that is defaulted by other controllers or mutating webhooks are ignored. This ensures `kro` coexists seamlessly with other reconciliation systems without fighting over field ownership. The delta `Compare` function takes the desired and observed `unstructured.Unstructured` objects and returns a list of structured "differences", where each difference contains the full path to the changed field and its desired/observed values. It recursively walks both object trees in parallel, building pathstring like `spec.containers[0].image` to precisely identify where values diverge. The comparison handles type mismatches, nil vs empty maps/slices, value differences etc... Also patch also adds integration tests in suites/core for generic resource updates and in suites/ackekscluster for EKS-clutser-specific version updates. note that in both tests we mainly rely on the `metadata.revision` to validate that indeed the controller did make an spec update call.
- Loading branch information
Showing
7 changed files
with
914 additions
and
13 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,181 @@ | ||
// Copyright Amazon.com Inc. or its affiliates. All Rights Reserved. | ||
// | ||
// Licensed under the Apache License, Version 2.0 (the "License"). You may | ||
// not use this file except in compliance with the License. A copy of the | ||
// License is located at | ||
// | ||
// http://aws.amazon.com/apache2.0/ | ||
// | ||
// or in the "license" file accompanying this file. This file 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 delta | ||
|
||
import ( | ||
"fmt" | ||
|
||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" | ||
) | ||
|
||
// Difference represents a single field-level difference between two objects. | ||
// Path is the full path to the differing field (e.g. "spec.containers[0].image") | ||
// Desired and Observed contain the actual values that differ at that path. | ||
type Difference struct { | ||
// Path is the full path to the differing field (e.g. "spec.x.y.z" | ||
Path string `json:"path"` | ||
// Desired is the desired value at the path | ||
Desired interface{} `json:"desired"` | ||
// Observed is the observed value at the path | ||
Observed interface{} `json:"observed"` | ||
} | ||
|
||
// Compare takes desired and observed unstructured objects and returns a list of | ||
// their differences. It performs a deep comparison while being aware of Kubernetes | ||
// metadata specifics. The comparison: | ||
// | ||
// - Cleans metadata from both objects to avoid spurious differences | ||
// - Walks object trees in parallel to find actual value differences | ||
// - Builds path strings to precisely identify where differences occurs | ||
// - Handles type mismatches, nil values, and empty vs nil collections | ||
func Compare(desired, observed *unstructured.Unstructured) ([]Difference, error) { | ||
desiredCopy := desired.DeepCopy() | ||
observedCopy := observed.DeepCopy() | ||
|
||
cleanMetadata(desiredCopy) | ||
cleanMetadata(observedCopy) | ||
|
||
var differences []Difference | ||
walkCompare(desiredCopy.Object, observedCopy.Object, "", &differences) | ||
return differences, nil | ||
} | ||
|
||
// ignoredFields are Kubernetes metadata fields that should not trigger updates. | ||
var ignoredFields = []string{ | ||
"creationTimestamp", | ||
"deletionTimestamp", | ||
"generation", | ||
"resourceVersion", | ||
"selfLink", | ||
"uid", | ||
"managedFields", | ||
"ownerReferences", | ||
"finalizers", | ||
} | ||
|
||
// cleanMetadata removes Kubernetes metadata fields that should not trigger updates | ||
// like resourceVersion, creationTimestamp, etc. Also handles empty maps in | ||
// annotations and labels. This ensures we don't detect spurious changes based on | ||
// Kubernetes-managed fields. | ||
func cleanMetadata(obj *unstructured.Unstructured) { | ||
metadata, ok := obj.Object["metadata"].(map[string]interface{}) | ||
if !ok { | ||
// Maybe we should panic here, but for now just return | ||
return | ||
} | ||
|
||
if annotations, exists := metadata["annotations"].(map[string]interface{}); exists { | ||
if len(annotations) == 0 { | ||
delete(metadata, "annotations") | ||
} | ||
} | ||
|
||
if annotations, exists := metadata["labels"].(map[string]interface{}); exists { | ||
if len(annotations) == 0 { | ||
delete(metadata, "labels") | ||
} | ||
} | ||
|
||
for _, field := range ignoredFields { | ||
delete(metadata, field) | ||
} | ||
} | ||
|
||
// walkCompare recursively compares desired and observed values, recording any | ||
// differences found. It handles different types appropriately: | ||
// - For maps: recursively compares all keys/values | ||
// - For slices: checks length and recursively compares elements | ||
// - For primitives: directly compares values | ||
// | ||
// Records a Difference if values don't match or are of different types. | ||
func walkCompare(desired, observed interface{}, path string, differences *[]Difference) { | ||
switch d := desired.(type) { | ||
case map[string]interface{}: | ||
e, ok := observed.(map[string]interface{}) | ||
if !ok { | ||
*differences = append(*differences, Difference{ | ||
Path: path, | ||
Observed: observed, | ||
Desired: desired, | ||
}) | ||
return | ||
} | ||
walkMap(d, e, path, differences) | ||
|
||
case []interface{}: | ||
e, ok := observed.([]interface{}) | ||
if !ok { | ||
*differences = append(*differences, Difference{ | ||
Path: path, | ||
Observed: observed, | ||
Desired: desired, | ||
}) | ||
return | ||
} | ||
walkSlice(d, e, path, differences) | ||
|
||
default: | ||
if desired != observed { | ||
*differences = append(*differences, Difference{ | ||
Path: path, | ||
Observed: observed, | ||
Desired: desired, | ||
}) | ||
} | ||
} | ||
} | ||
|
||
// walkMap compares two maps recursively. For each key in desired: | ||
// | ||
// - If key missing in observed: records a difference | ||
// - If key exists: recursively compares values | ||
func walkMap(desired, observed map[string]interface{}, path string, differences *[]Difference) { | ||
for k, desiredVal := range desired { | ||
newPath := k | ||
if path != "" { | ||
newPath = fmt.Sprintf("%s.%s", path, k) | ||
} | ||
|
||
observedVal, exists := observed[k] | ||
if !exists && desiredVal != nil { | ||
*differences = append(*differences, Difference{ | ||
Path: newPath, | ||
Observed: nil, | ||
Desired: desiredVal, | ||
}) | ||
continue | ||
} | ||
|
||
walkCompare(desiredVal, observedVal, newPath, differences) | ||
} | ||
} | ||
|
||
// walkSlice compares two slices recursively: | ||
// - If lengths differ: records entire slice as different | ||
// - If lengths match: recursively compares elements | ||
func walkSlice(desired, observed []interface{}, path string, differences *[]Difference) { | ||
if len(desired) != len(observed) { | ||
*differences = append(*differences, Difference{ | ||
Path: path, | ||
Observed: observed, | ||
Desired: desired, | ||
}) | ||
return | ||
} | ||
|
||
for i := range desired { | ||
newPath := fmt.Sprintf("%s[%d]", path, i) | ||
walkCompare(desired[i], observed[i], newPath, differences) | ||
} | ||
} |
Oops, something went wrong.