Skip to content

Commit 1fdada3

Browse files
Node maintenance finalizer
1 parent e96e793 commit 1fdada3

File tree

4 files changed

+121
-20
lines changed

4 files changed

+121
-20
lines changed

go.mod

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@ require (
1212
k8s.io/api v0.35.0
1313
k8s.io/apimachinery v0.35.0
1414
k8s.io/client-go v0.35.0
15-
k8s.io/cloud-provider v0.34.1
15+
k8s.io/cloud-provider v0.35.0
1616
k8s.io/component-base v0.35.0
17-
k8s.io/controller-manager v0.34.1
17+
k8s.io/controller-manager v0.35.0
1818
k8s.io/klog/v2 v2.130.1
1919
sigs.k8s.io/cluster-api v1.10.4
2020
sigs.k8s.io/controller-runtime v0.23.1
@@ -121,7 +121,7 @@ require (
121121
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
122122
k8s.io/apiextensions-apiserver v0.35.0 // indirect
123123
k8s.io/apiserver v0.35.0 // indirect
124-
k8s.io/component-helpers v0.34.1 // indirect
124+
k8s.io/component-helpers v0.35.0 // indirect
125125
k8s.io/kms v0.35.0 // indirect
126126
k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect
127127
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect

go.sum

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -344,14 +344,14 @@ k8s.io/apiserver v0.35.0 h1:CUGo5o+7hW9GcAEF3x3usT3fX4f9r8xmgQeCBDaOgX4=
344344
k8s.io/apiserver v0.35.0/go.mod h1:QUy1U4+PrzbJaM3XGu2tQ7U9A4udRRo5cyxkFX0GEds=
345345
k8s.io/client-go v0.35.0 h1:IAW0ifFbfQQwQmga0UdoH0yvdqrbwMdq9vIFEhRpxBE=
346346
k8s.io/client-go v0.35.0/go.mod h1:q2E5AAyqcbeLGPdoRB+Nxe3KYTfPce1Dnu1myQdqz9o=
347-
k8s.io/cloud-provider v0.34.1 h1:FS+4C1vq9pIngd/5LR5Jha1sEbn+fo0HJitgZmUyBNc=
348-
k8s.io/cloud-provider v0.34.1/go.mod h1:ghyQYfQIWZAXKNS+TEgEiQ8wPuhzIVt3wFO6rKqS/rQ=
347+
k8s.io/cloud-provider v0.35.0 h1:syiBCQbKh2gho/S1BkIl006Dc44pV8eAtGZmv5NMe7M=
348+
k8s.io/cloud-provider v0.35.0/go.mod h1:7grN+/Nt5Hf7tnSGPT3aErt4K7aQpygyCrGpbrQbzNc=
349349
k8s.io/component-base v0.35.0 h1:+yBrOhzri2S1BVqyVSvcM3PtPyx5GUxCK2tinZz1G94=
350350
k8s.io/component-base v0.35.0/go.mod h1:85SCX4UCa6SCFt6p3IKAPej7jSnF3L8EbfSyMZayJR0=
351-
k8s.io/component-helpers v0.34.1 h1:gWhH3CCdwAx5P3oJqZKb4Lg5FYZTWVbdWtOI8n9U4XY=
352-
k8s.io/component-helpers v0.34.1/go.mod h1:4VgnUH7UA/shuBur+OWoQC0xfb69sy/93ss0ybZqm3c=
353-
k8s.io/controller-manager v0.34.1 h1:c9Cmun/zF740kmdRQWPGga+4MglT5SlrwsCXDS/KtJI=
354-
k8s.io/controller-manager v0.34.1/go.mod h1:fGiJDhi3OSzSAB4f40ZkJLAqMQSag9RM+7m5BRhBO3Q=
351+
k8s.io/component-helpers v0.35.0 h1:wcXv7HJRksgVjM4VlXJ1CNFBpyDHruRI99RrBtrJceA=
352+
k8s.io/component-helpers v0.35.0/go.mod h1:ahX0m/LTYmu7fL3W8zYiIwnQ/5gT28Ex4o2pymF63Co=
353+
k8s.io/controller-manager v0.35.0 h1:KteodmfVIRzfZ3RDaxhnHb72rswBxEngvdL9vuZOA9A=
354+
k8s.io/controller-manager v0.35.0/go.mod h1:1bVuPNUG6/dpWpevsJpXioS0E0SJnZ7I/Wqc9Awyzm4=
355355
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
356356
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
357357
k8s.io/kms v0.35.0 h1:/x87FED2kDSo66csKtcYCEHsxF/DBlNl7LfJ1fVQs1o=

pkg/cloudprovider/metal/node_maintenance_controller.go

Lines changed: 55 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,11 @@ import (
1919
ctrl "sigs.k8s.io/controller-runtime"
2020
ctrlcache "sigs.k8s.io/controller-runtime/pkg/cache"
2121
"sigs.k8s.io/controller-runtime/pkg/client"
22+
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
2223
)
2324

25+
const nodeMaintenanceFinalizer = "cloud-provider-metal.ironcore.dev/node-maintenance"
26+
2427
type NodeMaintenanceReconciler struct {
2528
metalClient client.Client
2629
targetClient client.Client
@@ -133,6 +136,11 @@ func (r *NodeMaintenanceReconciler) Reconcile(ctx context.Context, req ctrl.Requ
133136

134137
l = l.WithValues("server-claim-name", serverClaimKey.Name, "server-claim-namespace", serverClaimKey.Namespace)
135138

139+
if !node.DeletionTimestamp.IsZero() {
140+
l.Info("Node is deleting, reconciling delete flow")
141+
return r.reconcileDelete(ctx, node, serverClaimKey)
142+
}
143+
136144
serverClaim := &metalv1alpha1.ServerClaim{}
137145
if err = r.metalClient.Get(ctx, serverClaimKey, serverClaim); err != nil {
138146
if apierrors.IsNotFound(err) {
@@ -151,6 +159,13 @@ func (r *NodeMaintenanceReconciler) Reconcile(ctx context.Context, req ctrl.Requ
151159
maintenanceRequested := node.Labels[metalv1alpha1.ServerMaintenanceRequestedLabelKey] == TrueStr
152160

153161
if maintenanceRequested {
162+
base := node.DeepCopy()
163+
if added := controllerutil.AddFinalizer(node, nodeMaintenanceFinalizer); added {
164+
if err = r.targetClient.Patch(ctx, node, client.MergeFrom(base)); err != nil {
165+
return fmt.Errorf("unable to add finalizer: %w", err)
166+
}
167+
}
168+
154169
serverName := serverClaim.Spec.ServerRef.Name
155170

156171
if err = r.ensureServerMaintenanceExists(ctx, maintenanceKey, serverName); err != nil {
@@ -161,6 +176,13 @@ func (r *NodeMaintenanceReconciler) Reconcile(ctx context.Context, req ctrl.Requ
161176
if err = r.ensureServerMaintenanceNotExists(ctx, maintenanceKey); err != nil {
162177
return fmt.Errorf("unable to ensure ServerMaintenance CR not exists: %w", err)
163178
}
179+
180+
base := node.DeepCopy()
181+
if removed := controllerutil.RemoveFinalizer(node, nodeMaintenanceFinalizer); removed {
182+
if err := r.targetClient.Patch(ctx, node, client.MergeFrom(base)); err != nil {
183+
return fmt.Errorf("unable to remove finalizer: %w", err)
184+
}
185+
}
164186
}
165187

166188
maintenanceNeeded := serverClaim.Labels[metalv1alpha1.ServerMaintenanceNeededLabelKey] == TrueStr
@@ -178,22 +200,23 @@ func (r *NodeMaintenanceReconciler) Reconcile(ctx context.Context, req ctrl.Requ
178200
return nil
179201
}
180202

181-
func parseProviderID(providerID string) (types.NamespacedName, error) {
182-
if providerID == "" {
183-
return types.NamespacedName{}, errors.New("empty providerID")
203+
func (r *NodeMaintenanceReconciler) reconcileDelete(ctx context.Context, node *corev1.Node, serverClaimKey types.NamespacedName) error {
204+
if !controllerutil.ContainsFinalizer(node, nodeMaintenanceFinalizer) {
205+
return nil
184206
}
185207

186-
provider, rest, ok := strings.Cut(providerID, "://")
187-
if !ok || provider == "" {
188-
return types.NamespacedName{}, errors.New("missing scheme")
208+
if err := r.ensureServerMaintenanceNotExists(ctx, serverClaimKey); err != nil {
209+
return fmt.Errorf("unable to cleanup ServerMaintenance: %w", err)
189210
}
190211

191-
parts := strings.Split(rest, "/")
192-
if len(parts) != 2 {
193-
return types.NamespacedName{}, errors.New("unexpected count of forward slashes")
212+
base := node.DeepCopy()
213+
if removed := controllerutil.RemoveFinalizer(node, nodeMaintenanceFinalizer); removed {
214+
if err := r.targetClient.Patch(ctx, node, client.MergeFrom(base)); err != nil {
215+
return fmt.Errorf("unable to remove finalizer: %w", err)
216+
}
194217
}
195218

196-
return types.NamespacedName{Namespace: parts[0], Name: parts[1]}, nil
219+
return nil
197220
}
198221

199222
func (r *NodeMaintenanceReconciler) ensureServerMaintenanceExists(ctx context.Context, key types.NamespacedName, serverName string) error {
@@ -267,3 +290,25 @@ func (r *NodeMaintenanceReconciler) syncServerClaimApproval(ctx context.Context,
267290

268291
return nil
269292
}
293+
294+
func parseProviderID(providerID string) (types.NamespacedName, error) {
295+
if providerID == "" {
296+
return types.NamespacedName{}, errors.New("empty providerID")
297+
}
298+
299+
provider, rest, ok := strings.Cut(providerID, "://")
300+
if !ok || provider == "" {
301+
return types.NamespacedName{}, errors.New("missing scheme")
302+
}
303+
304+
parts := strings.Split(rest, "/")
305+
if len(parts) != 2 {
306+
return types.NamespacedName{}, errors.New("unexpected count of forward slashes")
307+
}
308+
309+
if parts[0] == "" || parts[1] == "" {
310+
return types.NamespacedName{}, errors.New("missing namespace or name")
311+
}
312+
313+
return types.NamespacedName{Namespace: parts[0], Name: parts[1]}, nil
314+
}

pkg/cloudprovider/metal/node_maintenance_controller_test.go

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,9 @@ var _ = Describe("NodeMaintenanceReconciler", func() {
6868
},
6969
}
7070
Expect(k8sClient.Create(ctx, node)).To(Succeed())
71-
DeferCleanup(k8sClient.Delete, node)
71+
DeferCleanup(func(ctx SpecContext) error {
72+
return client.IgnoreNotFound(k8sClient.Delete(ctx, node))
73+
})
7274

7375
originalNode := node.DeepCopy()
7476
node.Spec.ProviderID = fmt.Sprintf("metal://%s/%s", serverClaim.Namespace, serverClaim.Name)
@@ -100,6 +102,9 @@ var _ = Describe("NodeMaintenanceReconciler", func() {
100102
Expect(maintenanceCR.Spec.Priority).To(Equal(int32(100)))
101103
Expect(maintenanceCR.Spec.ServerRef).NotTo(BeNil())
102104
Expect(maintenanceCR.Spec.ServerRef.Name).To(Equal(serverClaim.Spec.ServerRef.Name))
105+
106+
By("Verifying the finalizer is added to the Node")
107+
Eventually(Object(node)).Should(HaveField("Finalizers", ContainElement(nodeMaintenanceFinalizer)))
103108
})
104109

105110
It("should do nothing if ServerMaintenance CR already exists (idempotency)", func(ctx SpecContext) {
@@ -110,6 +115,7 @@ var _ = Describe("NodeMaintenanceReconciler", func() {
110115
Labels: map[string]string{
111116
"test-marker": "do-not-overwrite",
112117
},
118+
Finalizers: []string{nodeMaintenanceFinalizer},
113119
},
114120
Spec: metalv1alpha1.ServerMaintenanceSpec{
115121
Policy: metalv1alpha1.ServerMaintenancePolicyOwnerApproval,
@@ -136,6 +142,9 @@ var _ = Describe("NodeMaintenanceReconciler", func() {
136142
g.Expect(err).NotTo(HaveOccurred())
137143
g.Expect(checkCR.Labels).To(HaveKeyWithValue("test-marker", "do-not-overwrite"))
138144
}).Should(Succeed())
145+
146+
By("Verifying the finalizer is present in the Node")
147+
Consistently(Object(node)).Should(HaveField("Finalizers", ContainElement(nodeMaintenanceFinalizer)))
139148
})
140149

141150
It("should delete the ServerMaintenance CR when the maintenance-requested label is removed", func(ctx SpecContext) {
@@ -166,6 +175,9 @@ var _ = Describe("NodeMaintenanceReconciler", func() {
166175
g.Expect(err).To(HaveOccurred())
167176
g.Expect(apierrors.IsNotFound(err)).To(BeTrue())
168177
}).Should(Succeed())
178+
179+
By("Verifying the finalizer is removed from the Node")
180+
Eventually(Object(node)).ShouldNot(HaveField("Finalizers", ContainElement(nodeMaintenanceFinalizer)))
169181
})
170182

171183
It("should do nothing if the label is absent and CR does not exist (idempotency)", func(ctx SpecContext) {
@@ -187,6 +199,47 @@ var _ = Describe("NodeMaintenanceReconciler", func() {
187199
g.Expect(err).To(HaveOccurred())
188200
g.Expect(apierrors.IsNotFound(err)).To(BeTrue())
189201
}).Should(Succeed())
202+
203+
Consistently(Object(node)).ShouldNot(HaveField("Finalizers", ContainElement(nodeMaintenanceFinalizer)))
204+
})
205+
206+
It("should handle Node deletion by cleaning up the CR and removing the finalizer", func(ctx SpecContext) {
207+
By("Triggering maintenance to create CR and add finalizer")
208+
originalNode := node.DeepCopy()
209+
if node.Labels == nil {
210+
node.Labels = make(map[string]string)
211+
}
212+
node.Labels[metalv1alpha1.ServerMaintenanceRequestedLabelKey] = TrueStr
213+
Expect(k8sClient.Patch(ctx, node, client.MergeFrom(originalNode))).To(Succeed())
214+
215+
maintenanceKey := client.ObjectKey{
216+
Namespace: serverClaim.Namespace,
217+
Name: serverClaim.Name,
218+
}
219+
maintenanceCR := &metalv1alpha1.ServerMaintenance{}
220+
221+
Eventually(func(g Gomega) {
222+
err := k8sClient.Get(ctx, maintenanceKey, maintenanceCR)
223+
g.Expect(err).NotTo(HaveOccurred())
224+
}).Should(Succeed())
225+
Eventually(Object(node)).Should(HaveField("Finalizers", ContainElement(nodeMaintenanceFinalizer)))
226+
227+
By("Deleting the Node")
228+
Expect(k8sClient.Delete(ctx, node)).To(Succeed())
229+
230+
By("Verifying the ServerMaintenance CR is deleted first")
231+
Eventually(func(g Gomega) {
232+
err := k8sClient.Get(ctx, maintenanceKey, maintenanceCR)
233+
g.Expect(err).To(HaveOccurred())
234+
g.Expect(apierrors.IsNotFound(err)).To(BeTrue())
235+
}).Should(Succeed())
236+
237+
By("Verifying the Node is completely deleted (finalizer was removed)")
238+
Eventually(func(g Gomega) {
239+
err := k8sClient.Get(ctx, client.ObjectKeyFromObject(node), &corev1.Node{})
240+
g.Expect(err).To(HaveOccurred())
241+
g.Expect(apierrors.IsNotFound(err)).To(BeTrue())
242+
}).Should(Succeed())
190243
})
191244
})
192245

@@ -272,5 +325,8 @@ var _ = Describe("parseProviderID", func() {
272325
Entry("missing provider before scheme", "://default/node-1", types.NamespacedName{}, true),
273326
Entry("missing namespace or name (no slash)", "metal://node-1", types.NamespacedName{}, true),
274327
Entry("too many slashes", "metal://default/node-1/extra", types.NamespacedName{}, true),
328+
Entry("empty namespace", "metal:///name", types.NamespacedName{}, true),
329+
Entry("empty name", "metal://namespace/", types.NamespacedName{}, true),
330+
Entry("empty name and namespace", "metal:///", types.NamespacedName{}, true),
275331
)
276332
})

0 commit comments

Comments
 (0)