Unverified Commit 942070c2 authored by Julien Mancuso's avatar Julien Mancuso Committed by GitHub
Browse files

fix(operator): handle spec-less resources (ConfigMaps, Secrets, etc.) in SyncResource (#7953)

parent 3ad7b7c8
......@@ -215,56 +215,86 @@ func SyncResource[T client.Object](ctx context.Context, r Reconciler, parentReso
}
// CopySpec copies only the Spec field from source to destination using Unstructured
// kubeEnvelopeFields are standard top-level Kubernetes fields that don't
// represent the resource's desired state. Everything else (spec, data,
// rules, roleRef, subjects, etc.) is considered content.
var kubeEnvelopeFields = map[string]bool{
"apiVersion": true,
"kind": true,
"metadata": true,
"status": true,
}
// nonEnvelopeFields returns all top-level fields from an unstructured map
// except the Kubernetes envelope (apiVersion, kind, metadata, status).
func nonEnvelopeFields(obj map[string]interface{}) map[string]interface{} {
content := make(map[string]interface{}, len(obj))
for k, v := range obj {
if kubeEnvelopeFields[k] {
continue
}
content[k] = v
}
return content
}
// getContentFields returns all content fields from an unstructured object,
// i.e. everything except the Kubernetes envelope (apiVersion, kind, metadata, status).
// For resources with a "spec" field, it returns the spec directly for
// backward-compatible hashing. For spec-less resources (ConfigMaps, Secrets,
// Roles, etc.), it returns a map of all content fields.
func getContentFields(u *unstructured.Unstructured) (any, bool) {
if spec, found, err := unstructured.NestedFieldCopy(u.Object, "spec"); err == nil && found {
return spec, true
}
content := nonEnvelopeFields(u.Object)
if len(content) == 0 {
return nil, false
}
return content, true
}
func CopySpec(source, destination client.Object) error {
// Convert source to unstructured
sourceMap, err := runtime.DefaultUnstructuredConverter.ToUnstructured(source)
if err != nil {
return err
}
sourceUnstructured := &unstructured.Unstructured{Object: sourceMap}
// Convert destination to unstructured
destMap, err := runtime.DefaultUnstructuredConverter.ToUnstructured(destination)
if err != nil {
return err
}
destUnstructured := &unstructured.Unstructured{Object: destMap}
// Extract only the spec from source
sourceSpec, found, err := unstructured.NestedFieldCopy(sourceUnstructured.Object, "spec")
if err != nil {
return err
}
if !found {
return fmt.Errorf("spec not found in source object")
if spec, found, err := unstructured.NestedFieldCopy(sourceUnstructured.Object, "spec"); err == nil && found {
if err := unstructured.SetNestedField(destUnstructured.Object, spec, "spec"); err != nil {
return err
}
return runtime.DefaultUnstructuredConverter.FromUnstructured(destUnstructured.Object, destination)
}
// Set the spec in the destination
err = unstructured.SetNestedField(destUnstructured.Object, sourceSpec, "spec")
if err != nil {
return err
for k, v := range nonEnvelopeFields(sourceUnstructured.Object) {
destUnstructured.Object[k] = v
}
// Convert back to the original object
return runtime.DefaultUnstructuredConverter.FromUnstructured(destUnstructured.Object, destination)
}
func getSpec(obj client.Object) (any, error) {
// Convert source to unstructured
sourceMap, err := runtime.DefaultUnstructuredConverter.ToUnstructured(obj)
if err != nil {
return nil, err
}
sourceUnstructured := &unstructured.Unstructured{Object: sourceMap}
// Extract only the spec from source
spec, found, err := unstructured.NestedFieldCopy(sourceUnstructured.Object, "spec")
if err != nil {
return nil, err
}
content, found := getContentFields(sourceUnstructured)
if !found {
return nil, nil
}
return spec, nil
return content, nil
}
// SpecChangeResult contains the result of spec change detection
......
......@@ -770,3 +770,116 @@ func TestAppendUniqueImagePullSecrets(t *testing.T) {
})
}
}
func TestGetSpecChangeResult_ConfigMap(t *testing.T) {
baseHash := func(t *testing.T, obj client.Object) string {
t.Helper()
h, err := GetSpecHash(obj)
if err != nil {
t.Fatalf("GetSpecHash: %v", err)
}
return h
}
baseCM := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{Name: "test-cm", Namespace: "ns"},
Data: map[string]string{"script.py": "print('v1')"},
}
tests := []struct {
name string
current client.Object
desired client.Object
needsUpdate bool
}{
{
name: "same ConfigMap data does not need update",
current: func() client.Object {
cm := baseCM.DeepCopy()
cm.Annotations = map[string]string{
NvidiaAnnotationHashKey: baseHash(t, baseCM),
NvidiaAnnotationGenerationKey: "1",
}
cm.Generation = 1
return cm
}(),
desired: &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{Name: "test-cm", Namespace: "ns"},
Data: map[string]string{"script.py": "print('v1')"},
},
needsUpdate: false,
},
{
name: "changed ConfigMap data needs update",
current: func() client.Object {
cm := baseCM.DeepCopy()
cm.Annotations = map[string]string{
NvidiaAnnotationHashKey: baseHash(t, baseCM),
NvidiaAnnotationGenerationKey: "1",
}
cm.Generation = 1
return cm
}(),
desired: &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{Name: "test-cm", Namespace: "ns"},
Data: map[string]string{"script.py": "print('v2')"},
},
needsUpdate: true,
},
{
name: "metadata-only change does not need update",
current: func() client.Object {
cm := baseCM.DeepCopy()
cm.Annotations = map[string]string{
NvidiaAnnotationHashKey: baseHash(t, baseCM),
NvidiaAnnotationGenerationKey: "1",
}
cm.Generation = 1
return cm
}(),
desired: &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{Name: "different-name", Namespace: "ns", Labels: map[string]string{"foo": "bar"}},
Data: map[string]string{"script.py": "print('v1')"},
},
needsUpdate: false,
},
{
name: "added key needs update",
current: func() client.Object {
cm := baseCM.DeepCopy()
cm.Annotations = map[string]string{
NvidiaAnnotationHashKey: baseHash(t, baseCM),
NvidiaAnnotationGenerationKey: "1",
}
cm.Generation = 1
return cm
}(),
desired: &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{Name: "test-cm", Namespace: "ns"},
Data: map[string]string{"script.py": "print('v1')", "extra.py": "pass"},
},
needsUpdate: true,
},
{
name: "no hash annotation needs update (pre-upgrade resource)",
current: &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{Name: "test-cm", Namespace: "ns"},
Data: map[string]string{"script.py": "print('v1')"},
},
desired: &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{Name: "test-cm", Namespace: "ns"},
Data: map[string]string{"script.py": "print('v1')"},
},
needsUpdate: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
g := gomega.NewGomegaWithT(t)
result, err := GetSpecChangeResult(tt.current, tt.desired)
g.Expect(err).ToNot(gomega.HaveOccurred())
g.Expect(result.NeedsUpdate).To(gomega.Equal(tt.needsUpdate))
})
}
}
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment