Skip to content

Commit bff8dd3

Browse files
dkoshkinjimmidyson
andauthored
feat: delete Services type LoadBalancer on BeforeClusterDelete (#29)
Fixes #4 Automatically delete workload cluster Services with type `LoadBalancers` before deleting the cluster. It checks for certain conditions on the Cluster object if Services should be deleted. It's also possible to disable this behavior by adding an annotation `capi-runtime-extensions.d2iq-labs.com/loadbalancer-gc` to the Cluster object. --------- Signed-off-by: Dimitri Koshkin <[email protected]> Co-authored-by: Jimmi Dyson <[email protected]>
1 parent 8904554 commit bff8dd3

File tree

7 files changed

+588
-4
lines changed

7 files changed

+588
-4
lines changed

go.mod

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ require (
1212
github.com/onsi/ginkgo/v2 v2.8.1
1313
github.com/onsi/gomega v1.26.0
1414
github.com/spf13/pflag v1.0.5
15+
github.com/stretchr/testify v1.8.0
1516
golang.org/x/sync v0.1.0
1617
k8s.io/apimachinery v0.25.6
1718
k8s.io/component-base v0.25.6
@@ -21,9 +22,11 @@ require (
2122
)
2223

2324
require (
25+
github.com/evanphx/json-patch v5.6.0+incompatible // indirect
2426
github.com/fluxcd/pkg/apis/acl v0.1.0 // indirect
2527
github.com/fluxcd/pkg/apis/kustomize v0.7.0 // indirect
2628
github.com/go-logr/zapr v1.2.3 // indirect
29+
github.com/pmezard/go-difflib v1.0.0 // indirect
2730
go.uber.org/atomic v1.7.0 // indirect
2831
go.uber.org/multierr v1.6.0 // indirect
2932
go.uber.org/zap v1.21.0 // indirect

go.sum

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.m
9797
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
9898
github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ=
9999
github.com/evanphx/json-patch v5.6.0+incompatible h1:jBYDEEiFBPxA0v50tFdvOzQQTCvpL6mnFh5mB2/l16U=
100+
github.com/evanphx/json-patch v5.6.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk=
100101
github.com/evanphx/json-patch/v5 v5.6.0 h1:b91NhWfaz02IuVxO9faSllyAtNXHMPkC5J8sJCLunww=
101102
github.com/evanphx/json-patch/v5 v5.6.0/go.mod h1:G79N1coSVB93tBe7j6PhzjmR3/2VvlbKOFpnXhI9Bw4=
102103
github.com/flowstack/go-jsonschema v0.1.1/go.mod h1:yL7fNggx1o8rm9RlgXv7hTBWxdBM0rVwpMwimd3F3N0=

pkg/constants/constants.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
// Copyright 2023 D2iQ, Inc. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package constants
5+
6+
const (
7+
LoadBalancerGCAnnotation = "labs.d2iq.io/loadbalancer-gc"
8+
)

pkg/handlers/lifecycle/handlers.go

Lines changed: 71 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"context"
88
"fmt"
99

10+
"github.com/go-logr/logr"
1011
corev1 "k8s.io/api/core/v1"
1112
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
1213
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
@@ -19,6 +20,7 @@ import (
1920
"github.com/d2iq-labs/capi-runtime-extensions/pkg/addons/clusterresourcesets"
2021
"github.com/d2iq-labs/capi-runtime-extensions/pkg/addons/fluxhelmrelease"
2122
k8sclient "github.com/d2iq-labs/capi-runtime-extensions/pkg/k8s/client"
23+
"github.com/d2iq-labs/capi-runtime-extensions/pkg/k8s/deleter"
2224
)
2325

2426
type AddonProvider string
@@ -50,7 +52,12 @@ func (m *ExtensionHandlers) DoBeforeClusterCreate(
5052
request *runtimehooksv1.BeforeClusterCreateRequest,
5153
response *runtimehooksv1.BeforeClusterCreateResponse,
5254
) {
53-
log := ctrl.LoggerFrom(ctx)
55+
log := ctrl.LoggerFrom(ctx).WithValues(
56+
"Cluster",
57+
request.Cluster.GetName(),
58+
"Namespace",
59+
request.Cluster.GetNamespace(),
60+
)
5461
log.Info("BeforeClusterCreate is called")
5562
}
5663

@@ -59,7 +66,12 @@ func (m *ExtensionHandlers) DoAfterControlPlaneInitialized(
5966
request *runtimehooksv1.AfterControlPlaneInitializedRequest,
6067
response *runtimehooksv1.AfterControlPlaneInitializedResponse,
6168
) {
62-
log := ctrl.LoggerFrom(ctx)
69+
log := ctrl.LoggerFrom(ctx).WithValues(
70+
"Cluster",
71+
request.Cluster.GetName(),
72+
"Namespace",
73+
request.Cluster.GetNamespace(),
74+
)
6375
log.Info("AfterControlPlaneInitialized is called")
6476

6577
genericResourcesClient := k8sclient.NewGenericResourcesClient(m.client, log)
@@ -82,7 +94,12 @@ func (m *ExtensionHandlers) DoBeforeClusterUpgrade(
8294
request *runtimehooksv1.BeforeClusterUpgradeRequest,
8395
response *runtimehooksv1.BeforeClusterUpgradeResponse,
8496
) {
85-
log := ctrl.LoggerFrom(ctx)
97+
log := ctrl.LoggerFrom(ctx).WithValues(
98+
"Cluster",
99+
request.Cluster.GetName(),
100+
"Namespace",
101+
request.Cluster.GetNamespace(),
102+
)
86103
log.Info("BeforeClusterUpgrade is called")
87104
}
88105

@@ -91,7 +108,12 @@ func (m *ExtensionHandlers) DoBeforeClusterDelete(
91108
request *runtimehooksv1.BeforeClusterDeleteRequest,
92109
response *runtimehooksv1.BeforeClusterDeleteResponse,
93110
) {
94-
log := ctrl.LoggerFrom(ctx)
111+
log := ctrl.LoggerFrom(ctx).WithValues(
112+
"Cluster",
113+
request.Cluster.GetName(),
114+
"Namespace",
115+
request.Cluster.GetNamespace(),
116+
)
95117
log.Info("BeforeClusterDelete is called")
96118

97119
genericResourcesClient := k8sclient.NewGenericResourcesClient(m.client, log)
@@ -106,6 +128,14 @@ func (m *ExtensionHandlers) DoBeforeClusterDelete(
106128
response.Status = runtimehooksv1.ResponseStatusFailure
107129
response.Message = err.Error()
108130
}
131+
132+
// Delete Services of type LoadBalancer in the Cluster
133+
// Skip if annotation capi-runtime-extensions.d2iq-labs.com/loadbalancer-gc=false
134+
err = deleteServiceLoadBalancers(ctx, log, &request.Cluster, m.client)
135+
if err != nil {
136+
response.Status = runtimehooksv1.ResponseStatusFailure
137+
response.Message = err.Error()
138+
}
109139
}
110140

111141
func applyCNIResourcesForDelete(
@@ -181,3 +211,40 @@ func applyCNIResources(
181211

182212
return genericResourcesClient.Apply(ctx, objs...)
183213
}
214+
215+
func deleteServiceLoadBalancers(
216+
ctx context.Context,
217+
log logr.Logger,
218+
cluster *v1beta1.Cluster,
219+
c ctrlclient.Client,
220+
) error {
221+
shouldDelete, err := deleter.ShouldDeleteServicesWithLoadBalancer(cluster)
222+
if err != nil {
223+
return fmt.Errorf(
224+
"error determining if Services of type LoadBalancer should be deleted: %w",
225+
err,
226+
)
227+
}
228+
if !shouldDelete {
229+
return nil
230+
}
231+
232+
log.Info("Will attempt to delete Services with type LoadBalancer")
233+
remoteClient, err := remote.NewClusterClient(
234+
ctx,
235+
"",
236+
c,
237+
ctrlclient.ObjectKeyFromObject(cluster),
238+
)
239+
if err != nil {
240+
return err
241+
}
242+
243+
dltr := deleter.New(log, cluster, remoteClient)
244+
err = dltr.DeleteServicesWithLoadBalancer(ctx)
245+
if err != nil {
246+
return fmt.Errorf("error deleting Services of type LoadBalancer: %v", err)
247+
}
248+
249+
return nil
250+
}

pkg/k8s/annotations/annotations.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
// Copyright 2023 D2iQ, Inc. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package annotations
5+
6+
import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
7+
8+
// Get will get the value of the supplied annotation.
9+
func Get(obj metav1.Object, name string) (string, bool) {
10+
annotations := obj.GetAnnotations()
11+
val, found := annotations[name]
12+
return val, found
13+
}

pkg/k8s/deleter/deleter.go

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
// Copyright 2023 D2iQ, Inc. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package deleter
5+
6+
import (
7+
"context"
8+
"errors"
9+
"fmt"
10+
"strconv"
11+
"strings"
12+
"time"
13+
14+
"github.com/go-logr/logr"
15+
corev1 "k8s.io/api/core/v1"
16+
apierrors "k8s.io/apimachinery/pkg/api/errors"
17+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
18+
"k8s.io/apimachinery/pkg/util/wait"
19+
"sigs.k8s.io/cluster-api/api/v1beta1"
20+
"sigs.k8s.io/cluster-api/util/conditions"
21+
"sigs.k8s.io/controller-runtime/pkg/client"
22+
23+
"github.com/d2iq-labs/capi-runtime-extensions/pkg/constants"
24+
"github.com/d2iq-labs/capi-runtime-extensions/pkg/k8s/annotations"
25+
)
26+
27+
var ErrFailedToDeleteService = errors.New("kubernetes Services deletion failed")
28+
29+
type Deleter struct {
30+
cluster *v1beta1.Cluster
31+
client client.Client
32+
log logr.Logger
33+
}
34+
35+
type objectMetaList []metav1.ObjectMeta
36+
37+
func (ol objectMetaList) asCommaSeparatedString() string {
38+
names := make([]string, 0, len(ol))
39+
for n := range ol {
40+
obj := ol[n]
41+
names = append(names, fmt.Sprintf("%s/%s", obj.Namespace, obj.Name))
42+
}
43+
return strings.Join(names, ", ")
44+
}
45+
46+
func New(log logr.Logger, cluster *v1beta1.Cluster, remoteClient client.Client) Deleter {
47+
return Deleter{
48+
cluster: cluster,
49+
client: remoteClient,
50+
log: log,
51+
}
52+
}
53+
54+
func (d *Deleter) DeleteServicesWithLoadBalancer(ctx context.Context) error {
55+
return deleteServicesWithLoadBalancer(ctx, d.client, d.log)
56+
}
57+
58+
func deleteServicesWithLoadBalancer(
59+
ctx context.Context,
60+
c client.Client,
61+
log logr.Logger,
62+
) error {
63+
log.Info("Listing Services with type LoadBalancer")
64+
services := &corev1.ServiceList{}
65+
err := c.List(ctx, services)
66+
if err != nil {
67+
return fmt.Errorf("error listing Services: %w", err)
68+
}
69+
70+
var svcsFailedToBeDeleted objectMetaList
71+
for i := range services.Items {
72+
svc := &services.Items[i]
73+
if needsDelete(svc) {
74+
log.Info(fmt.Sprintf("Deleting Service %s/%s", svc.Namespace, svc.Name))
75+
if err = c.Delete(ctx, svc); client.IgnoreNotFound(err) != nil {
76+
log.Error(
77+
err,
78+
fmt.Sprintf(
79+
"Error deleting Service %s/%s",
80+
svc.Namespace,
81+
svc.Name,
82+
),
83+
)
84+
svcsFailedToBeDeleted = append(svcsFailedToBeDeleted, svc.ObjectMeta)
85+
continue
86+
}
87+
if err = waitForServiceDeletion(ctx, c, svc); err != nil {
88+
log.Error(
89+
err,
90+
fmt.Sprintf(
91+
"Error waiting for Service to be deleted %s/%s",
92+
svc.Namespace,
93+
svc.Name,
94+
),
95+
)
96+
svcsFailedToBeDeleted = append(svcsFailedToBeDeleted, svc.ObjectMeta)
97+
}
98+
}
99+
}
100+
if len(svcsFailedToBeDeleted) > 0 {
101+
return failedToDeleteServicesError(svcsFailedToBeDeleted)
102+
}
103+
104+
return nil
105+
}
106+
107+
// needsDelete will return true if the Service needs to be deleted to allow for cluster cleanup.
108+
func needsDelete(service *corev1.Service) bool {
109+
if service.Spec.Type != corev1.ServiceTypeLoadBalancer ||
110+
len(service.Status.LoadBalancer.Ingress) == 0 {
111+
return false
112+
}
113+
return service.Status.LoadBalancer.Ingress[0].IP != "" ||
114+
service.Status.LoadBalancer.Ingress[0].Hostname != ""
115+
}
116+
117+
func waitForServiceDeletion(
118+
ctx context.Context,
119+
c client.Client,
120+
service *corev1.Service,
121+
) error {
122+
backoff := wait.Backoff{
123+
Duration: 1 * time.Second,
124+
Factor: 1.5,
125+
Jitter: 0,
126+
Steps: 13,
127+
}
128+
return wait.ExponentialBackoff(backoff, func() (bool, error) {
129+
key := client.ObjectKey{
130+
Namespace: service.Namespace,
131+
Name: service.Name,
132+
}
133+
err := c.Get(ctx, key, service)
134+
if err != nil {
135+
if apierrors.IsNotFound(err) {
136+
return true, nil
137+
}
138+
return false, err
139+
}
140+
return false, nil
141+
})
142+
}
143+
144+
func failedToDeleteServicesError(svcsFailedToBeDeleted objectMetaList) error {
145+
return fmt.Errorf("%w: the following Services could not be deleted "+
146+
"and must cleaned up manually before deleting the cluster: %s",
147+
ErrFailedToDeleteService,
148+
svcsFailedToBeDeleted.asCommaSeparatedString(),
149+
)
150+
}
151+
152+
func ShouldDeleteServicesWithLoadBalancer(cluster *v1beta1.Cluster) (bool, error) {
153+
// use the Cluster annotations to skip deleting
154+
val, found := annotations.Get(cluster, constants.LoadBalancerGCAnnotation)
155+
if !found {
156+
val = "true"
157+
}
158+
shouldDeleteBasedOnAnnotation, err := strconv.ParseBool(val)
159+
if err != nil {
160+
return false, fmt.Errorf(
161+
"converting value %s of annotation %s to bool: %w",
162+
val,
163+
constants.LoadBalancerGCAnnotation,
164+
err,
165+
)
166+
}
167+
168+
// use the Cluster phase to determine if it's safe to skip deleting
169+
//
170+
// when ClusterPhasePending or ClusterPhaseProvisioning Kubernetes API has not been created
171+
// and the user would not have been able to create any Kubernetes resources that would prevent cleanup
172+
//nolint:lll // long URL cannot be split up
173+
// https://github.com/kubernetes-sigs/cluster-api/blob/7f879be68d15737e335b6cb39d380d1d163e06e6/controllers/cluster_controller_phases.go#L44-L50
174+
//
175+
// when ClusterPhaseDeleting it's too late to try to cleanup
176+
phase := cluster.Status.GetTypedPhase()
177+
skipDeleteBasedOnPhase := phase == v1beta1.ClusterPhasePending ||
178+
phase == v1beta1.ClusterPhaseProvisioning ||
179+
phase == v1beta1.ClusterPhaseDeleting
180+
181+
// use the Cluster conditions to determine if the API server is even reachable
182+
controlPlaneReachable := conditions.IsTrue(cluster, v1beta1.ControlPlaneInitializedCondition)
183+
184+
return shouldDeleteBasedOnAnnotation && controlPlaneReachable && !skipDeleteBasedOnPhase, nil
185+
}

0 commit comments

Comments
 (0)