Skip to content

Commit 07ca5e2

Browse files
Herbaerttuunit
authored andcommitted
fix: persist the finalizer before the first cloud call
1 parent a5e0347 commit 07ca5e2

5 files changed

Lines changed: 77 additions & 0 deletions

File tree

‎cloud/fake/client.go‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,14 @@ type Client struct {
5555
FailNextEnsureNodeSSH error
5656
FailNextDeleteNodeSSH error
5757

58+
// Before* hooks, if non-nil, run before the call they belong to does any
59+
// work. They let a test observe API server state at the exact moment a cloud
60+
// call is about to happen — for instance to assert a finalizer was persisted
61+
// before the first resource could be created. Unlike FailNext*, they are not
62+
// consumed and fire on every call.
63+
BeforeCreateServer func()
64+
BeforeGetNetwork func()
65+
5866
// CreateServerCalls counts successful CreateServer calls (for idempotency
5967
// assertions).
6068
CreateServerCalls int
@@ -171,6 +179,9 @@ func (c *Client) CreateServer(_ context.Context, input cloud.CreateServerInput)
171179
c.mu.Lock()
172180
defer c.mu.Unlock()
173181

182+
if c.BeforeCreateServer != nil {
183+
c.BeforeCreateServer()
184+
}
174185
if err := consume(&c.FailNextCreateServer); err != nil {
175186
return nil, err
176187
}
@@ -215,6 +226,9 @@ func (c *Client) GetNetwork(_ context.Context, id string) (*cloud.Network, error
215226
c.mu.Lock()
216227
defer c.mu.Unlock()
217228

229+
if c.BeforeGetNetwork != nil {
230+
c.BeforeGetNetwork()
231+
}
218232
if err := consume(&c.FailNextGetNetwork); err != nil {
219233
return nil, err
220234
}

‎controller/stackitcluster_controller_test.go‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -581,6 +581,28 @@ var _ = Describe("StackitCluster Controller", func() {
581581
}).Should(BeTrue())
582582
})
583583

584+
// AddFinalizer only mutated the object in memory, and the write to etcd
585+
// happened in the deferred PatchObject at the end of Reconcile — after the
586+
// load balancer and the bastion had been created. A process dying in between
587+
// left those resources behind an object with no finalizer to clean them up.
588+
// GetNetwork is the first cloud call of the reconcile, so a hook there
589+
// proves the ordering rather than only the end result.
590+
It("persists the finalizer before the first cloud call", func() {
591+
var finalizersAtFirstCall []string
592+
fakeCloud.BeforeGetNetwork = func() {
593+
got := &infrav1.StackitCluster{}
594+
Expect(k8sClient.Get(ctx, stackitKey, got)).To(Succeed())
595+
finalizersAtFirstCall = got.Finalizers
596+
}
597+
598+
_, err := reconciler.Reconcile(ctx, request)
599+
Expect(err).NotTo(HaveOccurred())
600+
Expect(fakeCloud.LoadBalancerCount()).To(Equal(1))
601+
602+
Expect(finalizersAtFirstCall).To(ContainElement(infrav1.ClusterFinalizer),
603+
"cloud resources were created while the API server had no finalizer to clean them up")
604+
})
605+
584606
// The finalizer used to go away regardless of remaining Machines. Their
585607
// controllers reach credentials and project context through this
586608
// StackitCluster, so once it is gone they can neither delete their servers

‎controller/stackitcluster_infrastructure.go‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,14 @@ func (r *StackitClusterReconciler) reconcileNormal(ctx context.Context, clusterS
3939

4040
if !controllerutil.ContainsFinalizer(stackitCluster, infrav1.ClusterFinalizer) {
4141
controllerutil.AddFinalizer(stackitCluster, infrav1.ClusterFinalizer)
42+
// Persisted immediately, before anything can create a cloud resource.
43+
// AddFinalizer only mutates the object in memory; it otherwise reaches
44+
// etcd through the deferred PatchObject at the end of Reconcile, and a
45+
// process that dies in between leaves a load balancer or a bastion
46+
// running behind an object that carries no finalizer to clean it up.
47+
if err := clusterScope.PatchObject(ctx); err != nil {
48+
return ctrl.Result{}, fmt.Errorf("persist finalizer: %w", err)
49+
}
4250
}
4351
stackitCluster.Status.FailureDomains = stackitFailureDomains(stackitCluster.Spec.Region)
4452

‎controller/stackitmachine_controller_test.go‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,31 @@ var _ = Describe("StackitMachine Controller", func() {
386386
}).Should(BeTrue())
387387
})
388388

389+
// AddFinalizer only mutated the object in memory, and the write to etcd
390+
// happened in the deferred PatchObject at the end of Reconcile — after
391+
// CreateServer. A process dying in between left a running server behind an
392+
// object with no finalizer to clean it up. The hook observes API server
393+
// state from inside the cloud call, so it proves the ordering rather than
394+
// only the end result.
395+
It("persists the finalizer before creating the server", func() {
396+
updateMachineBootstrapSecret(ctx, machineName, bootstrapName)
397+
createBootstrapSecret(ctx, bootstrapName)
398+
399+
var finalizersAtCreate []string
400+
fakeCloud.BeforeCreateServer = func() {
401+
got := &infrav1.StackitMachine{}
402+
Expect(k8sClient.Get(ctx, stackitKey, got)).To(Succeed())
403+
finalizersAtCreate = got.Finalizers
404+
}
405+
406+
_, err := reconciler.Reconcile(ctx, request)
407+
Expect(err).NotTo(HaveOccurred())
408+
Expect(fakeCloud.ServerCount()).To(Equal(1))
409+
410+
Expect(finalizersAtCreate).To(ContainElement(infrav1.MachineFinalizer),
411+
"the server was created while the API server had no finalizer to clean it up")
412+
})
413+
389414
// An empty status.instanceID used to be taken as proof that no VM had ever
390415
// been created, so the finalizer went away without a single cloud call. If
391416
// CreateServer had succeeded and the status patch had not, that server kept

‎controller/stackitmachine_infrastructure.go‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,14 @@ func (r *StackitMachineReconciler) reconcileNormal(ctx context.Context, machineS
3838

3939
if !controllerutil.ContainsFinalizer(stackitMachine, infrav1.MachineFinalizer) {
4040
controllerutil.AddFinalizer(stackitMachine, infrav1.MachineFinalizer)
41+
// Persisted immediately, before CreateServer can run. AddFinalizer only
42+
// mutates the object in memory; it otherwise reaches etcd through the
43+
// deferred PatchObject at the end of Reconcile, and a process that dies
44+
// in between leaves a server running behind an object that carries no
45+
// finalizer to clean it up.
46+
if err := machineScope.PatchObject(ctx); err != nil {
47+
return ctrl.Result{}, fmt.Errorf("persist finalizer: %w", err)
48+
}
4149
}
4250

4351
if !machineScope.StackitCluster.Status.Ready {

0 commit comments

Comments
 (0)