Skip to content

Commit 23e5d08

Browse files
committed
support (force) deleting buckets
1 parent 6719ab4 commit 23e5d08

14 files changed

+487
-48
lines changed

api/v1alpha2/linodecluster_types.go

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ type LinodeClusterSpec struct {
3333
// The Linode Region the LinodeCluster lives in.
3434
Region string `json:"region"`
3535

36-
// ControlPlaneEndpoint represents the endpoint used to communicate with the LinodeCluster control plane.
36+
// ControlPlaneEndpoint represents the endpoint used to communicate with
37+
// the LinodeCluster control plane.
3738
// If ControlPlaneEndpoint is unset then the Nodebalancer ip will be used.
3839
// +optional
3940
ControlPlaneEndpoint clusterv1.APIEndpoint `json:"controlPlaneEndpoint"`
@@ -47,22 +48,25 @@ type LinodeClusterSpec struct {
4748
VPCRef *corev1.ObjectReference `json:"vpcRef,omitempty"`
4849

4950
// +kubebuilder:validation:XValidation:rule="self == oldSelf",message="Value is immutable"
50-
// VPCID is the ID of an existing VPC in Linode. This allows using a VPC that is not managed by CAPL.
51+
// VPCID is the ID of an existing VPC in Linode. This allows using a VPC
52+
// that is not managed by CAPL.
5153
// +optional
5254
VPCID *int `json:"vpcID,omitempty"`
5355

5456
// +kubebuilder:validation:XValidation:rule="self == oldSelf",message="Value is immutable"
5557
// +optional
56-
// NodeBalancerFirewallRef is a reference to a NodeBalancer Firewall object. This makes the linode use the specified NodeBalancer Firewall.
58+
// NodeBalancerFirewallRef is a reference to a NodeBalancer Firewall object.
59+
// This makes the linode use the specified NodeBalancer Firewall.
5760
NodeBalancerFirewallRef *corev1.ObjectReference `json:"nodeBalancerFirewallRef,omitempty"`
5861

59-
// ObjectStore defines a supporting Object Storage bucket for cluster operations. This is currently used for
60-
// bootstrapping (e.g. Cloud-init).
62+
// ObjectStore defines a supporting Object Storage bucket for cluster
63+
// operations. This is currently used for bootstrapping (e.g. Cloud-init).
6164
// +optional
6265
ObjectStore *ObjectStore `json:"objectStore,omitempty"`
6366

64-
// CredentialsRef is a reference to a Secret that contains the credentials to use for provisioning this cluster. If not
65-
// supplied then the credentials of the controller will be used.
67+
// CredentialsRef is a reference to a Secret that contains the credentials
68+
// to use for provisioning this cluster. If not supplied then the
69+
// credentials of the controller will be used.
6670
// +optional
6771
CredentialsRef *corev1.SecretReference `json:"credentialsRef,omitempty"`
6872
}

api/v1alpha2/linodeobjectstoragebucket_types.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ const (
2929
ACLPublicRead ObjectStorageACL = "public-read"
3030
ACLAuthenticatedRead ObjectStorageACL = "authenticated-read"
3131
ACLPublicReadWrite ObjectStorageACL = "public-read-write"
32+
33+
BucketFinalizer = "linodeobjectstoragebucket.infrastructure.cluster.x-k8s.io"
3234
)
3335

3436
// LinodeObjectStorageBucketSpec defines the desired state of LinodeObjectStorageBucket
@@ -55,6 +57,14 @@ type LinodeObjectStorageBucketSpec struct {
5557
// If not supplied then the credentials of the controller will be used.
5658
// +optional
5759
CredentialsRef *corev1.SecretReference `json:"credentialsRef"`
60+
61+
// AccessKeyRef is a reference to a LinodeObjectStorageBucketKey for the bucket.
62+
// +optional
63+
AccessKeyRef *corev1.ObjectReference `json:"accessKeyRef"`
64+
65+
// ForceDeleteBucket enables the object storage bucket used to be deleted even if it contains objects.
66+
// +optional
67+
ForceDeleteBucket bool `json:"forceDeleteBucket,omitempty"`
5868
}
5969

6070
// LinodeObjectStorageBucketStatus defines the observed state of LinodeObjectStorageBucket

api/v1alpha2/zz_generated.deepcopy.go

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

cloud/scope/object_storage_bucket.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import (
88

99
"github.com/go-logr/logr"
1010
"sigs.k8s.io/cluster-api/util/patch"
11+
"sigs.k8s.io/controller-runtime/pkg/client"
12+
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
1113

1214
infrav1alpha2 "github.com/linode/cluster-api-provider-linode/api/v1alpha2"
1315
"github.com/linode/cluster-api-provider-linode/clients"
@@ -77,6 +79,16 @@ func NewObjectStorageBucketScope(ctx context.Context, linodeClientConfig ClientC
7779
}, nil
7880
}
7981

82+
// AddFinalizer adds a finalizer if not present and immediately patches the
83+
// object to avoid any race conditions.
84+
func (s *ObjectStorageBucketScope) AddFinalizer(ctx context.Context) error {
85+
if controllerutil.AddFinalizer(s.Bucket, infrav1alpha2.BucketFinalizer) {
86+
return s.Close(ctx)
87+
}
88+
89+
return nil
90+
}
91+
8092
// PatchObject persists the object storage bucket configuration and status.
8193
func (s *ObjectStorageBucketScope) PatchObject(ctx context.Context) error {
8294
return s.PatchHelper.Patch(ctx, s.Bucket)
@@ -86,3 +98,54 @@ func (s *ObjectStorageBucketScope) PatchObject(ctx context.Context) error {
8698
func (s *ObjectStorageBucketScope) Close(ctx context.Context) error {
8799
return s.PatchObject(ctx)
88100
}
101+
102+
// AddAccessKeyRefFinalizer adds a finalizer to the linodeobjectstoragekey referenced in spec.AccessKeyRef.
103+
func (s *ObjectStorageBucketScope) AddAccessKeyRefFinalizer(ctx context.Context, finalizer string) error {
104+
obj, err := s.getAccessKey(ctx)
105+
if err != nil {
106+
return err
107+
}
108+
109+
controllerutil.AddFinalizer(obj, finalizer)
110+
if err := s.Client.Update(ctx, obj); err != nil {
111+
return fmt.Errorf("add linodeobjectstoragekey finalizer %s/%s: %w", s.Bucket.Spec.AccessKeyRef.Namespace, s.Bucket.Spec.AccessKeyRef.Name, err)
112+
}
113+
114+
return nil
115+
}
116+
117+
// RemoveAccessKeyRefFinalizer removes a finalizer from the linodeobjectstoragekey referenced in spec.AccessKeyRef.
118+
func (s *ObjectStorageBucketScope) RemoveAccessKeyRefFinalizer(ctx context.Context, finalizer string) error {
119+
obj, err := s.getAccessKey(ctx)
120+
if err != nil {
121+
return err
122+
}
123+
124+
controllerutil.RemoveFinalizer(obj, finalizer)
125+
if err := s.Client.Update(ctx, obj); err != nil {
126+
return fmt.Errorf("remove linodeobjectstoragekey finalizer %s/%s: %w", s.Bucket.Spec.AccessKeyRef.Namespace, s.Bucket.Spec.AccessKeyRef.Name, err)
127+
}
128+
129+
return nil
130+
}
131+
132+
func (s *ObjectStorageBucketScope) getAccessKey(ctx context.Context) (*infrav1alpha2.LinodeObjectStorageKey, error) {
133+
if s.Bucket.Spec.AccessKeyRef == nil {
134+
return nil, fmt.Errorf("accessKeyRef is nil for bucket %s", s.Bucket.Name)
135+
}
136+
137+
objKey := client.ObjectKey{
138+
Name: s.Bucket.Spec.AccessKeyRef.Name,
139+
Namespace: s.Bucket.Spec.AccessKeyRef.Namespace,
140+
}
141+
if s.Bucket.Spec.AccessKeyRef.Namespace == "" {
142+
s.Bucket.Spec.AccessKeyRef.Namespace = s.Bucket.GetNamespace()
143+
}
144+
145+
objStorageKey := &infrav1alpha2.LinodeObjectStorageKey{}
146+
if err := s.Client.Get(ctx, objKey, objStorageKey); err != nil {
147+
return nil, fmt.Errorf("get linodeobjectstoragekey %s: %w", s.Bucket.Spec.AccessKeyRef.Name, err)
148+
}
149+
150+
return objStorageKey, nil
151+
}

cloud/services/object_storage_buckets.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,20 @@ import (
55
"fmt"
66
"net/http"
77

8+
"github.com/aws/aws-sdk-go-v2/aws"
9+
awsconfig "github.com/aws/aws-sdk-go-v2/config"
10+
"github.com/aws/aws-sdk-go-v2/credentials"
11+
"github.com/aws/aws-sdk-go-v2/service/s3"
812
"github.com/linode/linodego"
13+
corev1 "k8s.io/api/core/v1"
14+
"k8s.io/apimachinery/pkg/types"
915

16+
"github.com/linode/cluster-api-provider-linode/clients"
1017
"github.com/linode/cluster-api-provider-linode/cloud/scope"
1118
"github.com/linode/cluster-api-provider-linode/util"
1219
)
1320

21+
// EnsureAndUpdateObjectStorageBucket ensures that the bucket exists and updates its access options if necessary.
1422
func EnsureAndUpdateObjectStorageBucket(ctx context.Context, bScope *scope.ObjectStorageBucketScope) (*linodego.ObjectStorageBucket, error) {
1523
bucket, err := bScope.LinodeClient.GetObjectStorageBucket(
1624
ctx,
@@ -62,3 +70,50 @@ func EnsureAndUpdateObjectStorageBucket(ctx context.Context, bScope *scope.Objec
6270

6371
return bucket, nil
6472
}
73+
74+
// DeleteBucket deletes the bucket and all its objects.
75+
func DeleteBucket(ctx context.Context, bScope *scope.ObjectStorageBucketScope) error {
76+
s3Client, err := createS3ClientWithAccessKey(ctx, bScope.Client, bScope.Bucket.Spec.AccessKeyRef, bScope.Bucket.Namespace)
77+
if err != nil {
78+
return fmt.Errorf("failed to create S3 client: %w", err)
79+
}
80+
if err := PurgeAllObjects(ctx, bScope.Bucket.Name, s3Client, true, true); err != nil {
81+
return fmt.Errorf("failed to purge all objects: %w", err)
82+
}
83+
84+
return nil
85+
}
86+
87+
// createS3ClientWithAccessKey creates a connection to s3 given k8s client and an access key reference.
88+
func createS3ClientWithAccessKey(ctx context.Context, crClient clients.K8sClient, accessKeyRef *corev1.ObjectReference, defaultNamespace string) (*s3.Client, error) {
89+
if accessKeyRef == nil {
90+
return nil, fmt.Errorf("accessKeyRef is nil")
91+
}
92+
objSecret := &corev1.Secret{}
93+
if accessKeyRef.Namespace == "" {
94+
accessKeyRef.Namespace = defaultNamespace
95+
}
96+
if err := crClient.Get(ctx, types.NamespacedName{Name: accessKeyRef.Name + "-obj-key", Namespace: accessKeyRef.Namespace}, objSecret); err != nil {
97+
return nil, fmt.Errorf("failed to get bucket secret: %w", err)
98+
}
99+
accessKey := string(objSecret.Data["access"])
100+
secretKey := string(objSecret.Data["secret"])
101+
endpoint := string(objSecret.Data["endpoint"])
102+
awsConfig, err := awsconfig.LoadDefaultConfig(
103+
ctx,
104+
awsconfig.WithCredentialsProvider(
105+
credentials.NewStaticCredentialsProvider(accessKey, secretKey, ""),
106+
),
107+
awsconfig.WithRegion("auto"),
108+
)
109+
if err != nil {
110+
return nil, fmt.Errorf("failed to create aws config: %w", err)
111+
}
112+
113+
s3Client := s3.NewFromConfig(awsConfig, func(opts *s3.Options) {
114+
opts.BaseEndpoint = aws.String(endpoint)
115+
opts.DisableLogOutputChecksumValidationSkipped = true
116+
})
117+
118+
return s3Client, nil
119+
}

cloud/services/object_storage_objects.go

Lines changed: 142 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import (
99
"github.com/aws/aws-sdk-go-v2/aws"
1010
s3manager "github.com/aws/aws-sdk-go-v2/feature/s3/manager"
1111
"github.com/aws/aws-sdk-go-v2/service/s3"
12-
"github.com/aws/aws-sdk-go-v2/service/s3/types"
12+
s3types "github.com/aws/aws-sdk-go-v2/service/s3/types"
1313
"github.com/aws/smithy-go"
1414

1515
"github.com/linode/cluster-api-provider-linode/cloud/scope"
@@ -104,9 +104,9 @@ func DeleteObject(ctx context.Context, mscope *scope.MachineScope) error {
104104
if err != nil {
105105
var (
106106
ae smithy.APIError
107-
bne *types.NoSuchBucket
108-
kne *types.NoSuchKey
109-
nf *types.NotFound
107+
bne *s3types.NoSuchBucket
108+
kne *s3types.NoSuchKey
109+
nf *s3types.NotFound
110110
)
111111
switch {
112112
// In the case that the IAM policy does not have sufficient permissions to get the object, we will attempt to
@@ -137,3 +137,141 @@ func DeleteObject(ctx context.Context, mscope *scope.MachineScope) error {
137137

138138
return nil
139139
}
140+
141+
// PurgeAllObjects wipes out all versions and delete markers for versioned objects.
142+
func PurgeAllObjects(
143+
ctx context.Context,
144+
bucket string,
145+
s3client *s3.Client,
146+
bypassRetention,
147+
ignoreNotFound bool,
148+
) error {
149+
versioning, err := s3client.GetBucketVersioning(ctx, &s3.GetBucketVersioningInput{
150+
Bucket: aws.String(bucket),
151+
})
152+
if err != nil {
153+
return err
154+
}
155+
156+
if versioning.Status == s3types.BucketVersioningStatusEnabled {
157+
err = DeleteAllObjectVersionsAndDeleteMarkers(
158+
ctx,
159+
s3client,
160+
bucket,
161+
"",
162+
bypassRetention,
163+
ignoreNotFound,
164+
)
165+
} else {
166+
err = DeleteAllObjects(ctx, s3client, bucket, bypassRetention)
167+
}
168+
return err
169+
}
170+
171+
// DeleteAllObjects sends delete requests for every object.
172+
// Versioned objects will get a deletion marker instead of being fully purged.
173+
func DeleteAllObjects(
174+
ctx context.Context,
175+
s3client *s3.Client,
176+
bucketName string,
177+
bypassRetention bool,
178+
) error {
179+
objPaginator := s3.NewListObjectsV2Paginator(s3client, &s3.ListObjectsV2Input{
180+
Bucket: aws.String(bucketName),
181+
})
182+
183+
var objectsToDelete []s3types.ObjectIdentifier
184+
for objPaginator.HasMorePages() {
185+
page, err := objPaginator.NextPage(ctx)
186+
if err != nil {
187+
return err
188+
}
189+
190+
for _, obj := range page.Contents {
191+
objectsToDelete = append(objectsToDelete, s3types.ObjectIdentifier{
192+
Key: obj.Key,
193+
})
194+
}
195+
}
196+
197+
if len(objectsToDelete) == 0 {
198+
return nil
199+
}
200+
201+
_, err := s3client.DeleteObjects(ctx, &s3.DeleteObjectsInput{
202+
Bucket: aws.String(bucketName),
203+
Delete: &s3types.Delete{Objects: objectsToDelete},
204+
BypassGovernanceRetention: &bypassRetention,
205+
})
206+
207+
return err
208+
}
209+
210+
// DeleteAllObjectVersionsAndDeleteMarkers deletes all versions of a given object
211+
func DeleteAllObjectVersionsAndDeleteMarkers(ctx context.Context, client *s3.Client, bucket, prefix string, bypassRetention, ignoreNotFound bool) error {
212+
paginator := s3.NewListObjectVersionsPaginator(client, &s3.ListObjectVersionsInput{
213+
Bucket: aws.String(bucket),
214+
Prefix: aws.String(prefix),
215+
})
216+
217+
var objectsToDelete []s3types.ObjectIdentifier
218+
for paginator.HasMorePages() {
219+
page, err := paginator.NextPage(ctx)
220+
if page == nil {
221+
continue
222+
}
223+
if err != nil {
224+
if !IsObjNotFoundErr(err) || !ignoreNotFound {
225+
return err
226+
}
227+
}
228+
229+
for _, version := range page.Versions {
230+
objectsToDelete = append(
231+
objectsToDelete,
232+
s3types.ObjectIdentifier{
233+
Key: version.Key,
234+
VersionId: version.VersionId,
235+
},
236+
)
237+
}
238+
for _, marker := range page.DeleteMarkers {
239+
objectsToDelete = append(
240+
objectsToDelete,
241+
s3types.ObjectIdentifier{
242+
Key: marker.Key,
243+
VersionId: marker.VersionId,
244+
},
245+
)
246+
}
247+
}
248+
249+
if len(objectsToDelete) == 0 {
250+
return nil
251+
}
252+
253+
_, err := client.DeleteObjects(
254+
ctx,
255+
&s3.DeleteObjectsInput{
256+
Bucket: aws.String(bucket),
257+
Delete: &s3types.Delete{Objects: objectsToDelete},
258+
BypassGovernanceRetention: &bypassRetention,
259+
},
260+
)
261+
if err != nil {
262+
if !IsObjNotFoundErr(err) || !ignoreNotFound {
263+
return err
264+
}
265+
}
266+
return nil
267+
}
268+
269+
// IsObjNotFoundErr checks if the error is a NotFound or Forbidden error from the S3 API.
270+
func IsObjNotFoundErr(err error) bool {
271+
var apiErr smithy.APIError
272+
// Error code is 'Forbidden' when the bucket has been removed
273+
if errors.As(err, &apiErr) {
274+
return apiErr.ErrorCode() == "NotFound" || apiErr.ErrorCode() == "Forbidden"
275+
}
276+
return false
277+
}

0 commit comments

Comments
 (0)