-
Notifications
You must be signed in to change notification settings - Fork 567
OCPBUGS-86949: Guard HCCO KubeletConfig CM deletion against transient source absence #8672
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2997,6 +2997,14 @@ func (r *reconciler) reconcileKubeletConfig(ctx context.Context) error { | |
| if err := r.deleteImmutableConfigMapIfNeeded(ctx, log, hostedClusterCM); err != nil { | ||
| return err | ||
| } | ||
| // DeleteIfNeededWithPredicate populates hostedClusterCM via Get with all server-side | ||
| // fields. Reinitialize to avoid leaking stale fields into the subsequent CreateOrUpdate. | ||
| hostedClusterCM = &corev1.ConfigMap{ | ||
| ObjectMeta: metav1.ObjectMeta{ | ||
| Name: cm.Name, | ||
| Namespace: ConfigManagedNamespace, | ||
| }, | ||
| } | ||
|
|
||
| if result, err := r.CreateOrUpdate(ctx, r.client, hostedClusterCM, func() error { | ||
| return mutateKubeletConfig(&cm, hostedClusterCM) | ||
|
|
@@ -3019,6 +3027,19 @@ func (r *reconciler) reconcileKubeletConfig(ctx context.Context) error { | |
| if want.Has(cm.Name) { | ||
| continue | ||
| } | ||
| // Mirrored CMs have a source in the HCP namespace managed by the NodePool controller. | ||
| // During delete+recreate migrations or transient API errors the source can be briefly | ||
| // absent. Deleting the guest copy here would cause NTO to regenerate MachineConfigs | ||
| // without it, triggering MCO node rollouts. If the source is permanently removed | ||
| // (e.g. NodePool deletion), the orphaned guest CM is harmless and will be cleaned up | ||
| // when the HostedCluster is deleted. | ||
| // TODO(OCPBUGS-88738): check whether the owning NodePool (via NodePoolLabel) still exists | ||
| // before unconditionally skipping, to allow cleanup of truly orphaned CMs. | ||
| if cm.Labels[nodepool.NTOMirroredConfigLabel] == "true" { | ||
| log.Info("skipping deletion of mirrored ConfigMap; source may be transiently absent or permanently removed after NodePool deletion", | ||
| "configMap", client.ObjectKeyFromObject(cm).String()) | ||
| continue | ||
| } | ||
| log.Info("delete mirror config ConfigMap", "config", client.ObjectKeyFromObject(cm).String()) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This log message says "transiently absent source" but Suggestion:
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Updated the log message to "skipping deletion of mirrored ConfigMap; source may be transiently absent or permanently removed after NodePool deletion" to accurately reflect both scenarios. |
||
| if _, err := k8sutil.DeleteIfNeeded(ctx, r.client, cm); err != nil { | ||
| return fmt.Errorf("failed to delete ConfigMap %s: %w", client.ObjectKeyFromObject(cm).String(), err) | ||
|
|
@@ -3027,26 +3048,22 @@ func (r *reconciler) reconcileKubeletConfig(ctx context.Context) error { | |
| return nil | ||
| } | ||
|
|
||
| // deleteImmutableConfigMapIfNeeded checks if a ConfigMap exists and is immutable, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Any reason to delete this comment?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks for pointing out. The old comment described the previous implementation which didn't have the ownership guard. Removed it during the refactor but missed adding the updated one. Will add it back with the updated description. |
||
| // and deletes it if necessary to allow recreation as a mutable ConfigMap. | ||
| // This handles migration from immutable ConfigMaps to mutable ones. | ||
| // deleteImmutableConfigMapIfNeeded deletes an existing immutable ConfigMap only if it | ||
| // carries the KubeletConfigConfigMapLabel ownership label, allowing it to be recreated | ||
| // as mutable by the subsequent CreateOrUpdate. | ||
| func (r *reconciler) deleteImmutableConfigMapIfNeeded(ctx context.Context, log logr.Logger, cm *corev1.ConfigMap) error { | ||
| existingCM := &corev1.ConfigMap{} | ||
| if err := r.client.Get(ctx, client.ObjectKeyFromObject(cm), existingCM); err != nil { | ||
| if apierrors.IsNotFound(err) { | ||
| return nil | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The refactor to
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Added test case "When guest CM is immutable but not a |
||
| _, err := k8sutil.DeleteIfNeededWithPredicate(ctx, r.client, cm, func(existing *corev1.ConfigMap) bool { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The code being replaced is getting
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thank you for the suggestion, Instead of clearing individual fields, I now reinitialize |
||
| if existing.Labels[nodepool.KubeletConfigConfigMapLabel] != "true" { | ||
| return false | ||
| } | ||
| return fmt.Errorf("failed to get ConfigMap %s: %w", client.ObjectKeyFromObject(cm).String(), err) | ||
| } | ||
|
|
||
| if existingCM.Immutable != nil && *existingCM.Immutable { | ||
| log.Info("deleting immutable KubeletConfig ConfigMap to recreate as mutable", "configMap", client.ObjectKeyFromObject(existingCM).String()) | ||
| if _, err := k8sutil.DeleteIfNeeded(ctx, r.client, existingCM); err != nil { | ||
| return fmt.Errorf("failed to delete immutable ConfigMap %s: %w", client.ObjectKeyFromObject(existingCM).String(), err) | ||
| if existing.Immutable != nil && *existing.Immutable { | ||
| log.Info("deleting immutable KubeletConfig ConfigMap to recreate as mutable", | ||
| "configMap", client.ObjectKeyFromObject(existing).String()) | ||
| return true | ||
| } | ||
| } | ||
|
|
||
| return nil | ||
| return false | ||
| }) | ||
| return err | ||
| } | ||
|
|
||
| func mutateKubeletConfig(controlPlaneConfigMap, hostedClusterConfigMap *corev1.ConfigMap) error { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1610,6 +1610,7 @@ func TestReconcileKubeletConfig(t *testing.T) { | |
| hostedControlPlaneObjects []client.Object | ||
| existHostedControlPlaneObjects []client.Object | ||
| expectedHostedClusterObjects []client.Object | ||
| preservedObjects []client.Object | ||
| }{ | ||
| { | ||
| name: "copy kubelet config from control plane NS", | ||
|
|
@@ -1647,6 +1648,66 @@ func TestReconcileKubeletConfig(t *testing.T) { | |
| makeKubeletConfigConfigMap(netutil.ShortenName("bar", npName1, validation.LabelValueMaxLength), hcNamespace, kubeletConfig1), | ||
| }, | ||
| }, | ||
| { | ||
| name: "When source CM is transiently absent, it should not delete the mirrored guest-side CM", | ||
| hostedControlPlaneObjects: []client.Object{}, | ||
| existHostedControlPlaneObjects: []client.Object{ | ||
| makeMirroredKubeletConfigConfigMap(netutil.ShortenName("bar", npName1, validation.LabelValueMaxLength), hcNamespace, npName1, kubeletConfig1), | ||
| }, | ||
| expectedHostedClusterObjects: []client.Object{ | ||
| makeMirroredKubeletConfigConfigMap(netutil.ShortenName("bar", npName1, validation.LabelValueMaxLength), hcNamespace, npName1, kubeletConfig1), | ||
| }, | ||
| }, | ||
| { | ||
| // Defensive: this path is only reachable for CMs created before NTOMirroredConfigLabel was introduced. | ||
| name: "When source CM is absent and guest CM is not mirrored, it should be deleted", | ||
| hostedControlPlaneObjects: []client.Object{}, | ||
| existHostedControlPlaneObjects: []client.Object{ | ||
| makeKubeletConfigConfigMap(netutil.ShortenName("bar", npName1, validation.LabelValueMaxLength), hcNamespace, kubeletConfig1), | ||
| }, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: This test exercises a path that can't happen in production —
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Added a comment: // Defensive: this path is only reachable for CMs created before NTOMirroredConfigLabel was introduced. |
||
| expectedHostedClusterObjects: []client.Object{}, | ||
| }, | ||
| { | ||
| name: "When guest CM is immutable but not a KubeletConfig, it should not be deleted", | ||
| hostedControlPlaneObjects: []client.Object{ | ||
| makeKubeletConfigConfigMap(netutil.ShortenName("bar", npName1, validation.LabelValueMaxLength), hcpNamespace, kubeletConfig1), | ||
| }, | ||
| existHostedControlPlaneObjects: []client.Object{ | ||
| // Immutable CM without KubeletConfigConfigMapLabel — should be left alone. | ||
| &corev1.ConfigMap{ | ||
| ObjectMeta: metav1.ObjectMeta{ | ||
| Name: "unrelated-immutable-cm", | ||
| Namespace: hcNamespace, | ||
| Labels: map[string]string{"some-other-label": "true"}, | ||
| }, | ||
| Immutable: ptr.To(true), | ||
| Data: map[string]string{"key": "value"}, | ||
| }, | ||
| }, | ||
| expectedHostedClusterObjects: []client.Object{ | ||
| makeKubeletConfigConfigMap(netutil.ShortenName("bar", npName1, validation.LabelValueMaxLength), hcNamespace, kubeletConfig1), | ||
| }, | ||
| preservedObjects: []client.Object{ | ||
| &corev1.ConfigMap{ | ||
| ObjectMeta: metav1.ObjectMeta{ | ||
| Name: "unrelated-immutable-cm", | ||
| Namespace: hcNamespace, | ||
| }, | ||
| }, | ||
| }, | ||
| }, | ||
| { | ||
| name: "When guest CM is immutable, it should be deleted and recreated as mutable", | ||
| hostedControlPlaneObjects: []client.Object{ | ||
| makeKubeletConfigConfigMap(netutil.ShortenName("bar", npName1, validation.LabelValueMaxLength), hcpNamespace, kubeletConfig1), | ||
| }, | ||
| existHostedControlPlaneObjects: []client.Object{ | ||
| makeImmutableKubeletConfigConfigMap(netutil.ShortenName("bar", npName1, validation.LabelValueMaxLength), hcNamespace, kubeletConfig1), | ||
| }, | ||
| expectedHostedClusterObjects: []client.Object{ | ||
| makeKubeletConfigConfigMap(netutil.ShortenName("bar", npName1, validation.LabelValueMaxLength), hcNamespace, kubeletConfig1), | ||
| }, | ||
| }, | ||
| } | ||
|
|
||
| for _, tc := range testCases { | ||
|
|
@@ -1661,7 +1722,14 @@ func TestReconcileKubeletConfig(t *testing.T) { | |
| } | ||
| g.Expect(r.reconcileKubeletConfig(t.Context())).To(Succeed()) | ||
| for _, obj := range tc.expectedHostedClusterObjects { | ||
| g.Expect(r.client.Get(t.Context(), client.ObjectKeyFromObject(obj), obj)).To(Succeed(), "failed to get %s", client.ObjectKeyFromObject(obj)) | ||
| actual := &corev1.ConfigMap{} | ||
| g.Expect(r.client.Get(t.Context(), client.ObjectKeyFromObject(obj), actual)).To(Succeed(), "failed to get %s", client.ObjectKeyFromObject(obj)) | ||
| g.Expect(actual.Immutable).To(BeNil(), "recreated ConfigMap %s should be mutable", client.ObjectKeyFromObject(obj)) | ||
| } | ||
| for _, obj := range tc.preservedObjects { | ||
| actual := &corev1.ConfigMap{} | ||
| g.Expect(r.client.Get(t.Context(), client.ObjectKeyFromObject(obj), actual)).To(Succeed(), | ||
| "preserved object %s should still exist after reconcile", client.ObjectKeyFromObject(obj)) | ||
| } | ||
| listOpts := []client.ListOption{ | ||
| client.InNamespace(hcNamespace), | ||
|
|
@@ -1750,6 +1818,39 @@ func makeKubeletConfigConfigMap(name, namespace, data string) *corev1.ConfigMap | |
| } | ||
| } | ||
|
|
||
| func makeMirroredKubeletConfigConfigMap(name, namespace, nodePoolName, data string) *corev1.ConfigMap { | ||
| return &corev1.ConfigMap{ | ||
| ObjectMeta: metav1.ObjectMeta{ | ||
| Name: name, | ||
| Namespace: namespace, | ||
| Labels: map[string]string{ | ||
| nodepool.KubeletConfigConfigMapLabel: "true", | ||
| nodepool.NTOMirroredConfigLabel: "true", | ||
| hyperv1.NodePoolLabel: nodePoolName, | ||
| }, | ||
| }, | ||
| Data: map[string]string{ | ||
| "config": data, | ||
| }, | ||
| } | ||
| } | ||
|
|
||
| func makeImmutableKubeletConfigConfigMap(name, namespace, data string) *corev1.ConfigMap { | ||
| return &corev1.ConfigMap{ | ||
| ObjectMeta: metav1.ObjectMeta{ | ||
| Name: name, | ||
| Namespace: namespace, | ||
| Labels: map[string]string{ | ||
| nodepool.KubeletConfigConfigMapLabel: "true", | ||
| }, | ||
| }, | ||
| Immutable: ptr.To(true), | ||
| Data: map[string]string{ | ||
| "config": data, | ||
| }, | ||
| } | ||
| } | ||
|
|
||
| func TestReconcileAuthOIDC(t *testing.T) { | ||
| testNamespace := "master-cluster1" | ||
| testHCPName := "cluster1" | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since
mutateKubeletConfigat line 3056 always setsNTOMirroredConfigLabel: "true", this guard effectively disables orphan cleanup for all KubeletConfig CMs — not just ones with a transiently absent source. The trade-off is correct (stale CM < spurious MCO rollout), but worth a TODO for a future improvement: check whether the owning NodePool (viahyperv1.NodePoolLabel) still exists before unconditionally skipping.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Added a TODO to check whether the owning NodePool (via NodePoolLabel) still exists before skipping, to allow cleanup of truly orphaned CMs in a future improvement.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If I'm understanding properly once this PR get merged (if we merge as it is), orphaned KubeletConfig CMs will persist in the guest cluster until the HostedCluster is deleted. Right?
Unsure what is meant with
TODOhere. Please follow-upThere was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, that's correct. Orphaned KubeletConfig CMs will persist in the guest cluster until the HostedCluster is deleted. The trade-off is: a stale CM in openshift-config-managed is harmless (NTO ignores CMs that don't match any active MachineConfigPool), while deleting it during a transient source absence triggers an MCO node rollout.
The TODO proposes a future improvement: before skipping deletion, check whether the owning NodePool (tracked via NodePoolLabel) still exists. If the NodePool is gone, the CM is truly orphaned and safe to delete. This requires a cross-namespace lookup so I'll raise a follow-up Jira to track it