Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
Menu
Open sidebar
OpenDAS
dynamo
Commits
942070c2
Unverified
Commit
942070c2
authored
Apr 07, 2026
by
Julien Mancuso
Committed by
GitHub
Apr 07, 2026
Browse files
fix(operator): handle spec-less resources (ConfigMaps, Secrets, etc.) in SyncResource (#7953)
parent
3ad7b7c8
Changes
2
Show whitespace changes
Inline
Side-by-side
Showing
2 changed files
with
164 additions
and
21 deletions
+164
-21
deploy/operator/internal/controller_common/resource.go
deploy/operator/internal/controller_common/resource.go
+51
-21
deploy/operator/internal/controller_common/resource_test.go
deploy/operator/internal/controller_common/resource_test.go
+113
-0
No files found.
deploy/operator/internal/controller_common/resource.go
View file @
942070c2
...
@@ -215,56 +215,86 @@ func SyncResource[T client.Object](ctx context.Context, r Reconciler, parentReso
...
@@ -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
// 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
{
func
CopySpec
(
source
,
destination
client
.
Object
)
error
{
// Convert source to unstructured
sourceMap
,
err
:=
runtime
.
DefaultUnstructuredConverter
.
ToUnstructured
(
source
)
sourceMap
,
err
:=
runtime
.
DefaultUnstructuredConverter
.
ToUnstructured
(
source
)
if
err
!=
nil
{
if
err
!=
nil
{
return
err
return
err
}
}
sourceUnstructured
:=
&
unstructured
.
Unstructured
{
Object
:
sourceMap
}
sourceUnstructured
:=
&
unstructured
.
Unstructured
{
Object
:
sourceMap
}
// Convert destination to unstructured
destMap
,
err
:=
runtime
.
DefaultUnstructuredConverter
.
ToUnstructured
(
destination
)
destMap
,
err
:=
runtime
.
DefaultUnstructuredConverter
.
ToUnstructured
(
destination
)
if
err
!=
nil
{
if
err
!=
nil
{
return
err
return
err
}
}
destUnstructured
:=
&
unstructured
.
Unstructured
{
Object
:
destMap
}
destUnstructured
:=
&
unstructured
.
Unstructured
{
Object
:
destMap
}
// Extract only the spec from source
if
spec
,
found
,
err
:=
unstructured
.
NestedFieldCopy
(
sourceUnstructured
.
Object
,
"spec"
);
err
==
nil
&&
found
{
sourceSpec
,
found
,
err
:=
unstructured
.
NestedFieldCopy
(
sourceUnstructured
.
Object
,
"spec"
)
if
err
:=
unstructured
.
SetNestedField
(
destUnstructured
.
Object
,
spec
,
"spec"
);
err
!=
nil
{
if
err
!=
nil
{
return
err
return
err
}
}
if
!
found
{
return
runtime
.
DefaultUnstructuredConverter
.
FromUnstructured
(
destUnstructured
.
Object
,
destination
)
return
fmt
.
Errorf
(
"spec not found in source object"
)
}
}
// Set the spec in the destination
for
k
,
v
:=
range
nonEnvelopeFields
(
sourceUnstructured
.
Object
)
{
err
=
unstructured
.
SetNestedField
(
destUnstructured
.
Object
,
sourceSpec
,
"spec"
)
destUnstructured
.
Object
[
k
]
=
v
if
err
!=
nil
{
return
err
}
}
// Convert back to the original object
return
runtime
.
DefaultUnstructuredConverter
.
FromUnstructured
(
destUnstructured
.
Object
,
destination
)
return
runtime
.
DefaultUnstructuredConverter
.
FromUnstructured
(
destUnstructured
.
Object
,
destination
)
}
}
func
getSpec
(
obj
client
.
Object
)
(
any
,
error
)
{
func
getSpec
(
obj
client
.
Object
)
(
any
,
error
)
{
// Convert source to unstructured
sourceMap
,
err
:=
runtime
.
DefaultUnstructuredConverter
.
ToUnstructured
(
obj
)
sourceMap
,
err
:=
runtime
.
DefaultUnstructuredConverter
.
ToUnstructured
(
obj
)
if
err
!=
nil
{
if
err
!=
nil
{
return
nil
,
err
return
nil
,
err
}
}
sourceUnstructured
:=
&
unstructured
.
Unstructured
{
Object
:
sourceMap
}
sourceUnstructured
:=
&
unstructured
.
Unstructured
{
Object
:
sourceMap
}
// Extract only the spec from source
spec
,
found
,
err
:=
unstructured
.
NestedFieldCopy
(
sourceUnstructured
.
Object
,
"spec"
)
content
,
found
:=
getContentFields
(
sourceUnstructured
)
if
err
!=
nil
{
return
nil
,
err
}
if
!
found
{
if
!
found
{
return
nil
,
nil
return
nil
,
nil
}
}
return
spec
,
nil
return
content
,
nil
}
}
// SpecChangeResult contains the result of spec change detection
// SpecChangeResult contains the result of spec change detection
...
...
deploy/operator/internal/controller_common/resource_test.go
View file @
942070c2
...
@@ -770,3 +770,116 @@ func TestAppendUniqueImagePullSecrets(t *testing.T) {
...
@@ -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
))
})
}
}
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment