From cb6bf373223355a0ec800c408a6d1d512f113501 Mon Sep 17 00:00:00 2001 From: bcm820 Date: Mon, 4 Mar 2024 13:16:36 -0500 Subject: [PATCH] [feat] Add LinodeObjectStorageBucket (#154) Co-authored-by: Amol Deodhar --- PROJECT | 9 + .../linodeobjectstoragebucket_types.go | 120 +++++++++ api/v1alpha1/zz_generated.deepcopy.go | 130 ++++++++++ cloud/scope/common.go | 25 +- cloud/scope/object_storage_bucket.go | 185 ++++++++++++++ cloud/services/object_storage_buckets.go | 151 +++++++++++ cmd/main.go | 23 +- ...r.x-k8s.io_linodeobjectstoragebuckets.yaml | 169 +++++++++++++ config/crd/kustomization.yaml | 3 + ...linodeobjectstoragebucket_editor_role.yaml | 31 +++ ...linodeobjectstoragebucket_viewer_role.yaml | 27 ++ config/rbac/role.yaml | 29 +++ ...re_v1alpha1_linodeobjectstoragebucket.yaml | 24 ++ config/samples/kustomization.yaml | 1 + .../linodeobjectstoragebucket_controller.go | 235 ++++++++++++++++++ controller/suite_test.go | 2 +- e2e/Makefile | 3 + e2e/kuttl-config.yaml | 1 + .../00-assert.yaml | 15 ++ .../00-ensure-no-bucket.yaml | 5 + .../01-assert.yaml | 11 + .../01-create-linodeobjectstoragebucket.yaml | 6 + .../02-verify-bucket-and-secret.yaml | 9 + .../03-assert.yaml | 11 + .../03-patch-bucket.yaml | 7 + .../04-delete-linodeobjectstoragebucket.yaml | 6 + .../05-errors.yaml | 9 + .../Makefile | 1 + 28 files changed, 1241 insertions(+), 7 deletions(-) create mode 100644 api/v1alpha1/linodeobjectstoragebucket_types.go create mode 100644 cloud/scope/object_storage_bucket.go create mode 100644 cloud/services/object_storage_buckets.go create mode 100644 config/crd/bases/infrastructure.cluster.x-k8s.io_linodeobjectstoragebuckets.yaml create mode 100644 config/rbac/linodeobjectstoragebucket_editor_role.yaml create mode 100644 config/rbac/linodeobjectstoragebucket_viewer_role.yaml create mode 100644 config/samples/infrastructure_v1alpha1_linodeobjectstoragebucket.yaml create mode 100644 controller/linodeobjectstoragebucket_controller.go create mode 100644 e2e/linodeobjectstoragebucket-controller/minimal-linodeobjectstoragebucket/00-assert.yaml create mode 100644 e2e/linodeobjectstoragebucket-controller/minimal-linodeobjectstoragebucket/00-ensure-no-bucket.yaml create mode 100644 e2e/linodeobjectstoragebucket-controller/minimal-linodeobjectstoragebucket/01-assert.yaml create mode 100644 e2e/linodeobjectstoragebucket-controller/minimal-linodeobjectstoragebucket/01-create-linodeobjectstoragebucket.yaml create mode 100644 e2e/linodeobjectstoragebucket-controller/minimal-linodeobjectstoragebucket/02-verify-bucket-and-secret.yaml create mode 100644 e2e/linodeobjectstoragebucket-controller/minimal-linodeobjectstoragebucket/03-assert.yaml create mode 100644 e2e/linodeobjectstoragebucket-controller/minimal-linodeobjectstoragebucket/03-patch-bucket.yaml create mode 100644 e2e/linodeobjectstoragebucket-controller/minimal-linodeobjectstoragebucket/04-delete-linodeobjectstoragebucket.yaml create mode 100644 e2e/linodeobjectstoragebucket-controller/minimal-linodeobjectstoragebucket/05-errors.yaml create mode 100644 e2e/linodeobjectstoragebucket-controller/minimal-linodeobjectstoragebucket/Makefile diff --git a/PROJECT b/PROJECT index d87680376..320515e01 100644 --- a/PROJECT +++ b/PROJECT @@ -42,4 +42,13 @@ resources: kind: LinodeMachineTemplate path: github.com/linode/cluster-api-provider-linode/api/v1alpha1 version: v1alpha1 +- api: + crdVersion: v1 + namespaced: true + controller: true + domain: cluster.x-k8s.io + group: infrastructure + kind: LinodeObjectStorageBucket + path: github.com/linode/cluster-api-provider-linode/api/v1alpha1 + version: v1alpha1 version: "3" diff --git a/api/v1alpha1/linodeobjectstoragebucket_types.go b/api/v1alpha1/linodeobjectstoragebucket_types.go new file mode 100644 index 000000000..3a6b237f6 --- /dev/null +++ b/api/v1alpha1/linodeobjectstoragebucket_types.go @@ -0,0 +1,120 @@ +/* +Copyright 2023 Akamai Technologies, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1alpha1 + +import ( + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" +) + +// EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN! +// NOTE: json tags are required. Any new fields you add must have json tags for the fields to be serialized. + +// LinodeObjectStorageBucketSpec defines the desired state of LinodeObjectStorageBucket +type LinodeObjectStorageBucketSpec struct { + // INSERT ADDITIONAL SPEC FIELDS - desired state of cluster + // Important: Run "make" to regenerate code after modifying this file + + // Cluster is the ID of the Object Storage cluster for the bucket. + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="Value is immutable" + Cluster string `json:"cluster"` + + // CredentialsRef is a reference to a Secret that contains the credentials to use for provisioning the bucket. + // If not supplied then the credentials of the controller will be used. + // +optional + CredentialsRef *corev1.SecretReference `json:"credentialsRef"` + + // KeyGeneration may be modified to trigger rotations of access keys created for the bucket. + // +optional + // +kubebuilder:default=0 + KeyGeneration *int `json:"keyGeneration,omitempty"` +} + +// LinodeObjectStorageBucketStatus defines the observed state of LinodeObjectStorageBucket +type LinodeObjectStorageBucketStatus struct { + // INSERT ADDITIONAL STATUS FIELD - define observed state of cluster + // Important: Run "make" to regenerate code after modifying this file + + // Ready denotes that the bucket has been provisioned along with access keys. + // +optional + // +kubebuilder:default=false + Ready bool `json:"ready"` + + // FailureMessage will be set in the event that there is a terminal problem + // reconciling the Object Storage Bucket and will contain a verbose string + // suitable for logging and human consumption. + // +optional + FailureMessage *string `json:"failureMessage,omitempty"` + + // Conditions specify the service state of the LinodeObjectStorageBucket. + // +optional + Conditions clusterv1.Conditions `json:"conditions,omitempty"` + + // Hostname is the address assigned to the bucket. + // +optional + Hostname *string `json:"hostname,omitempty"` + + // CreationTime specifies the creation timestamp for the bucket. + // +optional + CreationTime *metav1.Time `json:"creationTime,omitempty"` + + // LastKeyGeneration tracks the last known value of .spec.keyGeneration. + // +optional + LastKeyGeneration *int `json:"lastKeyGeneration,omitempty"` + + // KeySecretName specifies the name of the Secret containing access keys for the bucket. + // +optional + KeySecretName *string `json:"keySecretName,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:resource:path=linodeobjectstoragebuckets,scope=Namespaced,shortName=lobj +// +kubebuilder:subresource:status +// +kubebuilder:printcolumn:name="Label",type="string",JSONPath=".spec.label",description="The name of the bucket" +// +kubebuilder:printcolumn:name="Cluster",type="string",JSONPath=".spec.cluster",description="The ID of the Object Storage cluster for the bucket" +// +kubebuilder:printcolumn:name="Ready",type="string",JSONPath=".status.ready",description="Bucket and keys have been provisioned" + +// LinodeObjectStorageBucket is the Schema for the linodeobjectstoragebuckets API +type LinodeObjectStorageBucket struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec LinodeObjectStorageBucketSpec `json:"spec,omitempty"` + Status LinodeObjectStorageBucketStatus `json:"status,omitempty"` +} + +func (b *LinodeObjectStorageBucket) GetConditions() clusterv1.Conditions { + return b.Status.Conditions +} + +func (b *LinodeObjectStorageBucket) SetConditions(conditions clusterv1.Conditions) { + b.Status.Conditions = conditions +} + +//+kubebuilder:object:root=true + +// LinodeObjectStorageBucketList contains a list of LinodeObjectStorageBucket +type LinodeObjectStorageBucketList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []LinodeObjectStorageBucket `json:"items"` +} + +func init() { + SchemeBuilder.Register(&LinodeObjectStorageBucket{}, &LinodeObjectStorageBucketList{}) +} diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index c65da8d0d..bca2950ca 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -526,6 +526,136 @@ func (in *LinodeMachineTemplateSpec) DeepCopy() *LinodeMachineTemplateSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LinodeObjectStorageBucket) DeepCopyInto(out *LinodeObjectStorageBucket) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LinodeObjectStorageBucket. +func (in *LinodeObjectStorageBucket) DeepCopy() *LinodeObjectStorageBucket { + if in == nil { + return nil + } + out := new(LinodeObjectStorageBucket) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *LinodeObjectStorageBucket) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LinodeObjectStorageBucketList) DeepCopyInto(out *LinodeObjectStorageBucketList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]LinodeObjectStorageBucket, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LinodeObjectStorageBucketList. +func (in *LinodeObjectStorageBucketList) DeepCopy() *LinodeObjectStorageBucketList { + if in == nil { + return nil + } + out := new(LinodeObjectStorageBucketList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *LinodeObjectStorageBucketList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LinodeObjectStorageBucketSpec) DeepCopyInto(out *LinodeObjectStorageBucketSpec) { + *out = *in + if in.CredentialsRef != nil { + in, out := &in.CredentialsRef, &out.CredentialsRef + *out = new(v1.SecretReference) + **out = **in + } + if in.KeyGeneration != nil { + in, out := &in.KeyGeneration, &out.KeyGeneration + *out = new(int) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LinodeObjectStorageBucketSpec. +func (in *LinodeObjectStorageBucketSpec) DeepCopy() *LinodeObjectStorageBucketSpec { + if in == nil { + return nil + } + out := new(LinodeObjectStorageBucketSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LinodeObjectStorageBucketStatus) DeepCopyInto(out *LinodeObjectStorageBucketStatus) { + *out = *in + if in.FailureMessage != nil { + in, out := &in.FailureMessage, &out.FailureMessage + *out = new(string) + **out = **in + } + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make(v1beta1.Conditions, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Hostname != nil { + in, out := &in.Hostname, &out.Hostname + *out = new(string) + **out = **in + } + if in.CreationTime != nil { + in, out := &in.CreationTime, &out.CreationTime + *out = (*in).DeepCopy() + } + if in.LastKeyGeneration != nil { + in, out := &in.LastKeyGeneration, &out.LastKeyGeneration + *out = new(int) + **out = **in + } + if in.KeySecretName != nil { + in, out := &in.KeySecretName, &out.KeySecretName + *out = new(string) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LinodeObjectStorageBucketStatus. +func (in *LinodeObjectStorageBucketStatus) DeepCopy() *LinodeObjectStorageBucketStatus { + if in == nil { + return nil + } + out := new(LinodeObjectStorageBucketStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *LinodeVPC) DeepCopyInto(out *LinodeVPC) { *out = *in diff --git a/cloud/scope/common.go b/cloud/scope/common.go index 6d223ac52..6d954555b 100644 --- a/cloud/scope/common.go +++ b/cloud/scope/common.go @@ -1,12 +1,16 @@ package scope import ( + "context" "fmt" "net/http" - "github.com/linode/cluster-api-provider-linode/version" "github.com/linode/linodego" "golang.org/x/oauth2" + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/linode/cluster-api-provider-linode/version" ) func createLinodeClient(apiKey string) *linodego.Client { @@ -23,3 +27,22 @@ func createLinodeClient(apiKey string) *linodego.Client { return &linodeClient } + +func getCredentialDataFromRef(ctx context.Context, crClient client.Client, credentialsRef *corev1.SecretReference) ([]byte, error) { + secretRefName := client.ObjectKey{ + Name: credentialsRef.Name, + Namespace: credentialsRef.Namespace, + } + + var credSecret corev1.Secret + if err := crClient.Get(ctx, secretRefName, &credSecret); err != nil { + return nil, fmt.Errorf("failed to retrieve configured credentials secret %s: %w", secretRefName.String(), err) + } + + rawData, ok := credSecret.Data["apiToken"] + if !ok { + return nil, fmt.Errorf("credentials secret %s is missing an apiToken key", secretRefName.String()) + } + + return rawData, nil +} diff --git a/cloud/scope/object_storage_bucket.go b/cloud/scope/object_storage_bucket.go new file mode 100644 index 000000000..f6ab1c2ce --- /dev/null +++ b/cloud/scope/object_storage_bucket.go @@ -0,0 +1,185 @@ +package scope + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "github.com/go-logr/logr" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/cluster-api/util/patch" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + + infrav1alpha1 "github.com/linode/cluster-api-provider-linode/api/v1alpha1" + "github.com/linode/linodego" +) + +type ObjectStorageBucketScopeParams struct { + Client client.Client + Object *infrav1alpha1.LinodeObjectStorageBucket + Logger *logr.Logger +} + +type ObjectStorageBucketScope struct { + client client.Client + Object *infrav1alpha1.LinodeObjectStorageBucket + Logger logr.Logger + LinodeClient *linodego.Client + BucketPatchHelper *patch.Helper +} + +const AccessKeyNameTemplate = "%s-access-keys" +const NumAccessKeys = 2 + +func validateObjectStorageBucketScopeParams(params ObjectStorageBucketScopeParams) error { + if params.Object == nil { + return errors.New("object storage bucket is required when creating an ObjectStorageBucketScope") + } + if params.Logger == nil { + return errors.New("logger is required when creating an ObjectStorageBucketScope") + } + + return nil +} + +func NewObjectStorageBucketScope(ctx context.Context, apiKey string, params ObjectStorageBucketScopeParams) (*ObjectStorageBucketScope, error) { + if err := validateObjectStorageBucketScopeParams(params); err != nil { + return nil, err + } + + // Override the controller credentials with ones from the Cluster's Secret reference (if supplied). + if params.Object.Spec.CredentialsRef != nil { + credRef := *params.Object.Spec.CredentialsRef + if credRef.Namespace == "" { + credRef.Namespace = params.Object.Namespace + } + data, err := getCredentialDataFromRef(ctx, params.Client, &credRef) + if err != nil { + return nil, fmt.Errorf("credentials from cluster secret ref: %w", err) + } + apiKey = string(data) + } + linodeClient := createLinodeClient(apiKey) + + bucketPatchHelper, err := patch.NewHelper(params.Object, params.Client) + if err != nil { + return nil, fmt.Errorf("failed to init patch helper: %w", err) + } + + return &ObjectStorageBucketScope{ + client: params.Client, + Object: params.Object, + Logger: *params.Logger, + LinodeClient: linodeClient, + BucketPatchHelper: bucketPatchHelper, + }, nil +} + +// PatchObject persists the object storage bucket configuration and status. +func (s *ObjectStorageBucketScope) PatchObject(ctx context.Context) error { + return s.BucketPatchHelper.Patch(ctx, s.Object) +} + +// Close closes the current scope persisting the object storage bucket configuration and status. +func (s *ObjectStorageBucketScope) Close(ctx context.Context) error { + return s.PatchObject(ctx) +} + +// AddFinalizer adds a finalizer if not present and immediately patches the +// object to avoid any race conditions. +func (s *ObjectStorageBucketScope) AddFinalizer(ctx context.Context) error { + if controllerutil.AddFinalizer(s.Object, infrav1alpha1.GroupVersion.String()) { + return s.Close(ctx) + } + + return nil +} + +// ApplyAccessKeySecret applies a Secret containing keys created for accessing the bucket. +func (s *ObjectStorageBucketScope) ApplyAccessKeySecret(ctx context.Context, keys [NumAccessKeys]linodego.ObjectStorageKey, secretName string) error { + var err error + + accessKeys := make([]json.RawMessage, NumAccessKeys) + for i, key := range keys { + accessKeys[i], err = json.Marshal(key) + if err != nil { + return fmt.Errorf("could not unmarshal access key %s: %w", key.Label, err) + } + } + + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: secretName, + Namespace: s.Object.Namespace, + }, + StringData: map[string]string{ + "read_write": string(accessKeys[0]), + "read_only": string(accessKeys[1]), + }, + } + + if err := controllerutil.SetOwnerReference(s.Object, secret, s.client.Scheme()); err != nil { + return fmt.Errorf("could not set owner ref on access key secret %s: %w", secretName, err) + } + + // Add finalizer to secret so it isn't deleted when bucket deletion is triggered + controllerutil.AddFinalizer(secret, infrav1alpha1.GroupVersion.String()) + + if s.Object.Status.KeySecretName == nil { + if err := s.client.Create(ctx, secret); err != nil { + return fmt.Errorf("could not create access key secret %s: %w", secretName, err) + } + + return nil + } + + if err := s.client.Update(ctx, secret); err != nil { + return fmt.Errorf("could not update access key secret %s: %w", secretName, err) + } + + return nil +} + +func (s *ObjectStorageBucketScope) GetAccessKeySecret(ctx context.Context) (*corev1.Secret, error) { + secretName := fmt.Sprintf(AccessKeyNameTemplate, s.Object.Name) + + objKey := client.ObjectKey{ + Namespace: s.Object.Namespace, + Name: secretName, + } + var secret corev1.Secret + if err := s.client.Get(ctx, objKey, &secret); err != nil { + return nil, err + } + + return &secret, nil +} + +// GetAccessKeysFromSecret gets the access key IDs for the OBJ buckets from a Secret. +func (s *ObjectStorageBucketScope) GetAccessKeysFromSecret(ctx context.Context, secret *corev1.Secret) ([NumAccessKeys]int, error) { + var keyIDs [NumAccessKeys]int + + permissions := [NumAccessKeys]string{"read_write", "read_only"} + for idx, permission := range permissions { + secretDataForKey, ok := secret.Data[permission] + if !ok { + return keyIDs, fmt.Errorf("secret %s missing data field %s", secret.Name, permission) + } + + var key linodego.ObjectStorageKey + if err := json.Unmarshal(secretDataForKey, &key); err != nil { + return keyIDs, fmt.Errorf("error unmarshaling key: %w", err) + } + + keyIDs[idx] = key.ID + } + + return keyIDs, nil +} + +func (s *ObjectStorageBucketScope) ShouldRotateKeys() bool { + return *s.Object.Spec.KeyGeneration != *s.Object.Status.LastKeyGeneration +} diff --git a/cloud/services/object_storage_buckets.go b/cloud/services/object_storage_buckets.go new file mode 100644 index 000000000..e9e6c5ebd --- /dev/null +++ b/cloud/services/object_storage_buckets.go @@ -0,0 +1,151 @@ +package services + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + + corev1 "k8s.io/api/core/v1" + utilerrors "k8s.io/apimachinery/pkg/util/errors" + + "github.com/linode/linodego" + + "github.com/linode/cluster-api-provider-linode/cloud/scope" +) + +func EnsureObjectStorageBucket(ctx context.Context, bScope *scope.ObjectStorageBucketScope) (*linodego.ObjectStorageBucket, error) { + filter := map[string]string{ + "label": bScope.Object.Name, + } + + rawFilter, err := json.Marshal(filter) + if err != nil { + return nil, fmt.Errorf("failed to list buckets in cluster %s: %w", bScope.Object.Spec.Cluster, err) + } + + var buckets []linodego.ObjectStorageBucket + if buckets, err = bScope.LinodeClient.ListObjectStorageBucketsInCluster( + ctx, + linodego.NewListOptions(1, string(rawFilter)), + bScope.Object.Spec.Cluster, + ); err != nil { + return nil, fmt.Errorf("failed to list buckets in cluster %s: %w", bScope.Object.Spec.Cluster, err) + } + if len(buckets) == 1 { + bScope.Logger.Info("Bucket exists") + + return &buckets[0], nil + } + + opts := linodego.ObjectStorageBucketCreateOptions{ + Cluster: bScope.Object.Spec.Cluster, + Label: bScope.Object.Name, + ACL: linodego.ACLPrivate, + } + + var bucket *linodego.ObjectStorageBucket + if bucket, err = bScope.LinodeClient.CreateObjectStorageBucket(ctx, opts); err != nil { + return nil, fmt.Errorf("failed to create bucket: %w", err) + } + + bScope.Logger.Info("Created bucket") + + return bucket, nil +} + +func RotateObjectStorageKeys(ctx context.Context, bScope *scope.ObjectStorageBucketScope) ([scope.NumAccessKeys]linodego.ObjectStorageKey, error) { + var newKeys [scope.NumAccessKeys]linodego.ObjectStorageKey + + for idx, permission := range []struct { + name string + suffix string + }{ + {"read_write", "rw"}, + {"read_only", "ro"}, + } { + keyLabel := fmt.Sprintf("%s-%s", bScope.Object.Name, permission.suffix) + key, err := createObjectStorageKey(ctx, bScope, keyLabel, permission.name) + if err != nil { + return newKeys, err + } + + newKeys[idx] = *key + } + + // If key revocation fails here, just log the errors since new keys have been created + if bScope.Object.Status.LastKeyGeneration != nil && bScope.ShouldRotateKeys() { + secret, err := bScope.GetAccessKeySecret(ctx) + if err != nil { + bScope.Logger.Error(err, "Failed to read secret with access keys to revoke; keys must be manually revoked") + } + + if err := RevokeObjectStorageKeys(ctx, bScope, secret); err != nil { + bScope.Logger.Error(err, "Failed to revoke access keys; keys must be manually revoked") + } + } + + return newKeys, nil +} + +func createObjectStorageKey(ctx context.Context, bScope *scope.ObjectStorageBucketScope, label, permission string) (*linodego.ObjectStorageKey, error) { + opts := linodego.ObjectStorageKeyCreateOptions{ + Label: label, + BucketAccess: &[]linodego.ObjectStorageKeyBucketAccess{ + { + BucketName: bScope.Object.Name, + Cluster: bScope.Object.Spec.Cluster, + Permissions: permission, + }, + }, + } + + key, err := bScope.LinodeClient.CreateObjectStorageKey(ctx, opts) + if err != nil { + bScope.Logger.Error(err, "Failed to create access key", "label", label) + + return nil, fmt.Errorf("failed to create access key: %w", err) + } + + bScope.Logger.Info("Created access key", "id", key.ID) + + return key, nil +} + +func RevokeObjectStorageKeys(ctx context.Context, bScope *scope.ObjectStorageBucketScope, secret *corev1.Secret) error { + if secret == nil { + return errors.New("unable to read access keys from nil secret") + } + + keyIDs, err := bScope.GetAccessKeysFromSecret(ctx, secret) + if err != nil { + bScope.Logger.Error(err, "Failed to read secret with access keys to revoke; must be manually revoked") + + return fmt.Errorf("failed to read secret %s with access keys: %w", secret.Name, err) + } + + var errs []error + for _, keyID := range keyIDs { + if err := revokeObjectStorageKey(ctx, bScope, keyID); err != nil { + errs = append(errs, err) + } + } + + return utilerrors.NewAggregate(errs) +} + +func revokeObjectStorageKey(ctx context.Context, bScope *scope.ObjectStorageBucketScope, keyID int) error { + if err := bScope.LinodeClient.DeleteObjectStorageKey(ctx, keyID); err != nil { + linodeErr := &linodego.Error{} + if errors.As(err, linodeErr) && linodeErr.StatusCode() != http.StatusNotFound { + bScope.Logger.Error(err, "Failed to revoke access key", "id", keyID) + + return fmt.Errorf("failed to revoke access key: %w", err) + } + } + + bScope.Logger.Info("Revoked access key", "id", keyID) + + return nil +} diff --git a/cmd/main.go b/cmd/main.go index 86e20a308..c007012c4 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -62,14 +62,16 @@ func main() { // Environment variables linodeToken string = os.Getenv("LINODE_TOKEN") - machineWatchFilter string - clusterWatchFilter string - metricsAddr string - enableLeaderElection bool - probeAddr string + machineWatchFilter string + clusterWatchFilter string + objectStorageBucketWatchFilter string + metricsAddr string + enableLeaderElection bool + probeAddr string ) flag.StringVar(&machineWatchFilter, "machine-watch-filter", "", "The machines to watch by label.") flag.StringVar(&clusterWatchFilter, "cluster-watch-filter", "", "The clusters to watch by label.") + flag.StringVar(&objectStorageBucketWatchFilter, "object-storage-bucket-watch-filter", "", "The object bucket storages to watch by label.") flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "The address the metric endpoint binds to.") flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.") flag.BoolVar(&enableLeaderElection, "leader-elect", false, @@ -140,6 +142,17 @@ func main() { setupLog.Error(err, "unable to create controller", "controller", "LinodeVPC") os.Exit(1) } + if err = (&controller2.LinodeObjectStorageBucketReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Logger: ctrl.Log.WithName("LinodeObjectStorageBucketReconciler"), + Recorder: mgr.GetEventRecorderFor("LinodeObjectStorageBucketReconciler"), + WatchFilterValue: objectStorageBucketWatchFilter, + LinodeApiKey: linodeToken, + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "LinodeObjectStorageBucket") + os.Exit(1) + } // +kubebuilder:scaffold:builder if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { diff --git a/config/crd/bases/infrastructure.cluster.x-k8s.io_linodeobjectstoragebuckets.yaml b/config/crd/bases/infrastructure.cluster.x-k8s.io_linodeobjectstoragebuckets.yaml new file mode 100644 index 000000000..78f24b48f --- /dev/null +++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_linodeobjectstoragebuckets.yaml @@ -0,0 +1,169 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.14.0 + name: linodeobjectstoragebuckets.infrastructure.cluster.x-k8s.io +spec: + group: infrastructure.cluster.x-k8s.io + names: + kind: LinodeObjectStorageBucket + listKind: LinodeObjectStorageBucketList + plural: linodeobjectstoragebuckets + shortNames: + - lobj + singular: linodeobjectstoragebucket + scope: Namespaced + versions: + - additionalPrinterColumns: + - description: The name of the bucket + jsonPath: .spec.label + name: Label + type: string + - description: The ID of the Object Storage cluster for the bucket + jsonPath: .spec.cluster + name: Cluster + type: string + - description: Bucket and keys have been provisioned + jsonPath: .status.ready + name: Ready + type: string + name: v1alpha1 + schema: + openAPIV3Schema: + description: LinodeObjectStorageBucket is the Schema for the linodeobjectstoragebuckets + API + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: LinodeObjectStorageBucketSpec defines the desired state of + LinodeObjectStorageBucket + properties: + cluster: + description: Cluster is the ID of the Object Storage cluster for the + bucket. + type: string + x-kubernetes-validations: + - message: Value is immutable + rule: self == oldSelf + credentialsRef: + description: |- + CredentialsRef is a reference to a Secret that contains the credentials to use for provisioning the bucket. + If not supplied then the credentials of the controller will be used. + properties: + name: + description: name is unique within a namespace to reference a + secret resource. + type: string + namespace: + description: namespace defines the space within which the secret + name must be unique. + type: string + type: object + x-kubernetes-map-type: atomic + keyGeneration: + default: 0 + description: KeyGeneration may be modified to trigger rotations of + access keys created for the bucket. + type: integer + required: + - cluster + type: object + status: + description: LinodeObjectStorageBucketStatus defines the observed state + of LinodeObjectStorageBucket + properties: + conditions: + description: Conditions specify the service state of the LinodeObjectStorageBucket. + items: + description: Condition defines an observation of a Cluster API resource + operational state. + properties: + lastTransitionTime: + description: |- + Last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when + the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + A human readable message indicating details about the transition. + This field may be empty. + type: string + reason: + description: |- + The reason for the condition's last transition in CamelCase. + The specific API may choose whether or not this field is considered a guaranteed API. + This field may not be empty. + type: string + severity: + description: |- + Severity provides an explicit classification of Reason code, so the users or machines can immediately + understand the current situation and act accordingly. + The Severity field MUST be set only when Status=False. + type: string + status: + description: Status of the condition, one of True, False, Unknown. + type: string + type: + description: |- + Type of condition in CamelCase or in foo.example.com/CamelCase. + Many .condition.type values are consistent across resources like Available, but because arbitrary conditions + can be useful (see .node.status.conditions), the ability to deconflict is important. + type: string + required: + - lastTransitionTime + - status + - type + type: object + type: array + creationTime: + description: CreationTime specifies the creation timestamp for the + bucket. + format: date-time + type: string + failureMessage: + description: |- + FailureMessage will be set in the event that there is a terminal problem + reconciling the Object Storage Bucket and will contain a verbose string + suitable for logging and human consumption. + type: string + hostname: + description: Hostname is the address assigned to the bucket. + type: string + keySecretName: + description: KeySecretName specifies the name of the Secret containing + access keys for the bucket. + type: string + lastKeyGeneration: + description: LastKeyGeneration tracks the last known value of .spec.keyGeneration. + type: integer + ready: + default: false + description: Ready denotes that the bucket has been provisioned along + with access keys. + type: boolean + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index e90807c7b..6ced66145 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -13,6 +13,7 @@ resources: - bases/infrastructure.cluster.x-k8s.io_linodemachinetemplates.yaml - bases/infrastructure.cluster.x-k8s.io_linodeclustertemplates.yaml - bases/infrastructure.cluster.x-k8s.io_linodevpcs.yaml +- bases/infrastructure.cluster.x-k8s.io_linodeobjectstoragebuckets.yaml #+kubebuilder:scaffold:crdkustomizeresource patches: @@ -22,6 +23,7 @@ patches: #- path: patches/webhook_in_linodemachines.yaml #- path: patches/webhook_in_linodemachinetemplates.yaml #- path: patches/webhook_in_linodeclustertemplates.yaml +#- path: patches/webhook_in_linodeobjectstoragebuckets.yaml #+kubebuilder:scaffold:crdkustomizewebhookpatch # [CERTMANAGER] To enable cert-manager, uncomment all the sections with [CERTMANAGER] prefix. @@ -30,6 +32,7 @@ patches: #- path: patches/cainjection_in_linodemachines.yaml #- path: patches/cainjection_in_linodemachinetemplates.yaml #- path: patches/cainjection_in_linodeclustertemplates.yaml +#- path: patches/cainjection_in_linodeobjectstoragebuckets.yaml #+kubebuilder:scaffold:crdkustomizecainjectionpatch # the following config is for teaching kustomize how to do kustomization for CRDs. diff --git a/config/rbac/linodeobjectstoragebucket_editor_role.yaml b/config/rbac/linodeobjectstoragebucket_editor_role.yaml new file mode 100644 index 000000000..e1d0ec425 --- /dev/null +++ b/config/rbac/linodeobjectstoragebucket_editor_role.yaml @@ -0,0 +1,31 @@ +# permissions for end users to edit linodeobjectstoragebuckets. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: clusterrole + app.kubernetes.io/instance: linodeobjectstoragebucket-editor-role + app.kubernetes.io/component: rbac + app.kubernetes.io/created-by: cluster-api-provider-linode + app.kubernetes.io/part-of: cluster-api-provider-linode + app.kubernetes.io/managed-by: kustomize + name: linodeobjectstoragebucket-editor-role +rules: +- apiGroups: + - infrastructure.cluster.x-k8s.io + resources: + - linodeobjectstoragebuckets + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - infrastructure.cluster.x-k8s.io + resources: + - linodeobjectstoragebuckets/status + verbs: + - get diff --git a/config/rbac/linodeobjectstoragebucket_viewer_role.yaml b/config/rbac/linodeobjectstoragebucket_viewer_role.yaml new file mode 100644 index 000000000..6060e5d8e --- /dev/null +++ b/config/rbac/linodeobjectstoragebucket_viewer_role.yaml @@ -0,0 +1,27 @@ +# permissions for end users to view linodeobjectstoragebuckets. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: clusterrole + app.kubernetes.io/instance: linodeobjectstoragebucket-viewer-role + app.kubernetes.io/component: rbac + app.kubernetes.io/created-by: cluster-api-provider-linode + app.kubernetes.io/part-of: cluster-api-provider-linode + app.kubernetes.io/managed-by: kustomize + name: linodeobjectstoragebucket-viewer-role +rules: +- apiGroups: + - infrastructure.cluster.x-k8s.io + resources: + - linodeobjectstoragebuckets + verbs: + - get + - list + - watch +- apiGroups: + - infrastructure.cluster.x-k8s.io + resources: + - linodeobjectstoragebuckets/status + verbs: + - get diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index c55c63395..ce62e56e6 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -20,8 +20,11 @@ rules: resources: - secrets verbs: + - create - get - list + - patch + - update - watch - apiGroups: - cluster.x-k8s.io @@ -91,6 +94,32 @@ rules: - get - patch - update +- apiGroups: + - infrastructure.cluster.x-k8s.io + resources: + - linodeobjectstoragebuckets + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - infrastructure.cluster.x-k8s.io + resources: + - linodeobjectstoragebuckets/finalizers + verbs: + - update +- apiGroups: + - infrastructure.cluster.x-k8s.io + resources: + - linodeobjectstoragebuckets/status + verbs: + - get + - patch + - update - apiGroups: - infrastructure.cluster.x-k8s.io resources: diff --git a/config/samples/infrastructure_v1alpha1_linodeobjectstoragebucket.yaml b/config/samples/infrastructure_v1alpha1_linodeobjectstoragebucket.yaml new file mode 100644 index 000000000..ca32b98b7 --- /dev/null +++ b/config/samples/infrastructure_v1alpha1_linodeobjectstoragebucket.yaml @@ -0,0 +1,24 @@ +--- +apiVersion: infrastructure.cluster.x-k8s.io/v1alpha1 +kind: LinodeObjectStorageBucket +metadata: + labels: + app.kubernetes.io/name: linodeobjectstoragebucket + app.kubernetes.io/instance: linodeobjectstoragebucket-sample + app.kubernetes.io/part-of: cluster-api-provider-linode + app.kubernetes.io/managed-by: kustomize + app.kubernetes.io/created-by: cluster-api-provider-linode + name: linodeobjectstoragebucket-sample +spec: + credentialsRef: + name: api-key-secret + namespace: default + keyGeneration: 0 + cluster: us-ord-1 +--- +apiVersion: v1 +kind: Secret +metadata: + name: api-key-secret +stringData: + apiToken: "changeme" diff --git a/config/samples/kustomization.yaml b/config/samples/kustomization.yaml index 69a2f69e0..cbc113d74 100644 --- a/config/samples/kustomization.yaml +++ b/config/samples/kustomization.yaml @@ -5,4 +5,5 @@ resources: - infrastructure_v1alpha1_linodemachinetemplate.yaml - infrastructure_v1alpha1_linodeclustertemplate.yaml - infrastructure_v1alpha1_linodevpc.yaml +- infrastructure_v1alpha1_linodeobjectstoragebucket.yaml #+kubebuilder:scaffold:manifestskustomizesamples diff --git a/controller/linodeobjectstoragebucket_controller.go b/controller/linodeobjectstoragebucket_controller.go new file mode 100644 index 000000000..046baa842 --- /dev/null +++ b/controller/linodeobjectstoragebucket_controller.go @@ -0,0 +1,235 @@ +/* +Copyright 2023 Akamai Technologies, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import ( + "context" + "fmt" + "time" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + utilerrors "k8s.io/apimachinery/pkg/util/errors" + + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/record" + clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" + "sigs.k8s.io/cluster-api/util/conditions" + "sigs.k8s.io/cluster-api/util/predicates" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/predicate" + + "github.com/go-logr/logr" + infrav1alpha1 "github.com/linode/cluster-api-provider-linode/api/v1alpha1" + "github.com/linode/cluster-api-provider-linode/cloud/scope" + "github.com/linode/cluster-api-provider-linode/cloud/services" + "github.com/linode/cluster-api-provider-linode/util" + "github.com/linode/cluster-api-provider-linode/util/reconciler" +) + +// LinodeObjectStorageBucketReconciler reconciles a LinodeObjectStorageBucket object +type LinodeObjectStorageBucketReconciler struct { + client.Client + Scheme *runtime.Scheme + Logger logr.Logger + Recorder record.EventRecorder + LinodeApiKey string + WatchFilterValue string + ReconcileTimeout time.Duration +} + +//+kubebuilder:rbac:groups=infrastructure.cluster.x-k8s.io,resources=linodeobjectstoragebuckets,verbs=get;list;watch;create;update;patch;delete +//+kubebuilder:rbac:groups=infrastructure.cluster.x-k8s.io,resources=linodeobjectstoragebuckets/status,verbs=get;update;patch +//+kubebuilder:rbac:groups=infrastructure.cluster.x-k8s.io,resources=linodeobjectstoragebuckets/finalizers,verbs=update + +// +kubebuilder:rbac:groups="",resources=events,verbs=get;list;watch;create;update;patch +// +kubebuilder:rbac:groups="",resources=secrets;,verbs=get;list;watch;create;update;patch + +// Reconcile is part of the main kubernetes reconciliation loop which aims to +// move the current state of the cluster closer to the desired state. +// TODO(user): Modify the Reconcile function to compare the state specified by +// the LinodeObjectStorageBucket object against the actual cluster state, and then +// perform operations to make the cluster state reflect the state specified by +// the user. +// +// For more details, check Reconcile and its Result here: +// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.17.0/pkg/reconcile +func (r *LinodeObjectStorageBucketReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + ctx, cancel := context.WithTimeout(ctx, reconciler.DefaultedLoopTimeout(r.ReconcileTimeout)) + defer cancel() + + logger := r.Logger.WithValues("name", req.NamespacedName.String()) + + objectStorageBucket := &infrav1alpha1.LinodeObjectStorageBucket{} + if err := r.Client.Get(ctx, req.NamespacedName, objectStorageBucket); err != nil { + if err = client.IgnoreNotFound(err); err != nil { + logger.Error(err, "Failed to fetch LinodeObjectStorageBucket", "name", req.NamespacedName.String()) + } + + return ctrl.Result{}, err + } + + bScope, err := scope.NewObjectStorageBucketScope( + ctx, + r.LinodeApiKey, + scope.ObjectStorageBucketScopeParams{ + Client: r.Client, + Object: objectStorageBucket, + Logger: &logger, + }, + ) + if err != nil { + logger.Error(err, "Failed to create object storage bucket scope") + + return ctrl.Result{}, fmt.Errorf("failed to create object storage bucket scope: %w", err) + } + + return r.reconcile(ctx, bScope) +} + +func (r *LinodeObjectStorageBucketReconciler) reconcile(ctx context.Context, bScope *scope.ObjectStorageBucketScope) (res ctrl.Result, reterr error) { + // Always close the scope when exiting this function so we can persist any LinodeObjectStorageBucket changes. + defer func() { + // Filter out any IsNotFound message since client.IgnoreNotFound does not handle aggregate errors + if err := bScope.Close(ctx); utilerrors.FilterOut(err, apierrors.IsNotFound) != nil && reterr == nil { + bScope.Logger.Error(err, "failed to patch LinodeObjectStorageBucket") + reterr = err + } + }() + + // Delete + if !bScope.Object.DeletionTimestamp.IsZero() { + return res, r.reconcileDelete(ctx, bScope) + } + + // Apply + if err := r.reconcileApply(ctx, bScope); err != nil { + return res, err + } + + return res, nil +} + +func (r *LinodeObjectStorageBucketReconciler) setFailure(bScope *scope.ObjectStorageBucketScope, err error) { + bScope.Object.Status.FailureMessage = util.Pointer(err.Error()) + r.Recorder.Event(bScope.Object, corev1.EventTypeWarning, "Failed", err.Error()) + conditions.MarkFalse(bScope.Object, clusterv1.ReadyCondition, "Failed", clusterv1.ConditionSeverityError, "%s", err.Error()) +} + +func (r *LinodeObjectStorageBucketReconciler) reconcileApply(ctx context.Context, bScope *scope.ObjectStorageBucketScope) error { + bScope.Logger.Info("Reconciling apply") + + bScope.Object.Status.Ready = false + + if err := bScope.AddFinalizer(ctx); err != nil { + return err + } + + bucket, err := services.EnsureObjectStorageBucket(ctx, bScope) + if err != nil { + bScope.Logger.Error(err, "Failed to ensure bucket exists") + r.setFailure(bScope, err) + + return err + } + bScope.Object.Status.Hostname = util.Pointer(bucket.Hostname) + bScope.Object.Status.CreationTime = &metav1.Time{Time: *bucket.Created} + + if bScope.Object.Status.LastKeyGeneration == nil || bScope.ShouldRotateKeys() { + keys, err := services.RotateObjectStorageKeys(ctx, bScope) + if err != nil { + bScope.Logger.Error(err, "Failed to provision new access keys") + r.setFailure(bScope, err) + + return err + } + + secretName := fmt.Sprintf(scope.AccessKeyNameTemplate, bScope.Object.Name) + if err := bScope.ApplyAccessKeySecret(ctx, keys, secretName); err != nil { + bScope.Logger.Error(err, "Failed to apply access key secret") + r.setFailure(bScope, err) + + return err + } + bScope.Object.Status.KeySecretName = util.Pointer(secretName) + bScope.Object.Status.LastKeyGeneration = bScope.Object.Spec.KeyGeneration + } + + r.Recorder.Event(bScope.Object, corev1.EventTypeNormal, "Ready", "Object storage bucket configuration applied") + + bScope.Object.Status.Ready = true + conditions.MarkTrue(bScope.Object, clusterv1.ReadyCondition) + + return nil +} + +func (r *LinodeObjectStorageBucketReconciler) reconcileDelete(ctx context.Context, bScope *scope.ObjectStorageBucketScope) error { + bScope.Logger.Info("Reconciling delete") + + secret, err := bScope.GetAccessKeySecret(ctx) + if err != nil { + bScope.Logger.Error(err, "Failed to read secret with access keys to revoke") + r.setFailure(bScope, err) + + return err + } + + if err := services.RevokeObjectStorageKeys(ctx, bScope, secret); err != nil { + bScope.Logger.Error(err, "failed to revoke access keys; keys must be manually revoked") + r.setFailure(bScope, err) + + return err + } + + // Only permit Secret and LinodeObjectStorageBucket deletion if keys were revoked + if !controllerutil.RemoveFinalizer(secret, infrav1alpha1.GroupVersion.String()) { + bScope.Logger.Error(err, "Failed to remove finalizer from secret; will not be deleted") + r.setFailure(bScope, err) + + return err + } + + if err := r.Client.Update(ctx, secret); err != nil { + bScope.Logger.Error(err, "Failed to remove finalizer from secret; will not be deleted") + r.setFailure(bScope, err) + + return err + } + + if !controllerutil.RemoveFinalizer(bScope.Object, infrav1alpha1.GroupVersion.String()) { + bScope.Logger.Error(err, "Failed to remove finalizer from bucket; will not be deleted") + r.setFailure(bScope, err) + + return err + } + + return nil +} + +// SetupWithManager sets up the controller with the Manager. +func (r *LinodeObjectStorageBucketReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&infrav1alpha1.LinodeObjectStorageBucket{}). + WithEventFilter(predicate.And( + predicates.ResourceHasFilterLabel(mgr.GetLogger(), r.WatchFilterValue), + predicate.GenerationChangedPredicate{}, + )). + Complete(r) +} diff --git a/controller/suite_test.go b/controller/suite_test.go index f199e4f4a..cd8951838 100644 --- a/controller/suite_test.go +++ b/controller/suite_test.go @@ -56,7 +56,7 @@ var _ = BeforeSuite(func() { By("bootstrapping test environment") testEnv = &envtest.Environment{ - CRDDirectoryPaths: []string{filepath.Join("..", "..", "config", "crd", "bases")}, + CRDDirectoryPaths: []string{filepath.Join("..", "config", "crd", "bases")}, ErrorIfCRDPathMissing: true, // The BinaryAssetsDirectory is only required if you want to run the tests directly diff --git a/e2e/Makefile b/e2e/Makefile index 994aeff35..001c2a366 100644 --- a/e2e/Makefile +++ b/e2e/Makefile @@ -19,3 +19,6 @@ callLinodeApiGet: callLinodeApiPost: @curl -s -H "Authorization: Bearer $$LINODE_TOKEN" -H "Content-Type: application/json" -d "$$BODY" -X POST "https://$(TARGET_API)/$(TARGET_API_VERSION)/$$URI" + +callLinodeApiDelete: + @curl -s -H "Authorization: Bearer $$LINODE_TOKEN" -X DELETE "https://$(TARGET_API)/$(TARGET_API_VERSION)/$$URI" diff --git a/e2e/kuttl-config.yaml b/e2e/kuttl-config.yaml index a9abaa602..8ecc60bf1 100644 --- a/e2e/kuttl-config.yaml +++ b/e2e/kuttl-config.yaml @@ -4,4 +4,5 @@ testDirs: - e2e/linodecluster-controller - e2e/linodemachine-controller - e2e/linodevpc-controller +- e2e/linodeobjectstoragebucket-controller timeout: 300 diff --git a/e2e/linodeobjectstoragebucket-controller/minimal-linodeobjectstoragebucket/00-assert.yaml b/e2e/linodeobjectstoragebucket-controller/minimal-linodeobjectstoragebucket/00-assert.yaml new file mode 100644 index 000000000..8a5b8dbab --- /dev/null +++ b/e2e/linodeobjectstoragebucket-controller/minimal-linodeobjectstoragebucket/00-assert.yaml @@ -0,0 +1,15 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: capi-controller-manager + namespace: capi-system +status: + availableReplicas: 1 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: capl-controller-manager + namespace: capl-system +status: + availableReplicas: 1 diff --git a/e2e/linodeobjectstoragebucket-controller/minimal-linodeobjectstoragebucket/00-ensure-no-bucket.yaml b/e2e/linodeobjectstoragebucket-controller/minimal-linodeobjectstoragebucket/00-ensure-no-bucket.yaml new file mode 100644 index 000000000..ae0bf51a7 --- /dev/null +++ b/e2e/linodeobjectstoragebucket-controller/minimal-linodeobjectstoragebucket/00-ensure-no-bucket.yaml @@ -0,0 +1,5 @@ +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +commands: + - script: |- + URI="object-storage/buckets/us-sea-1/linodeobjectstoragebucket-sample" make callLinodeApiDelete || exit 0 diff --git a/e2e/linodeobjectstoragebucket-controller/minimal-linodeobjectstoragebucket/01-assert.yaml b/e2e/linodeobjectstoragebucket-controller/minimal-linodeobjectstoragebucket/01-assert.yaml new file mode 100644 index 000000000..c7c0bf831 --- /dev/null +++ b/e2e/linodeobjectstoragebucket-controller/minimal-linodeobjectstoragebucket/01-assert.yaml @@ -0,0 +1,11 @@ +apiVersion: infrastructure.cluster.x-k8s.io/v1alpha1 +kind: LinodeObjectStorageBucket +metadata: + name: linodeobjectstoragebucket-sample +spec: + cluster: us-sea-1 + keyGeneration: 0 +status: + ready: true + keySecretName: linodeobjectstoragebucket-sample-access-keys + lastKeyGeneration: 0 diff --git a/e2e/linodeobjectstoragebucket-controller/minimal-linodeobjectstoragebucket/01-create-linodeobjectstoragebucket.yaml b/e2e/linodeobjectstoragebucket-controller/minimal-linodeobjectstoragebucket/01-create-linodeobjectstoragebucket.yaml new file mode 100644 index 000000000..c070bdf20 --- /dev/null +++ b/e2e/linodeobjectstoragebucket-controller/minimal-linodeobjectstoragebucket/01-create-linodeobjectstoragebucket.yaml @@ -0,0 +1,6 @@ +apiVersion: infrastructure.cluster.x-k8s.io/v1alpha1 +kind: LinodeObjectStorageBucket +metadata: + name: linodeobjectstoragebucket-sample +spec: + cluster: us-sea-1 diff --git a/e2e/linodeobjectstoragebucket-controller/minimal-linodeobjectstoragebucket/02-verify-bucket-and-secret.yaml b/e2e/linodeobjectstoragebucket-controller/minimal-linodeobjectstoragebucket/02-verify-bucket-and-secret.yaml new file mode 100644 index 000000000..d50521397 --- /dev/null +++ b/e2e/linodeobjectstoragebucket-controller/minimal-linodeobjectstoragebucket/02-verify-bucket-and-secret.yaml @@ -0,0 +1,9 @@ +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +commands: + - script: |- + URI="object-storage/buckets/us-sea-1" FILTER="" make callLinodeApiGet | grep linodeobjectstoragebucket-sample.us-sea-1.linodeobjects.com + - script: |- + secret=$(kubectl -n $NAMESPACE get secret linodeobjectstoragebucket-sample-access-keys -oyaml) + echo $secret | grep read_write + echo $secret | grep read_only diff --git a/e2e/linodeobjectstoragebucket-controller/minimal-linodeobjectstoragebucket/03-assert.yaml b/e2e/linodeobjectstoragebucket-controller/minimal-linodeobjectstoragebucket/03-assert.yaml new file mode 100644 index 000000000..f06e6bfb2 --- /dev/null +++ b/e2e/linodeobjectstoragebucket-controller/minimal-linodeobjectstoragebucket/03-assert.yaml @@ -0,0 +1,11 @@ +apiVersion: infrastructure.cluster.x-k8s.io/v1alpha1 +kind: LinodeObjectStorageBucket +metadata: + name: linodeobjectstoragebucket-sample +spec: + cluster: us-sea-1 + keyGeneration: 1 +status: + ready: true + keySecretName: linodeobjectstoragebucket-sample-access-keys + lastKeyGeneration: 1 diff --git a/e2e/linodeobjectstoragebucket-controller/minimal-linodeobjectstoragebucket/03-patch-bucket.yaml b/e2e/linodeobjectstoragebucket-controller/minimal-linodeobjectstoragebucket/03-patch-bucket.yaml new file mode 100644 index 000000000..aee98b733 --- /dev/null +++ b/e2e/linodeobjectstoragebucket-controller/minimal-linodeobjectstoragebucket/03-patch-bucket.yaml @@ -0,0 +1,7 @@ +apiVersion: infrastructure.cluster.x-k8s.io/v1alpha1 +kind: LinodeObjectStorageBucket +metadata: + name: linodeobjectstoragebucket-sample +spec: + cluster: us-sea-1 + keyGeneration: 1 diff --git a/e2e/linodeobjectstoragebucket-controller/minimal-linodeobjectstoragebucket/04-delete-linodeobjectstoragebucket.yaml b/e2e/linodeobjectstoragebucket-controller/minimal-linodeobjectstoragebucket/04-delete-linodeobjectstoragebucket.yaml new file mode 100644 index 000000000..f9d29aa42 --- /dev/null +++ b/e2e/linodeobjectstoragebucket-controller/minimal-linodeobjectstoragebucket/04-delete-linodeobjectstoragebucket.yaml @@ -0,0 +1,6 @@ +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +delete: +- apiVersion: infrastructure.cluster.x-k8s.io/v1alpha1 + kind: LinodeObjectStorageBucket + name: linodeobjectstoragebucket-sample diff --git a/e2e/linodeobjectstoragebucket-controller/minimal-linodeobjectstoragebucket/05-errors.yaml b/e2e/linodeobjectstoragebucket-controller/minimal-linodeobjectstoragebucket/05-errors.yaml new file mode 100644 index 000000000..95cec6dce --- /dev/null +++ b/e2e/linodeobjectstoragebucket-controller/minimal-linodeobjectstoragebucket/05-errors.yaml @@ -0,0 +1,9 @@ +apiVersion: infrastructure.cluster.x-k8s.io/v1alpha1 +kind: LinodeObjectStorageBucket +metadata: + name: linodeobjectstoragebucket-sample +--- +apiVersion: v1 +kind: Secret +metadata: + name: linodeobjectstoragebucket-sample-access-keys diff --git a/e2e/linodeobjectstoragebucket-controller/minimal-linodeobjectstoragebucket/Makefile b/e2e/linodeobjectstoragebucket-controller/minimal-linodeobjectstoragebucket/Makefile new file mode 100644 index 000000000..3924bfdc1 --- /dev/null +++ b/e2e/linodeobjectstoragebucket-controller/minimal-linodeobjectstoragebucket/Makefile @@ -0,0 +1 @@ +include ../../Makefile