Skip to content

Commit 1293ea2

Browse files
authored
🐛 Prevent volume deletion during node replacement (#119)
**What is the purpose of this pull request/Why do we need it?** When using a CSI the volumes, which are additionally attached to a server, are not handled by CAPIC. When nodes are replaced, the current implementation will force delete all volumes, which have been attached to the server. When a cluster deletion is not requested, we need to make sure to only delete the volumes which are explicitly managed by CAPIC **Description of changes:** * Adds a check that will ensure to only delete the volumes that are managed by CAPIC when a cluster was not flagged for deletion. **Checklist:** - [x] Unit Tests added - [x] Includes [emojis](https://github.com/kubernetes-sigs/kubebuilder-release-tools?tab=readme-ov-file#kubebuilder-project-versioning)
1 parent 91e64ed commit 1293ea2

File tree

10 files changed

+254
-28
lines changed

10 files changed

+254
-28
lines changed

docs/quickstart.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,9 @@ KUBECONFIG=ionos-quickstart.kubeconfig kubectl get nodes
139139

140140
TODO(gfariasalves): Add instructions about installing a CNI or available flavours
141141

142-
### Cleaning a cluster
142+
### Cleanup
143+
144+
**Note: Deleting a cluster will also delete any associated volumes that have been attached to the servers**
143145

144146
```sh
145147
kubectl delete cluster ionos-quickstart

internal/ionoscloud/client.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,12 @@ type Client interface {
3333
// GetServer returns the server that matches the provided serverID in the specified data center.
3434
GetServer(ctx context.Context, datacenterID, serverID string) (*sdk.Server, error)
3535
// DeleteServer deletes the server that matches the provided serverID in the specified data center.
36-
DeleteServer(ctx context.Context, datacenterID, serverID string) (string, error)
36+
DeleteServer(ctx context.Context, datacenterID, serverID string, deleteVolumes bool) (string, error)
3737
// StartServer starts the server that matches the provided serverID in the specified data center.
3838
// Returning the location and an error if starting the server fails.
3939
StartServer(ctx context.Context, datacenterID, serverID string) (string, error)
40+
// DeleteVolume deletes the volume that matches the provided volumeID in the specified data center.
41+
DeleteVolume(ctx context.Context, datacenterID, volumeID string) (string, error)
4042
// CreateLAN creates a new LAN with the provided properties in the specified data center,
4143
// returning the request path.
4244
CreateLAN(ctx context.Context, datacenterID string, properties sdk.LanPropertiesPost) (string, error)

internal/ionoscloud/client/client.go

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ func (c *IonosCloudClient) GetServer(ctx context.Context, datacenterID, serverID
152152
}
153153

154154
// DeleteServer deletes the server that matches the provided serverID in the specified data center.
155-
func (c *IonosCloudClient) DeleteServer(ctx context.Context, datacenterID, serverID string) (string, error) {
155+
func (c *IonosCloudClient) DeleteServer(ctx context.Context, datacenterID, serverID string, deleteVolumes bool) (string, error) {
156156
if datacenterID == "" {
157157
return "", errDatacenterIDIsEmpty
158158
}
@@ -161,7 +161,7 @@ func (c *IonosCloudClient) DeleteServer(ctx context.Context, datacenterID, serve
161161
}
162162
req, err := c.API.ServersApi.
163163
DatacentersServersDelete(ctx, datacenterID, serverID).
164-
DeleteVolumes(true).
164+
DeleteVolumes(deleteVolumes).
165165
Execute()
166166
if err != nil {
167167
return "", fmt.Errorf(apiCallErrWrapper, err)
@@ -195,6 +195,28 @@ func (c *IonosCloudClient) StartServer(ctx context.Context, datacenterID, server
195195
return "", errLocationHeaderEmpty
196196
}
197197

198+
// DeleteVolume deletes the volume that matches the provided volumeID in the specified data center.
199+
func (c *IonosCloudClient) DeleteVolume(ctx context.Context, datacenterID, volumeID string) (string, error) {
200+
if datacenterID == "" {
201+
return "", errDatacenterIDIsEmpty
202+
}
203+
204+
if volumeID == "" {
205+
return "", errVolumeIDIsEmpty
206+
}
207+
208+
resp, err := c.API.VolumesApi.DatacentersVolumesDelete(ctx, datacenterID, volumeID).Execute()
209+
if err != nil {
210+
return "", fmt.Errorf(apiCallErrWrapper, err)
211+
}
212+
213+
if location := resp.Header.Get(locationHeaderKey); location != "" {
214+
return location, nil
215+
}
216+
217+
return "", errLocationHeaderEmpty
218+
}
219+
198220
// CreateLAN creates a new LAN with the provided properties in the specified data center,
199221
// returning the request location.
200222
func (c *IonosCloudClient) CreateLAN(ctx context.Context, datacenterID string, properties sdk.LanPropertiesPost,

internal/ionoscloud/client/errors.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import "errors"
2121
var (
2222
errDatacenterIDIsEmpty = errors.New("error parsing data center ID: value cannot be empty")
2323
errServerIDIsEmpty = errors.New("error parsing server ID: value cannot be empty")
24+
errVolumeIDIsEmpty = errors.New("error parsing volume ID: value cannot be empty")
2425
errLANIDIsEmpty = errors.New("error parsing LAN ID: value cannot be empty")
2526
errNICIDIsEmpty = errors.New("error parsing NIC ID: value cannot be empty")
2627
errIPBlockIDIsEmpty = errors.New("error parsing IP block ID: value cannot be empty")

internal/ionoscloud/clienttest/mock_client.go

Lines changed: 73 additions & 14 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

internal/service/cloud/server.go

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ func (s *Service) ReconcileServerDeletion(ctx context.Context, ms *scope.Machine
138138
}
139139
}
140140

141-
err = s.deleteServer(ctx, ms, *server.Id)
141+
err = s.deleteServer(ctx, ms, server)
142142
return err == nil, err
143143
}
144144

@@ -239,11 +239,30 @@ func (s *Service) getServer(ctx context.Context, ms *scope.Machine) (*sdk.Server
239239
return nil, err
240240
}
241241

242-
func (s *Service) deleteServer(ctx context.Context, ms *scope.Machine, serverID string) error {
242+
func (s *Service) deleteServer(ctx context.Context, ms *scope.Machine, server *sdk.Server) error {
243243
log := s.logger.WithName("deleteServer")
244244

245-
log.V(4).Info("Deleting server", "serverID", serverID)
246-
requestLocation, err := s.ionosClient.DeleteServer(ctx, ms.DatacenterID(), serverID)
245+
serverID := ptr.Deref(server.GetId(), "")
246+
247+
deleteVolumes := ms.ClusterScope.IsDeleted()
248+
bootVolumeID := server.GetProperties().GetBootVolume().GetId()
249+
if !deleteVolumes && bootVolumeID != nil {
250+
// We need to make sure to only delete volumes if the cluster is being deleted.
251+
// If a node is being replaced, we only delete the boot volume and keep all other volumes.
252+
// The CSI will take care of re-attaching the existing volumes to the new node.
253+
254+
requestLocation, err := s.ionosClient.DeleteVolume(ctx, ms.DatacenterID(), *bootVolumeID)
255+
if err != nil {
256+
return fmt.Errorf("failed to request boot volume deletion: %w", err)
257+
}
258+
259+
ms.IonosMachine.SetCurrentRequest(http.MethodDelete, sdk.RequestStatusQueued, requestLocation)
260+
log.V(4).Info("Successfully requested for boot volume deletion", "location", requestLocation)
261+
return nil
262+
}
263+
264+
log.V(4).Info("Deleting server", "serverID", serverID, "deleteVolumes", deleteVolumes)
265+
requestLocation, err := s.ionosClient.DeleteServer(ctx, ms.DatacenterID(), serverID, deleteVolumes)
247266
if err != nil {
248267
return fmt.Errorf("failed to request server deletion: %w", err)
249268
}

internal/service/cloud/server_test.go

Lines changed: 64 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -193,14 +193,68 @@ func (s *serverSuite) TestReconcileServerDeletion() {
193193
reqLocation := "delete/location"
194194

195195
s.mockGetServerDeletionRequestCall(exampleServerID).Return(nil, nil)
196-
s.mockDeleteServerCall(exampleServerID).Return(reqLocation, nil)
196+
s.mockDeleteServerCall(exampleServerID, false).Return(reqLocation, nil)
197197

198198
res, err := s.service.ReconcileServerDeletion(s.ctx, s.machineScope)
199+
s.validateSuccessfulDeletionResponse(res, err, reqLocation)
200+
}
201+
202+
func (s *serverSuite) TestReconcileServerDeletionDeleteBootVolume() {
203+
s.mockGetServerCall(exampleServerID).Return(&sdk.Server{
204+
Id: ptr.To(exampleServerID),
205+
Properties: &sdk.ServerProperties{
206+
BootVolume: &sdk.ResourceReference{
207+
Id: ptr.To(exampleBootVolumeID),
208+
},
209+
},
210+
}, nil).Once()
211+
212+
s.mockGetServerCall(exampleServerID).Return(&sdk.Server{
213+
Id: ptr.To(exampleServerID),
214+
}, nil).Once()
215+
216+
reqLocationVolume := "delete/location/volume"
217+
reqLocationServer := "delete/location/server"
218+
219+
s.mockDeleteVolumeCall(exampleBootVolumeID).Return(reqLocationVolume, nil).Once()
220+
221+
s.mockGetServerDeletionRequestCall(exampleServerID).Return(nil, nil)
222+
s.mockDeleteServerCall(exampleServerID, false).Return(reqLocationServer, nil)
223+
224+
res, err := s.service.ReconcileServerDeletion(s.ctx, s.machineScope)
225+
s.validateSuccessfulDeletionResponse(res, err, reqLocationVolume)
226+
227+
res, err = s.service.ReconcileServerDeletion(s.ctx, s.machineScope)
228+
s.validateSuccessfulDeletionResponse(res, err, reqLocationServer)
229+
}
230+
231+
func (s *serverSuite) TestReconcileServerDeletionDeleteAllVolumes() {
232+
s.clusterScope.Cluster.DeletionTimestamp = ptr.To(metav1.Now())
233+
s.mockGetServerCall(exampleServerID).Return(&sdk.Server{
234+
Id: ptr.To(exampleServerID),
235+
Properties: &sdk.ServerProperties{
236+
BootVolume: &sdk.ResourceReference{
237+
Id: ptr.To(exampleBootVolumeID),
238+
},
239+
},
240+
}, nil).Once()
241+
242+
reqLocation := "delete/location"
243+
s.mockGetServerDeletionRequestCall(exampleServerID).Return(nil, nil)
244+
s.mockDeleteServerCall(exampleServerID, true).Return(reqLocation, nil)
245+
246+
res, err := s.service.ReconcileServerDeletion(s.ctx, s.machineScope)
247+
s.validateSuccessfulDeletionResponse(res, err, reqLocation)
248+
}
249+
250+
func (s *serverSuite) validateSuccessfulDeletionResponse(success bool, err error, requestLocation string) {
251+
s.T().Helper()
252+
199253
s.NoError(err)
200-
s.True(res)
254+
s.True(success)
201255
s.NotNil(s.machineScope.IonosMachine.Status.CurrentRequest)
202256
s.Equal(http.MethodDelete, s.machineScope.IonosMachine.Status.CurrentRequest.Method)
203-
s.Equal(s.machineScope.IonosMachine.Status.CurrentRequest.RequestPath, reqLocation)
257+
s.Equal(s.machineScope.IonosMachine.Status.CurrentRequest.RequestPath, requestLocation)
204258
}
205259

206260
func (s *serverSuite) TestReconcileServerDeletionServerNotFound() {
@@ -281,7 +335,7 @@ func (s *serverSuite) TestReconcileServerDeletionRequestFailed() {
281335
exampleRequest := s.exampleDeleteRequest(sdk.RequestStatusFailed, exampleServerID)
282336

283337
s.mockGetServerDeletionRequestCall(exampleServerID).Return([]sdk.Request{exampleRequest}, nil)
284-
s.mockDeleteServerCall(exampleServerID).Return("delete/triggered", nil)
338+
s.mockDeleteServerCall(exampleServerID, false).Return("delete/triggered", nil)
285339

286340
res, err := s.service.ReconcileServerDeletion(s.ctx, s.machineScope)
287341
s.NoError(err)
@@ -352,8 +406,12 @@ func (s *serverSuite) mockListServersCall() *clienttest.MockClient_ListServers_C
352406
return s.ionosClient.EXPECT().ListServers(s.ctx, s.machineScope.DatacenterID())
353407
}
354408

355-
func (s *serverSuite) mockDeleteServerCall(serverID string) *clienttest.MockClient_DeleteServer_Call {
356-
return s.ionosClient.EXPECT().DeleteServer(s.ctx, s.machineScope.DatacenterID(), serverID)
409+
func (s *serverSuite) mockDeleteVolumeCall(volumeID string) *clienttest.MockClient_DeleteVolume_Call {
410+
return s.ionosClient.EXPECT().DeleteVolume(s.ctx, s.machineScope.DatacenterID(), volumeID)
411+
}
412+
413+
func (s *serverSuite) mockDeleteServerCall(serverID string, deleteVolumes bool) *clienttest.MockClient_DeleteServer_Call {
414+
return s.ionosClient.EXPECT().DeleteServer(s.ctx, s.machineScope.DatacenterID(), serverID, deleteVolumes)
357415
}
358416

359417
func (s *serverSuite) mockGetServerCreationRequestCall() *clienttest.MockClient_GetRequests_Call {

internal/service/cloud/suite_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ const (
6767
exampleSecondaryNICID = "f3b3f8e4-3b6d-4b6d-8f1d-3e3e6e3e3e3d"
6868
exampleIPBlockID = "f882d597-4ee2-4b89-b01a-cbecd0f513d8"
6969
exampleServerID = "dd426c63-cd1d-4c02-aca3-13b4a27c2ebf"
70+
exampleBootVolumeID = "dd426c63-cd1d-4c02-aca3-13b4a27c2ebf"
7071
exampleSecondaryServerID = "dd426c63-cd1d-4c02-aca3-13b4a27c2ebd"
7172
exampleRequestPath = "/test"
7273
exampleLocation = "de/txl"

scope/cluster.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,11 @@ func (c *Cluster) Location() string {
148148
return c.IonosCluster.Spec.Location
149149
}
150150

151+
// IsDeleted checks if the cluster was requested for deletion.
152+
func (c *Cluster) IsDeleted() bool {
153+
return !c.Cluster.DeletionTimestamp.IsZero() || !c.IonosCluster.DeletionTimestamp.IsZero()
154+
}
155+
151156
// PatchObject will apply all changes from the IonosCloudCluster.
152157
// It will also make sure to patch the status subresource.
153158
func (c *Cluster) PatchObject() error {

0 commit comments

Comments
 (0)