Skip to content

Commit c8bbdcb

Browse files
committed
feat: delete Services type LoadBalancer on BeforeClusterDelete
1 parent 8904554 commit c8bbdcb

File tree

7 files changed

+596
-4
lines changed

7 files changed

+596
-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 = "capi-runtime-extensions.d2iq-labs.com/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: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
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) (value string, found bool) {
10+
annotations := obj.GetAnnotations()
11+
if len(annotations) == 0 {
12+
return "", false
13+
}
14+
15+
value, found = annotations[name]
16+
17+
return
18+
}

pkg/k8s/deleter/deleter.go

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

0 commit comments

Comments
 (0)