From 0ad0ad4a91783e4a61c83014702bbd031c908e1f Mon Sep 17 00:00:00 2001 From: Vincent Marguerie <24724195+vincentmrg@users.noreply.github.com> Date: Fri, 8 Nov 2024 18:26:29 +0100 Subject: [PATCH] WIP --- Makefile | 2 +- go.mod | 14 + go.sum | 28 + internal/controller/externalip_controller.go | 308 ++----- .../controller/firewallrule_controller.go | 616 +++----------- internal/controller/utils.go | 14 - .../provider/aws/converter/address_decoder.go | 22 - .../aws/converter/address_decoder_test.go | 60 -- .../aws/converter/instance_decoder.go | 54 -- .../aws/converter/instance_decoder_test.go | 91 -- .../aws/converter/security_group_decoder.go | 71 +- .../converter/security_group_decoder_test.go | 133 --- .../aws/converter/security_group_encoder.go | 36 +- .../converter/security_group_encoder_test.go | 16 +- internal/provider/aws/imds.go | 109 +++ internal/provider/aws/provider.go | 795 ++++++++++++------ internal/provider/model.go | 158 ++-- internal/provider/provider.go | 26 +- internal/provider/utils.go | 2 +- internal/utils/utils.go | 17 + 20 files changed, 987 insertions(+), 1585 deletions(-) delete mode 100644 internal/provider/aws/converter/address_decoder.go delete mode 100644 internal/provider/aws/converter/address_decoder_test.go delete mode 100644 internal/provider/aws/converter/instance_decoder.go delete mode 100644 internal/provider/aws/converter/instance_decoder_test.go delete mode 100644 internal/provider/aws/converter/security_group_decoder_test.go create mode 100644 internal/provider/aws/imds.go create mode 100644 internal/utils/utils.go diff --git a/Makefile b/Makefile index 94f9188..54a1749 100644 --- a/Makefile +++ b/Makefile @@ -123,7 +123,7 @@ docker-push: ## Push docker image with the manager. # - have enabled BuildKit. More info: https://docs.docker.com/develop/develop-images/build_enhancements/ # - be able to push the image to your registry (i.e. if you do not set a valid value via IMG=> then the export will fail) # To adequately provide solutions that are compatible with multiple platforms, you should consider using this option. -PLATFORMS ?= linux/arm64,linux/amd64,linux/s390x,linux/ppc64le +PLATFORMS ?= linux/arm64,linux/amd64 .PHONY: docker-buildx docker-buildx: ## Build and push docker image for the manager for cross-platform support # copy existing Dockerfile and insert --platform=${BUILDPLATFORM} into Dockerfile.cross, and preserve the original Dockerfile diff --git a/go.mod b/go.mod index 8c1db1a..7d25d98 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,20 @@ require ( require ( github.com/antlr4-go/antlr/v4 v4.13.0 // indirect github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a // indirect + github.com/aws/aws-sdk-go-v2 v1.32.3 // indirect + github.com/aws/aws-sdk-go-v2/config v1.28.1 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.17.42 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.18 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.22 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.22 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 // indirect + github.com/aws/aws-sdk-go-v2/service/ec2 v1.186.1 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.0 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.3 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.24.3 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.3 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.32.3 // indirect + github.com/aws/smithy-go v1.22.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver/v4 v4.0.0 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect diff --git a/go.sum b/go.sum index 4c30ec5..b108257 100644 --- a/go.sum +++ b/go.sum @@ -6,6 +6,34 @@ github.com/aws/aws-sdk-go v1.44.211 h1:YNr5DwdzG/8y9Tl0QrPTnC99aFUHgT5hhy6GpnnzH github.com/aws/aws-sdk-go v1.44.211/go.mod h1:aVsgQcEevwlmQ7qHE9I3h+dtQgpqhFB+i8Phjh7fkwI= github.com/aws/aws-sdk-go v1.55.5 h1:KKUZBfBoyqy5d3swXyiC7Q76ic40rYcbqH7qjh59kzU= github.com/aws/aws-sdk-go v1.55.5/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= +github.com/aws/aws-sdk-go-v2 v1.32.3 h1:T0dRlFBKcdaUPGNtkBSwHZxrtis8CQU17UpNBZYd0wk= +github.com/aws/aws-sdk-go-v2 v1.32.3/go.mod h1:2SK5n0a2karNTv5tbP1SjsX0uhttou00v/HpXKM1ZUo= +github.com/aws/aws-sdk-go-v2/config v1.28.1 h1:oxIvOUXy8x0U3fR//0eq+RdCKimWI900+SV+10xsCBw= +github.com/aws/aws-sdk-go-v2/config v1.28.1/go.mod h1:bRQcttQJiARbd5JZxw6wG0yIK3eLeSCPdg6uqmmlIiI= +github.com/aws/aws-sdk-go-v2/credentials v1.17.42 h1:sBP0RPjBU4neGpIYyx8mkU2QqLPl5u9cmdTWVzIpHkM= +github.com/aws/aws-sdk-go-v2/credentials v1.17.42/go.mod h1:FwZBfU530dJ26rv9saAbxa9Ej3eF/AK0OAY86k13n4M= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.18 h1:68jFVtt3NulEzojFesM/WVarlFpCaXLKaBxDpzkQ9OQ= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.18/go.mod h1:Fjnn5jQVIo6VyedMc0/EhPpfNlPl7dHV916O6B+49aE= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.22 h1:Jw50LwEkVjuVzE1NzkhNKkBf9cRN7MtE1F/b2cOKTUM= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.22/go.mod h1:Y/SmAyPcOTmpeVaWSzSKiILfXTVJwrGmYZhcRbhWuEY= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.22 h1:981MHwBaRZM7+9QSR6XamDzF/o7ouUGxFzr+nVSIhrs= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.22/go.mod h1:1RA1+aBEfn+CAB/Mh0MB6LsdCYCnjZm7tKXtnk499ZQ= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 h1:VaRN3TlFdd6KxX1x3ILT5ynH6HvKgqdiXoTxAF4HQcQ= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc= +github.com/aws/aws-sdk-go-v2/service/ec2 v1.186.1 h1:s3en74URaTjlhpJqOUCHlmombBFo88jxZqs3qjRmXrI= +github.com/aws/aws-sdk-go-v2/service/ec2 v1.186.1/go.mod h1:ossaD9Z1ugYb6sq9QIqQLEOorCGcqUoxlhud9M9yE70= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.0 h1:TToQNkvGguu209puTojY/ozlqy2d/SFNcoLIqTFi42g= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.0/go.mod h1:0jp+ltwkf+SwG2fm/PKo8t4y8pJSgOCO4D8Lz3k0aHQ= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.3 h1:qcxX0JYlgWH3hpPUnd6U0ikcl6LLA9sLkXE2w1fpMvY= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.3/go.mod h1:cLSNEmI45soc+Ef8K/L+8sEA3A3pYFEYf5B5UI+6bH4= +github.com/aws/aws-sdk-go-v2/service/sso v1.24.3 h1:UTpsIf0loCIWEbrqdLb+0RxnTXfWh2vhw4nQmFi4nPc= +github.com/aws/aws-sdk-go-v2/service/sso v1.24.3/go.mod h1:FZ9j3PFHHAR+w0BSEjK955w5YD2UwB/l/H0yAK3MJvI= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.3 h1:2YCmIXv3tmiItw0LlYf6v7gEHebLY45kBEnPezbUKyU= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.3/go.mod h1:u19stRyNPxGhj6dRm+Cdgu6N75qnbW7+QN0q0dsAk58= +github.com/aws/aws-sdk-go-v2/service/sts v1.32.3 h1:wVnQ6tigGsRqSWDEEyH6lSAJ9OyFUsSnbaUWChuSGzs= +github.com/aws/aws-sdk-go-v2/service/sts v1.32.3/go.mod h1:VZa9yTFyj4o10YGsmDO4gbQJUvvhY72fhumT8W4LqsE= +github.com/aws/smithy-go v1.22.0 h1:uunKnWlcoL3zO7q+gG2Pk53joueEOsnNB28QdMsmiMM= +github.com/aws/smithy-go v1.22.0/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= diff --git a/internal/controller/externalip_controller.go b/internal/controller/externalip_controller.go index df8c8d8..d07eed6 100644 --- a/internal/controller/externalip_controller.go +++ b/internal/controller/externalip_controller.go @@ -20,7 +20,7 @@ import ( "context" "encoding/json" "fmt" - "time" + "reflect" "github.com/go-logr/logr" "github.com/google/uuid" @@ -83,285 +83,105 @@ func (r *ExternalIPReconciler) Reconcile(ctx context.Context, req ctrl.Request) return ctrl.Result{}, nil } - // Lifecycle reconciliation - if externalIP.ObjectMeta.DeletionTimestamp.IsZero() { - return r.reconcileExternalIP(ctx, log, externalIP) + // TODO: Check what to do with the case where the nodeName is nil. + if externalIP.Spec.NodeName == "" { + return ctrl.Result{}, nil } - // Deletion reconciliation - return r.reconcileExternalIPDeletion(ctx, log, externalIP) -} - -func (r *ExternalIPReconciler) reconcileExternalIP(ctx context.Context, log logr.Logger, externalIP *v1alpha1.ExternalIP) (ctrl.Result, error) { - // 1st STEP - // // Add finalizer if !controllerutil.ContainsFinalizer(externalIP, externalIPFinalizer) { - controllerutil.AddFinalizer(externalIP, externalIPFinalizer) + externalIP.ObjectMeta.Finalizers = append(externalIP.ObjectMeta.Finalizers, externalIPFinalizer) log.V(1).Info("Updating ExternalIP", "finalizer", externalIPFinalizer) return ctrl.Result{}, r.Update(ctx, externalIP) } - // 2nd STEP - // - // Reserve external IP address - if externalIP.Status.State == v1alpha1.ExternalIPStateNone { - // Create external address - res, err := r.Provider.CreateAddress(ctx) - if err != nil { - log.Error(err, "Failed to create address") + // Get node from ExternalIP spec + var node corev1.Node + var instanceID string + if externalIP.Spec.NodeName != "" { + if err := r.Get(ctx, types.NamespacedName{Name: externalIP.Spec.NodeName}, &node); err != nil { + if errors.IsNotFound(err) { + // Invalid nodeName, remove ExternalIP nodeName attribute. + log.Info("Node not found. Removing it from ExternalIP spec", "nodeName", externalIP.Spec.NodeName) + externalIP.Spec.NodeName = "" + return ctrl.Result{}, r.Update(ctx, externalIP) + } + // Error reading the object - requeue the request. + log.Error(err, "Failed to get Node") return ctrl.Result{}, err } - log.Info("Created address", "id", res.AddressID, "publicIP", res.PublicIP) - - // Update status - externalIP.Status.State = v1alpha1.ExternalIPStateReserved - externalIP.Status.AddressID = &res.AddressID - externalIP.Status.PublicIPAddress = &res.PublicIP - log.V(1).Info( - "Updating ExternalIP", - "state", externalIP.Status.State, - "addressID", externalIP.Status.AddressID, - "PublicIPAddress", externalIP.Status.PublicIPAddress, - ) - return ctrl.Result{RequeueAfter: time.Second * 5}, r.Status().Update(ctx, externalIP) + // Retrieve node instance + instanceID = r.Provider.GetInstanceID(node) } - // 3rd STEP - // - // Finally associate external ip to instance network interface. - // This must be the last step, since this exposes the instance on the outside. - if externalIP.IsReserved() { - if externalIP.Spec.NodeName != "" { - // Get node from ExternalIP spec - var node corev1.Node - if err := r.Get(ctx, types.NamespacedName{Name: externalIP.Spec.NodeName}, &node); err != nil { - if errors.IsNotFound(err) { - // Invalid nodeName, remove ExternalIP nodeName attribute. - log.Info("Node not found. Removing it from ExternalIP spec", "nodeName", externalIP.Spec.NodeName) - externalIP.Spec.NodeName = "" - return ctrl.Result{}, r.Update(ctx, externalIP) - } - // Error reading the object - requeue the request. - log.Error(err, "Failed to get Node") + var status v1alpha1.ExternalIPStatus + var err error + if externalIP.ObjectMeta.DeletionTimestamp.IsZero() { + status, err = r.Provider.ReconcileExternalIP(ctx, instanceID, externalIP) + if err != nil { + log.Error(err, "Failed to reconcile ExternalIP") + return ctrl.Result{}, err + } + // Node not being deleted, reconcile externalip label + if externalIP.Spec.NodeName != "" && node.ObjectMeta.DeletionTimestamp.IsZero() { + // Marshal node, ... + old, err := json.Marshal(node) + if err != nil { + log.Error(err, "Failed to marshal node") return ctrl.Result{}, err } - // Retrieve node instance - instanceID := r.Provider.GetInstanceID(node) - res, err := r.Provider.GetInstance(ctx, instanceID) + // ... then compute new node to marshal it... + node.Labels[externalIPLabel] = *status.PublicIPAddress + new, err := json.Marshal(node) if err != nil { - log.Error(err, "Failed to get instance", "id", instanceID) + log.Error(err, "Failed to marshal new node") return ctrl.Result{}, err } - // Interface with index 0 is the first attached to node, the one we're interested in. - // Each instance has a default network interface, called the primary network interface. - // You cannot detach a primary network interface from an instance. - var networkInterface *provider.NetworkInterface - for _, elem := range res.NetworkInterfaces { - if elem != nil && *elem.DeviceID == 0 { - networkInterface = elem - break - } - } - if networkInterface == nil { - err := fmt.Errorf("no network interface with public IP found for instance %s", instanceID) - log.Error(err, "Cannot associate an address with this instance", "instanceID", instanceID) + // ... and create a patch. + patch, err := strategicpatch.CreateTwoWayMergePatch(old, new, node) + if err != nil { + log.Error(err, "Failed to create patch for node") return ctrl.Result{}, err } - // Finally, associate address to instance network interface, then update status. - if err := r.Provider.AssociateAddress(ctx, provider.AssociateAddressRequest{ - AddressID: *externalIP.Status.AddressID, - NetworkInterfaceID: networkInterface.NetworkInterfaceID, - }); err != nil { - log.Error( - err, - "Failed to associate address", - "addressID", *externalIP.Status.AddressID, - "instanceID", instanceID, - "networkInterfaceID", networkInterface.NetworkInterfaceID, - ) + // Apply patch to set node's wanted labels. + if err = r.Client.Patch(ctx, &node, client.RawPatch(types.MergePatchType, patch)); err != nil { + log.Error(err, "Failed to patch node") return ctrl.Result{}, err } - log.Info( - "Associated address", - "addressID", *externalIP.Status.AddressID, - "instanceID", instanceID, - "networkInterfaceID", networkInterface.NetworkInterfaceID, - ) - - // Update status - externalIP.Status.State = v1alpha1.ExternalIPStateAssociated - externalIP.Status.InstanceID = &instanceID - log.V(1).Info("Updating ExternalIP", "state", externalIP.Status.State, "InstanceID", externalIP.Status.InstanceID) - return ctrl.Result{RequeueAfter: time.Second * 5}, r.Status().Update(ctx, externalIP) } - - // No spec.nodeName, no association, end reconciliation for ExternalIP. - log.V(1).Info("No No spec.nodeName, no association, end reconciliation for ExternalIP.") - return ctrl.Result{}, nil - } - - // ExternalIP reliability check - // - // Check if the associated node still exists and disassociate it if it does not. - // No nodeName or no living node, set state back to "Reserved" - if externalIP.IsAssociated() { - if externalIP.Spec.NodeName != "" { - // Get node from ExternalIP spec - var node corev1.Node - if err := r.Get(ctx, types.NamespacedName{Name: externalIP.Spec.NodeName}, &node); err != nil { - if errors.IsNotFound(err) { - // Invalid nodeName, remove ExternalIP nodeName attribute. - log.Info("Node not found. Removing it from ExternalIP spec", "nodeName", externalIP.Spec.NodeName) - - // Set status back to Reserved - externalIP.Status.State = v1alpha1.ExternalIPStateReserved - log.V(1).Info("Updating ExternalIP", "state", externalIP.Status.State, "InstanceID", externalIP.Status.InstanceID) - if err = r.Status().Update(ctx, externalIP); err != nil { - log.Error(err, "Failed to update ExternalIP status", "externalIP", externalIP.Name, "status", externalIP.Status.State) - return ctrl.Result{}, err - } - - externalIP.Spec.NodeName = "" - return ctrl.Result{}, r.Update(ctx, externalIP) - } - // Error reading the object - requeue the request. - log.Error(err, "Failed to get Node") - return ctrl.Result{}, err - } - - // Node not being deleted, reconcile externalip label - if node.ObjectMeta.DeletionTimestamp.IsZero() { - // Marshal node, ... - old, err := json.Marshal(node) - if err != nil { - log.Error(err, "Failed to marshal node") - return ctrl.Result{}, err - } - - // ... then compute new node to marshal it... - node.Labels[externalIPLabel] = *externalIP.Status.PublicIPAddress - new, err := json.Marshal(node) - if err != nil { - log.Error(err, "Failed to marshal new node") - return ctrl.Result{}, err - } - - // ... and create a patch. - patch, err := strategicpatch.CreateTwoWayMergePatch(old, new, node) - if err != nil { - log.Error(err, "Failed to create patch for node") - return ctrl.Result{}, err - } - - // Apply patch to set node's wanted labels. - if err = r.Client.Patch(ctx, &node, client.RawPatch(types.MergePatchType, patch)); err != nil { - log.Error(err, "Failed to patch node") - return ctrl.Result{}, err - } - - return ctrl.Result{}, nil - } - } - - // Set state back to "Reserved", disassociate address and end reconciliation - return r.disassociateAddress(ctx, r.Provider, log, externalIP) - } - - return ctrl.Result{}, nil -} - -func (r *ExternalIPReconciler) reconcileExternalIPDeletion( - ctx context.Context, - log logr.Logger, - externalIP *v1alpha1.ExternalIP, -) (ctrl.Result, error) { - // 1st STEP - // - // Reconciliation of a possible external IP associated with the instance. - // If an IP is associated with the instance, disassociate it. - if externalIP.IsAssociated() { - return r.disassociateAddress(ctx, r.Provider, log, externalIP) - } - - // 2nd STEP - // - // Release unassociated address. - if externalIP.IsReserved() { - // Do not delete EIP if flag PreventEIPDeallocation is set - if externalIP.Status.AddressID != nil && !externalIP.Spec.PreventEIPDeallocation { - if err := r.Provider.DeleteAddress(ctx, *externalIP.Status.AddressID); err != nil { - if !provider.IsErrNotFound(err) { - log.Error(err, "Failed to delete Address", "addressID", *externalIP.Status.AddressID) - return ctrl.Result{}, err - } - log.V(1).Info("Address not found", "addressID", *externalIP.Status.AddressID) - } - log.Info("Deleted Address", "addressID", *externalIP.Status.AddressID) - - // Update status - externalIP.Status.AddressID = nil + } else { + if err := r.Provider.ReconcileExternalIPDeletion(ctx, externalIP); err != nil { + log.Error(err, "Failed to reconcile ExternalIP deletion") + return ctrl.Result{}, err } - // set State to None for finalizer to delete externalIP object - externalIP.Status.State = v1alpha1.ExternalIPStateNone - log.V(1).Info("Updating ExternalIP", "state", externalIP.Status.State) - return ctrl.Result{RequeueAfter: time.Second * 5}, r.Status().Update(ctx, externalIP) } - // 3rd STEP - // - // Remove finalizer to release ExternalIP - if externalIP.Status.State == v1alpha1.ExternalIPStateNone { - if controllerutil.ContainsFinalizer(externalIP, externalIPFinalizer) { - controllerutil.RemoveFinalizer(externalIP, externalIPFinalizer) - return ctrl.Result{}, r.Update(ctx, externalIP) + if !externalIP.DeletionTimestamp.IsZero() && controllerutil.ContainsFinalizer(externalIP, externalIPFinalizer) { + controllerutil.RemoveFinalizer(externalIP, externalIPFinalizer) + if err := r.Update(ctx, externalIP); err != nil { + log.Error(err, "Failed to remove finalizer") + return ctrl.Result{}, err } + return ctrl.Result{}, nil } - return ctrl.Result{}, nil -} + // Copy the existing ExternalIP to avoid mutating the original + existingExternalIP := externalIP.DeepCopy() -// disassociateAddress performs address disassociation tasks -func (r *ExternalIPReconciler) disassociateAddress( - ctx context.Context, - pvd provider.Provider, - log logr.Logger, - externalIP *v1alpha1.ExternalIP, -) (ctrl.Result, error) { - // Get address and disassociate it - if externalIP.Status.AddressID != nil { - res, err := pvd.GetAddress(ctx, *externalIP.Status.AddressID) + // Patch the Pool status if it differs from the desired status + externalIP.Status = status + if !reflect.DeepEqual(externalIP.Status, existingExternalIP.Status) { + err := r.Status().Patch(ctx, externalIP, client.MergeFrom(existingExternalIP)) if err != nil { - log.Error(err, "Failed to retrieve address", "addressID", *externalIP.Status.AddressID) - return ctrl.Result{}, err - } - - if res.AssociationID != nil { - if err := pvd.DisassociateAddress(ctx, provider.DisassociateAddressRequest{ - AssociationID: *res.AssociationID, - }); err != nil { - log.Error(err, "Failed to disassociate address", "addressID", *externalIP.Status.AddressID, "instanceID", *externalIP.Status.InstanceID) - return ctrl.Result{}, err - } - log.Info("Disassociated address", "addressID", *externalIP.Status.AddressID, "instanceID", *externalIP.Status.InstanceID) + log.Error(err, "Failed to patch ExternalIP status") + return ctrl.Result{}, fmt.Errorf("failed to patch ExternalIP status: %w", err) } } - // Update status - externalIP.Status.State = v1alpha1.ExternalIPStateReserved - externalIP.Status.InstanceID = nil - log.V(1).Info("Updating ExternalIP", "state", externalIP.Status.State) - if err := r.Status().Update(ctx, externalIP); err != nil { - log.Error(err, "Failed to update ExternalIP state", "externalIP", externalIP.Name) - return ctrl.Result{}, err - } - - log.V(1).Info("Removing ExternalIP NodeName", "externalIP", externalIP.Name) - externalIP.Spec.NodeName = "" - return ctrl.Result{}, r.Update(ctx, externalIP) + return ctrl.Result{}, nil } // SetupWithManager sets up the controller with the Manager. diff --git a/internal/controller/firewallrule_controller.go b/internal/controller/firewallrule_controller.go index bf6c545..0be045e 100644 --- a/internal/controller/firewallrule_controller.go +++ b/internal/controller/firewallrule_controller.go @@ -18,10 +18,9 @@ package controller import ( "context" - "encoding/json" "fmt" "reflect" - "time" + "slices" "github.com/go-logr/logr" "github.com/google/uuid" @@ -40,6 +39,8 @@ import ( ) const ( + annNodeName = "kubestatic.quortex.io/node-name" + // firewallRuleFinalizer is a finalizer for FirewallRule firewallRuleFinalizer = "firewallrule.finalizers.kubestatic.quortex.io" firewallRuleNodeNameKey = ".spec.nodeName" @@ -48,10 +49,9 @@ const ( // FirewallRuleReconciler reconciles a FirewallRule object type FirewallRuleReconciler struct { client.Client - Log logr.Logger - Scheme *runtime.Scheme - Provider provider.Provider - frLastTransitionTime map[string]metav1.Time + Log logr.Logger + Scheme *runtime.Scheme + Provider provider.Provider } //+kubebuilder:rbac:groups=kubestatic.quortex.io,resources=firewallrules,verbs=get;list;watch;create;update;patch;delete @@ -84,444 +84,170 @@ func (r *FirewallRuleReconciler) Reconcile(ctx context.Context, req ctrl.Request return ctrl.Result{}, nil } - // LastTransitionTime is not set. This should happen when kubestatic is - // upgraded from a version that does not support this field, we set it with - // the current time. - if firewallRule.Status.LastTransitionTime.IsZero() { - firewallRule.Status.LastTransitionTime = metav1.Now() - if err := r.Status().Update(ctx, firewallRule); err != nil { - log.Error(err, "Failed to update FirewallRule state", "firewallRule", firewallRule.Name) - return ctrl.Result{}, err - } - r.frLastTransitionTime[firewallRule.Name] = firewallRule.Status.LastTransitionTime - } - - // Check for LastTransitionTime consistency, if not, requeueing. - knownLastTransitionTime := r.frLastTransitionTime[firewallRule.Name] - if firewallRule.Status.LastTransitionTime.Before(&knownLastTransitionTime) { - log.V(1).Info("FirewallRule LastTransitionTime inconsistency, requeuing in 1 second") - return ctrl.Result{RequeueAfter: time.Second}, nil - } - r.frLastTransitionTime[firewallRule.Name] = firewallRule.Status.LastTransitionTime - - // Lifecycle reconciliation - if firewallRule.ObjectMeta.DeletionTimestamp.IsZero() { - return r.reconcileFirewallRule(ctx, log, firewallRule) + if firewallRule.Spec.NodeName == nil { + return ctrl.Result{}, nil } - // Deletion reconciliation - return r.reconcileFirewallRuleDeletion(ctx, log, firewallRule) -} - -//nolint:gocyclo -func (r *FirewallRuleReconciler) reconcileFirewallRule( - ctx context.Context, - log logr.Logger, - rule *v1alpha1.FirewallRule, -) (ctrl.Result, error) { - // 1st STEP - // // Add finalizer - if !controllerutil.ContainsFinalizer(rule, firewallRuleFinalizer) { - rule.ObjectMeta.Finalizers = append(rule.ObjectMeta.Finalizers, firewallRuleFinalizer) + if !controllerutil.ContainsFinalizer(firewallRule, firewallRuleFinalizer) { + firewallRule.ObjectMeta.Finalizers = append(firewallRule.ObjectMeta.Finalizers, firewallRuleFinalizer) log.V(1).Info("Updating FirewallRule", "finalizer", firewallRuleFinalizer) - return ctrl.Result{}, r.Update(ctx, rule) + return ctrl.Result{}, r.Update(ctx, firewallRule) } - // 2nd STEP - // - // Reserve firewall - if rule.Status.State == v1alpha1.FirewallRuleStateNone && rule.Spec.NodeName != nil { - // Create firewall rule - // In the case of standalone firewall rules, we create it, - // otherwise, we update the group dedicated to the node. - var id string - var err error - if r.Provider.HasGroupedFirewallRules() { - // List FirewallRules with identical nodeName - frs := &v1alpha1.FirewallRuleList{} - if err := r.List(ctx, frs, client.MatchingFields{firewallRuleNodeNameKey: *rule.Spec.NodeName}); err != nil { - log.Error(err, "Unable to list FirewallRules") - return ctrl.Result{}, err - } - - // Check for other rules associated to the node. - // If there is already one, we update the group of rules, if not, we create a new group. - rulesAssociated := []v1alpha1.FirewallRule{} - for _, fr := range frs.Items { - knownLastTransitionTime := r.frLastTransitionTime[fr.Name] - if !fr.Status.LastTransitionTime.Equal(&knownLastTransitionTime) { - log.V(1).Info("FirewallRule LastTransitionTime inconsistency, requeuing in 1 second", "firewallRuleName", fr.Name) - return ctrl.Result{RequeueAfter: time.Second}, nil - } - if fr.Name != rule.Name && fr.Status.State != v1alpha1.FirewallRuleStateNone { - rulesAssociated = append(rulesAssociated, fr) - } - } + previousNodeName := firewallRule.Annotations[annNodeName] + currentNodeName := ptr.Deref(firewallRule.Spec.NodeName, "") - if len(rulesAssociated) > 0 { - firewallRuleID := ptr.Deref(rulesAssociated[0].Status.FirewallRuleID, "") - log.V(1).Info("Updating FirewallRule group", "firewallRuleID", firewallRuleID) - id, err = r.Provider.UpdateFirewallRuleGroup(ctx, encodeUpdateFirewallRuleGroupRequest(firewallRuleID, frs.Items)) - if err != nil { - log.Error(err, "Unable to update FirewallRules") - return ctrl.Result{}, err - } - } else { - // No existing group, we create a new one. - log.V(1).Info("Creating FirewallRule group") - id, err = r.Provider.CreateFirewallRuleGroup( - ctx, - encodeCreateFirewallRuleGroupRequest( - fmt.Sprintf("kubestatic-%s", randomString(10)), - fmt.Sprintf("Kubestatic managed group for node %s", *rule.Spec.NodeName), - frs.Items, - ), - ) - } - } else { - // Standalone rules, we simply create a rule. - log.V(1).Info("Creating FirewallRule") - id, err = r.Provider.CreateFirewallRule(ctx, encodeCreateFirewallRuleRequest(rule)) - } - - if err != nil { - log.Error(err, "Failed to create firewall rule") + // Initial creation or previous node name has been reconciled, + // reconcile FirewallRules for the current node and update the + // FirewallRule with the node annotation + if previousNodeName == "" && currentNodeName != "" { + if err := r.reconcileNodeFirewallRules(ctx, log, currentNodeName); err != nil { + log.Error(err, "Failed to reconcile node FirewallRules") return ctrl.Result{}, err } - log.Info("Created firewall rule", "id", id) - - // Update status - rule.Status.LastTransitionTime = metav1.Now() - rule.Status.State = v1alpha1.FirewallRuleStateReserved - rule.Status.FirewallRuleID = &id - lastApplied, err := json.Marshal(rule.Spec) - if err != nil { - return ctrl.Result{}, fmt.Errorf("Failed to marshal last applied firewallrule: %w", err) - } - rule.Status.LastApplied = nil - if len(lastApplied) > 0 { - rule.Status.LastApplied = ptr.To(string(lastApplied)) - } - log.V(1).Info("Updating FirewallRule", "state", rule.Status.State, "firewallRuleID", rule.Status.FirewallRuleID) - if err = r.Status().Update(ctx, rule); err != nil { - log.Error(err, "Failed to update FirewallRule status", "firewallRule", rule.Name, "status", rule.Status.State) - return ctrl.Result{}, err - } - r.frLastTransitionTime[rule.Name] = rule.Status.LastTransitionTime - return ctrl.Result{RequeueAfter: time.Second * 5}, nil - - } else if rule.Spec.NodeName != nil { - lastApplied := &v1alpha1.FirewallRuleSpec{} - if err := json.Unmarshal([]byte(ptr.Deref(rule.Status.LastApplied, "")), lastApplied); err != nil { - return ctrl.Result{}, fmt.Errorf("Failed to unmarshal last applied firewallrule: %w", err) - } - - // Update firewall rule. - // In the case of standalone firewall rules, we update it, - // otherwise, we update the group dedicated to the node. - if !reflect.DeepEqual(rule.Spec, *lastApplied) { - // Update firewall rule - firewallRuleID := ptr.Deref(rule.Status.FirewallRuleID, "") - var err error - if r.Provider.HasGroupedFirewallRules() { - // List FirewallRules with identical nodeName - frs := &v1alpha1.FirewallRuleList{} - if err := r.List(ctx, frs, client.MatchingFields{firewallRuleNodeNameKey: *rule.Spec.NodeName}); err != nil { - log.Error(err, "Unable to list FirewallRules") - return ctrl.Result{}, err - } - rules := []v1alpha1.FirewallRule{} - for _, fr := range frs.Items { - knownLastTransitionTime := r.frLastTransitionTime[fr.Name] - if !fr.Status.LastTransitionTime.Equal(&knownLastTransitionTime) { - log.V(1).Info("FirewallRule LastTransitionTime inconsistency, requeuing in 1 second", "firewallRuleName", fr.Name) - return ctrl.Result{RequeueAfter: time.Second}, nil - } - rules = append(rules, fr) - } - log.V(1).Info("Updating FirewallRule group", "firewallRuleID", firewallRuleID) - _, err = r.Provider.UpdateFirewallRuleGroup(ctx, encodeUpdateFirewallRuleGroupRequest(firewallRuleID, rules)) - } else { - log.V(1).Info("Updating FirewallRule", "firewallRuleID", firewallRuleID) - _, err = r.Provider.UpdateFirewallRule(ctx, encodeUpdateFirewallRuleRequest(firewallRuleID, rule)) - } - if err != nil { - log.Error(err, "Failed to update firewall rule", "id", firewallRuleID) - return ctrl.Result{}, err - } - log.Info("Updated firewall rule", "id", firewallRuleID) - - // Update status - lastApplied, err := json.Marshal(rule.Spec) - if err != nil { - return ctrl.Result{}, fmt.Errorf("Failed to marshal last applied firewallrule: %w", err) - } - rule.Status.LastTransitionTime = metav1.Now() - rule.Status.LastApplied = nil - if len(lastApplied) > 0 { - rule.Status.LastApplied = ptr.To(string(lastApplied)) - } - log.V(1).Info("Updating FirewallRule", "state", rule.Status.State, "firewallRuleID", rule.Status.FirewallRuleID) - if err = r.Status().Update(ctx, rule); err != nil { - log.Error(err, "Failed to update FirewallRule status", "firewallRule", rule.Name, "status", rule.Status.State) - return ctrl.Result{}, err - } - r.frLastTransitionTime[rule.Name] = rule.Status.LastTransitionTime - return ctrl.Result{RequeueAfter: time.Second * 5}, nil + existingFR := client.MergeFrom(firewallRule.DeepCopy()) + if firewallRule.Annotations == nil { + firewallRule.Annotations = make(map[string]string, 1) } + firewallRule.Annotations[annNodeName] = currentNodeName + return ctrl.Result{}, r.Client.Patch(ctx, firewallRule, existingFR) } - // 3rd STEP - // - // Finally associate firewall rule to instance network interface. - if rule.IsReserved() && rule.Spec.NodeName != nil { - // Get node from FirewallRule spec - var node corev1.Node - if err := r.Get(ctx, types.NamespacedName{Name: *rule.Spec.NodeName}, &node); err != nil { - if errors.IsNotFound(err) { - // Invalid nodeName, remove FirewallRule nodeName attribute. - log.Info("Node not found. Removing it from FirewallRule spec", "nodeName", rule.Spec.NodeName) - rule.Spec.NodeName = nil - return ctrl.Result{}, r.Update(ctx, rule) - } - // Error reading the object - requeue the request. - log.Error(err, "Failed to get Node") - return ctrl.Result{}, err - } - - // Retrieve node instance - instanceID := r.Provider.GetInstanceID(node) - res, err := r.Provider.GetInstance(ctx, instanceID) - if err != nil { - log.Error(err, "Failed to get instance", "id", instanceID) + // Node name has changed, reconcile FirewallRules for the previous node, + // then update the FirewallRule to remove the node annotation + if previousNodeName != "" && currentNodeName != previousNodeName { + if err := r.reconcileNodeFirewallRules(ctx, log, previousNodeName); err != nil { + log.Error(err, "Failed to reconcile node FirewallRules") return ctrl.Result{}, err } - // Get the first network interface with a public IP address - // This is needed because we could have multiple network interfaces, - // for example on EKS we have the public one, as well as one or more created by the EKS CNI. - var networkInterface *provider.NetworkInterface - for _, elem := range res.NetworkInterfaces { - if elem != nil && elem.PublicIP != nil { - networkInterface = elem - break - } - } - if networkInterface == nil { - err := fmt.Errorf("no network interface with public IP found for instance %s", instanceID) - log.Error(err, "Cannot associate a firewall rule with this instance", "instanceID", instanceID) - return ctrl.Result{}, err - } + existingFR := client.MergeFrom(firewallRule.DeepCopy()) + delete(firewallRule.Annotations, annNodeName) + return ctrl.Result{}, r.Client.Patch(ctx, firewallRule, existingFR) + } - // Finally, associate firewall rule to instance network interface, then update status. - if err := r.Provider.AssociateFirewallRule(ctx, provider.AssociateFirewallRuleRequest{ - FirewallRuleID: *rule.Status.FirewallRuleID, - NetworkInterfaceID: networkInterface.NetworkInterfaceID, - }); err != nil { - log.Error( - err, - "Failed to associate firewall rule", - "firewallRuleID", *rule.Status.FirewallRuleID, - "instanceID", instanceID, - "networkInterfaceID", networkInterface.NetworkInterfaceID, - ) + // Node name has not changed, reconcile FirewallRules for the current node + if previousNodeName != "" && currentNodeName == previousNodeName { + if err := r.reconcileNodeFirewallRules(ctx, log, currentNodeName); err != nil { + log.Error(err, "Failed to reconcile node FirewallRules") return ctrl.Result{}, err } - log.Info( - "Associated firewall rule", - "firewallRuleID", *rule.Status.FirewallRuleID, - "instanceID", instanceID, - "networkInterfaceID", networkInterface.NetworkInterfaceID, - ) - - // Update status - rule.Status.LastTransitionTime = metav1.Now() - rule.Status.State = v1alpha1.FirewallRuleStateAssociated - rule.Status.InstanceID = &instanceID - rule.Status.NetworkInterfaceID = &networkInterface.NetworkInterfaceID - log.V(1).Info( - "Updating FirewallRule", - "state", rule.Status.State, - "instanceID", rule.Status.InstanceID, - "networkInterfaceID", rule.Status.NetworkInterfaceID, - ) - if err = r.Status().Update(ctx, rule); err != nil { - log.Error(err, "Failed to update FirewallRule status", "firewallRule", rule.Name, "status", rule.Status.State) - return ctrl.Result{}, err - } - r.frLastTransitionTime[rule.Name] = rule.Status.LastTransitionTime - return ctrl.Result{RequeueAfter: time.Second * 5}, nil } - // FirewallRule reliability check - // - // Check if the associated node still exists and disassociate it if it does not. - // No nodeName or no living node, set state back to "Reserved" - if rule.Status.State != v1alpha1.FirewallRuleStateNone { - if rule.Spec.NodeName != nil { - // Get node from FirewallRule spec - var node corev1.Node - if err := r.Get(ctx, types.NamespacedName{Name: *rule.Spec.NodeName}, &node); err != nil { - if errors.IsNotFound(err) { - // Invalid nodeName, remove FirewallRule nodeName attribute. - log.Info("Node not found. Set state back to Reserved", "nodeName", rule.Spec.NodeName) - - // Set status back to Reserved - rule.Status.LastTransitionTime = metav1.Now() - rule.Status.State = v1alpha1.FirewallRuleStateReserved - log.V(1).Info("Updating FirewallRule", "state", rule.Status.State, "InstanceID", rule.Status.InstanceID) - if err = r.Status().Update(ctx, rule); err != nil { - log.Error(err, "Failed to update FirewallRule status", "firewallRule", rule.Name, "status", rule.Status.State) - return ctrl.Result{}, err - } - r.frLastTransitionTime[rule.Name] = rule.Status.LastTransitionTime - - rule.Spec.NodeName = nil - return ctrl.Result{}, r.Update(ctx, rule) - } - // Error reading the object - requeue the request. - log.Error(err, "Failed to get Node") - return ctrl.Result{}, err - } - - // If the node is not being deleted and has an instance corresponding to its node name, the reconciliation is done - // This check exist to disassociate the rule of the old instance if the node name change - if node.ObjectMeta.DeletionTimestamp.IsZero() && ptr.Deref(rule.Status.InstanceID, "") == r.Provider.GetInstanceID(node) { - return ctrl.Result{}, nil - } + // Remove finalizer + if !firewallRule.DeletionTimestamp.IsZero() && controllerutil.ContainsFinalizer(firewallRule, firewallRuleFinalizer) { + controllerutil.RemoveFinalizer(firewallRule, firewallRuleFinalizer) + if err := r.Update(ctx, firewallRule); err != nil { + log.Error(err, "Failed to remove finalizer") + return ctrl.Result{}, err } - - // If the rule has no node name, has an node name not matching its instance ID, or its node is being deleted - // clear firewall rule from provider and set state back to "None" - return r.clearFirewallRule(ctx, log, rule) } return ctrl.Result{}, nil } -func (r *FirewallRuleReconciler) reconcileFirewallRuleDeletion( +// reconcileNodeFirewallRules reconciles the firewall rules for a specific node. +// It lists all FirewallRule resources associated with the given node and filters out those that are marked for deletion. +// If no active firewall rules are found, it triggers the deletion of firewall rules on the provider side. +// Otherwise, it reconciles the existing firewall rules. +// +// Parameters: +// - ctx: The context for the reconciliation process. +// - log: The logger used for logging messages. +// - nodeName: The name of the node for which firewall rules are being reconciled. +// +// Returns: +// - error: An error if the reconciliation process fails, otherwise nil. +func (r *FirewallRuleReconciler) reconcileNodeFirewallRules( ctx context.Context, log logr.Logger, - rule *v1alpha1.FirewallRule, -) (ctrl.Result, error) { - // 1st STEP - // - // Reconciliation of a possible firewall rule associated with the instance. - // If a rule is associated with an instance or reserved, clear it. - if rule.Status.State != v1alpha1.FirewallRuleStateNone { - return r.clearFirewallRule(ctx, log, rule) + nodeName string, +) error { + var firewallRuleList v1alpha1.FirewallRuleList + if err := r.List(ctx, &firewallRuleList, client.MatchingFields{firewallRuleNodeNameKey: nodeName}); err != nil { + log.Error(err, "Unable to list FirewallRules") + return err } - // 2nd STEP - // - // Remove finalizer to release FirewallRule - if controllerutil.ContainsFinalizer(rule, firewallRuleFinalizer) { - controllerutil.RemoveFinalizer(rule, firewallRuleFinalizer) - return ctrl.Result{}, r.Update(ctx, rule) + firewallRuleList.Items = slices.DeleteFunc(firewallRuleList.Items, func(fr v1alpha1.FirewallRule) bool { + return !fr.DeletionTimestamp.IsZero() + }) + + if len(firewallRuleList.Items) == 0 { + if err := r.Provider.ReconcileFirewallRulesDeletion(ctx, nodeName); err != nil { + log.Error(err, "Failed to reconcile FirewallRule deletion") + return err + } + } else { + if err := r.reconcileFirewallRules(ctx, log, nodeName, firewallRuleList.Items); err != nil { + log.Error(err, "Failed to reconcile FirewallRules") + return err + } } - return ctrl.Result{}, nil + return nil } -// clearFirewallRule remove the rule from the provider rule -// In the case of grouped rules, the provider rule is updated and deleted if needed -// In the case of standalone rules the provider rule is deleted -func (r *FirewallRuleReconciler) clearFirewallRule(ctx context.Context, log logr.Logger, rule *v1alpha1.FirewallRule) (ctrl.Result, error) { - log = log.WithValues("ruleName", rule.Name) - - if rule.Status.FirewallRuleID != nil { - firewallRuleID := ptr.Deref(rule.Status.FirewallRuleID, "") - - toDelete := false - if r.Provider.HasGroupedFirewallRules() { - // List FirewallRules - frs := &v1alpha1.FirewallRuleList{} - if err := r.List(ctx, frs); err != nil { - log.Error(err, "Unable to list FirewallRules") - return ctrl.Result{}, err - } - - // Check for other rules associated to the node. - // If there is other ones, we only update the group of rules, if not, we also disassociate the group. - rules := []v1alpha1.FirewallRule{} - for _, fr := range frs.Items { - knownLastTransitionTime := r.frLastTransitionTime[fr.Name] - if !fr.Status.LastTransitionTime.Equal(&knownLastTransitionTime) { - log.V(1).Info("FirewallRule LastTransitionTime inconsistency, requeuing in 1 second", "firewallRuleName", fr.Name) - return ctrl.Result{RequeueAfter: time.Second}, nil - } - if fr.Name != rule.Name && ptr.Deref(fr.Status.FirewallRuleID, "") == ptr.Deref(rule.Status.FirewallRuleID, "") { - rules = append(rules, fr) - } - } - if len(rules) > 0 { - log.V(1).Info("Updating FirewallRule", "firewallRuleID", firewallRuleID) - if _, err := r.Provider.UpdateFirewallRuleGroup(ctx, encodeUpdateFirewallRuleGroupRequest(firewallRuleID, rules)); err != nil { - log.Error(err, "Unable to update FirewallRules") - return ctrl.Result{}, err - } - } else { - toDelete = true +// reconcileFirewallRules reconciles the firewall rules for a specific node. +// It retrieves the node information and updates the firewall rules accordingly. +// If the node is not found, it triggers the deletion of firewall rules on the provider side. +// Otherwise, it reconciles the existing firewall rules and updates their status. +// +// Parameters: +// - ctx: The context for the reconciliation process. +// - log: The logger used for logging messages. +// - nodeName: The name of the node for which firewall rules are being reconciled. +// - firewallRules: A list of FirewallRule resources to be reconciled. +// +// Returns: +// - error: An error if the reconciliation process fails, otherwise nil. +func (r *FirewallRuleReconciler) reconcileFirewallRules( + ctx context.Context, + log logr.Logger, + nodeName string, + firewallRules []v1alpha1.FirewallRule, +) error { + var node corev1.Node + if err := r.Get(ctx, types.NamespacedName{Name: nodeName}, &node); err != nil { + if errors.IsNotFound(err) { + if err := r.Provider.ReconcileFirewallRulesDeletion(ctx, nodeName); err != nil { + log.Error(err, "Failed to reconcile FirewallRule deletion") + return err } - } else { - toDelete = true + return err } - // Perform firewallrule deletion if needed - if toDelete { - if rule.Status.NetworkInterfaceID != nil { - log.V(1).Info("Disassociating firewall rule on provider", "firewallRuleID", firewallRuleID) - err := r.Provider.DisassociateFirewallRule(ctx, provider.AssociateFirewallRuleRequest{ - FirewallRuleID: *rule.Status.FirewallRuleID, - NetworkInterfaceID: *rule.Status.NetworkInterfaceID, - }) - if err != nil { - if !provider.IsErrNotFound(err) { - log.Error( - err, - "Failed to disassociate firewall rule", - "firewallRuleID", *rule.Status.FirewallRuleID, - "networkInterfaceID", *rule.Status.NetworkInterfaceID, - ) - return ctrl.Result{}, err - } - log.V(1).Info("Firewall rule already disassociated", "firewallRuleID", *rule.Status.FirewallRuleID) - } else { - log.Info("Disassociated firewall rule", "firewallRuleID", *rule.Status.FirewallRuleID, "networkInterfaceID", *rule.Status.NetworkInterfaceID) - } - } + log.Error(err, "Failed to get Node") + return err + } + + status, err := r.Provider.ReconcileFirewallRules(ctx, nodeName, r.Provider.GetInstanceID(node), firewallRules) + if err != nil { + log.Error(err, "Failed to reconcile FirewallRule") + return err + } - log.V(1).Info("Deleting firewall rule on provider", "firewallRuleID", firewallRuleID) - err := r.Provider.DeleteFirewallRule(ctx, *rule.Status.FirewallRuleID) + for _, fr := range firewallRules { + existingFR := fr.DeepCopy() + fr.Status = status + fr.Status.LastTransitionTime = existingFR.Status.LastTransitionTime + if !reflect.DeepEqual(fr.Status, existingFR.Status) { + fr.Status.LastTransitionTime = metav1.Now() + err := r.Status().Patch(ctx, &fr, client.MergeFrom(existingFR)) if err != nil { - if !provider.IsErrNotFound(err) { - log.Error(err, "Failed to delete firewall rule", "firewallRuleID", *rule.Status.FirewallRuleID) - return ctrl.Result{}, err - } - log.V(1).Info("Firewall rule already deleted", "firewallRuleID", *rule.Status.FirewallRuleID) - } else { - log.Info("Deleted firewall rule", "firewallRuleID", *rule.Status.FirewallRuleID) + log.Error(err, "Failed to patch FirewallRule status") + return fmt.Errorf("failed to patch FirewallRule status: %w", err) } } } - // Update status - rule.Status = v1alpha1.FirewallRuleStatus{State: v1alpha1.FirewallRuleStateNone, LastTransitionTime: metav1.Now()} - log.V(1).Info("Updating FirewallRule", "state", rule.Status.State) - if err := r.Status().Update(ctx, rule); err != nil { - log.Error(err, "Failed to update FirewallRule state", "firewallRule", rule.Name) - return ctrl.Result{}, err - } - r.frLastTransitionTime[rule.Name] = rule.Status.LastTransitionTime - - return ctrl.Result{RequeueAfter: time.Second * 5}, nil + return nil } // SetupWithManager sets up the controller with the Manager. func (r *FirewallRuleReconciler) SetupWithManager(mgr ctrl.Manager) error { - // Index FirewallRule NodeName to list FirewallRules by node. if err := mgr.GetFieldIndexer().IndexField(context.Background(), &v1alpha1.FirewallRule{}, firewallRuleNodeNameKey, func(o client.Object) []string { fr := o.(*v1alpha1.FirewallRule) return []string{ptr.Deref(fr.Spec.NodeName, "")} @@ -529,105 +255,7 @@ func (r *FirewallRuleReconciler) SetupWithManager(mgr ctrl.Manager) error { return err } - r.frLastTransitionTime = make(map[string]metav1.Time) - return ctrl.NewControllerManagedBy(mgr). For(&v1alpha1.FirewallRule{}). Complete(r) } - -// encodeCreateFirewallRuleGroupRequest converts an api FirewallRule slice to a CreateFirewallRuleGroupRequest slice. -func encodeCreateFirewallRuleGroupRequest(name, description string, data []v1alpha1.FirewallRule) provider.CreateFirewallRuleGroupRequest { - return provider.CreateFirewallRuleGroupRequest{ - Name: name, - Description: description, - FirewallRules: encodeFirewallRuleSpecs(data), - } -} - -// encodeCreateFirewallRuleRequest converts an api FirewallRule to a CreateFirewallRuleRequest. -func encodeCreateFirewallRuleRequest(data *v1alpha1.FirewallRule) provider.CreateFirewallRuleRequest { - return provider.CreateFirewallRuleRequest{ - FirewallRuleSpec: encodeFirewallRuleSpec(data), - } -} - -// encodeUpdateFirewallRuleGroupRequest converts an api FirewallRule slice to a UpdateFirewallRuleGroupRequest slice. -func encodeUpdateFirewallRuleGroupRequest(id string, data []v1alpha1.FirewallRule) provider.UpdateFirewallRuleGroupRequest { - return provider.UpdateFirewallRuleGroupRequest{ - FirewallRuleGroupID: id, - FirewallRules: encodeFirewallRuleSpecs(data), - } -} - -// encodeUpdateFirewallRuleRequest converts an api FirewallRule to a UpdateFirewallRuleRequest. -func encodeUpdateFirewallRuleRequest(id string, data *v1alpha1.FirewallRule) provider.UpdateFirewallRuleRequest { - return provider.UpdateFirewallRuleRequest{ - FirewallRuleID: id, - FirewallRuleSpec: encodeFirewallRuleSpec(data), - } -} - -// encodeFirewallRuleSpecs converts an api FirewallRule slice to a FirewallRuleSpec slice. -func encodeFirewallRuleSpecs(data []v1alpha1.FirewallRule) []provider.FirewallRuleSpec { - if data == nil { - return make([]provider.FirewallRuleSpec, 0) - } - - res := make([]provider.FirewallRuleSpec, len(data)) - for i, e := range data { - res[i] = encodeFirewallRuleSpec(&e) - } - return res -} - -// encodeFirewallRuleSpec converts an api FirewallRule to a FirewallRuleSpec. -func encodeFirewallRuleSpec(data *v1alpha1.FirewallRule) provider.FirewallRuleSpec { - return provider.FirewallRuleSpec{ - Name: data.Name, - Description: data.Spec.Description, - Direction: encodeDirection(data.Spec.Direction), - IPPermission: &provider.IPPermission{ - FromPort: data.Spec.FromPort, - Protocol: data.Spec.Protocol, - IPRanges: encodeIPRanges(data.Spec.IPRanges), - ToPort: data.Spec.ToPort, - }, - } -} - -// encodeIPRange converts an api IPRange to an IPRange. -func encodeIPRange(data *v1alpha1.IPRange) *provider.IPRange { - if data == nil { - return nil - } - - return &provider.IPRange{ - CIDR: data.CIDR, - Description: data.Description, - } -} - -// encodeIPRange converts an api IPRange slice to an IPRange slice. -func encodeIPRanges(data []*v1alpha1.IPRange) []*provider.IPRange { - if data == nil { - return make([]*provider.IPRange, 0) - } - - res := make([]*provider.IPRange, len(data)) - for i, e := range data { - res[i] = encodeIPRange(e) - } - return res -} - -// encodeDirection converts an api Direction to a Direction. -func encodeDirection(data v1alpha1.Direction) provider.Direction { - switch data { - case v1alpha1.DirectionEgress: - return provider.DirectionEgress - case v1alpha1.DirectionIngress: - return provider.DirectionIngress - } - return provider.Direction("") -} diff --git a/internal/controller/utils.go b/internal/controller/utils.go index fd0f660..1c79e30 100644 --- a/internal/controller/utils.go +++ b/internal/controller/utils.go @@ -17,9 +17,6 @@ limitations under the License. package controller import ( - "math/rand" - "time" - corev1 "k8s.io/api/core/v1" "github.com/quortex/kubestatic/api/v1alpha1" @@ -61,14 +58,3 @@ func getMostReferencedIP(pods []corev1.Pod, eips []v1alpha1.ExternalIP) (ip *v1a } return } - -const charset = "abcdefghijklmnopqrstuvwxyz" - -func randomString(length int) string { - seededRand := rand.New(rand.NewSource(time.Now().UnixNano())) - b := make([]byte, length) - for i := range b { - b[i] = charset[seededRand.Intn(len(charset))] - } - return string(b) -} diff --git a/internal/provider/aws/converter/address_decoder.go b/internal/provider/aws/converter/address_decoder.go deleted file mode 100644 index 7855ef3..0000000 --- a/internal/provider/aws/converter/address_decoder.go +++ /dev/null @@ -1,22 +0,0 @@ -// Package converter provides conversion methods for AWS models. -package converter - -import ( - "github.com/aws/aws-sdk-go/aws" - "github.com/aws/aws-sdk-go/service/ec2" - - "github.com/quortex/kubestatic/internal/provider" -) - -// DecodeAddress converts an ec2 Address to an Address. -func DecodeAddress(data *ec2.Address) *provider.Address { - if data == nil { - return nil - } - - return &provider.Address{ - AddressID: aws.StringValue(data.AllocationId), - AssociationID: data.AssociationId, - PublicIP: aws.StringValue(data.PublicIp), - } -} diff --git a/internal/provider/aws/converter/address_decoder_test.go b/internal/provider/aws/converter/address_decoder_test.go deleted file mode 100644 index af0b265..0000000 --- a/internal/provider/aws/converter/address_decoder_test.go +++ /dev/null @@ -1,60 +0,0 @@ -// Package converter provides conversion methods for AWS models. -package converter - -import ( - "reflect" - "testing" - - "github.com/aws/aws-sdk-go/aws" - "github.com/aws/aws-sdk-go/service/ec2" - - "github.com/quortex/kubestatic/internal/provider" -) - -func TestDecodeAddress(t *testing.T) { - type args struct { - data *ec2.Address - } - tests := []struct { - name string - args args - want *provider.Address - }{ - { - name: "nil ec2 address should return nil", - args: args{ - data: nil, - }, - want: nil, - }, - { - name: "empty ec2 address should return empty address", - args: args{ - data: &ec2.Address{}, - }, - want: &provider.Address{}, - }, - { - name: "complete ec2 address should be decoded properly to address", - args: args{ - data: &ec2.Address{ - AllocationId: aws.String("AllocationId"), - AssociationId: aws.String("AssociationId"), - PublicIp: aws.String("PublicIp"), - }, - }, - want: &provider.Address{ - AddressID: "AllocationId", - AssociationID: aws.String("AssociationId"), - PublicIP: "PublicIp", - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := DecodeAddress(tt.args.data); !reflect.DeepEqual(got, tt.want) { - t.Errorf("DecodeAddress() = %v, want %v", got, tt.want) - } - }) - } -} diff --git a/internal/provider/aws/converter/instance_decoder.go b/internal/provider/aws/converter/instance_decoder.go deleted file mode 100644 index 20d5d4c..0000000 --- a/internal/provider/aws/converter/instance_decoder.go +++ /dev/null @@ -1,54 +0,0 @@ -// Package converter provides conversion methods for AWS models. -package converter - -import ( - "github.com/aws/aws-sdk-go/aws" - "github.com/aws/aws-sdk-go/service/ec2" - - "github.com/quortex/kubestatic/internal/provider" -) - -// DecodeInstance converts an ec2 Instance to an Instance. -func DecodeInstance(data *ec2.Instance) *provider.Instance { - if data == nil { - return nil - } - - return &provider.Instance{ - InstanceID: aws.StringValue(data.InstanceId), - NetworkInterfaces: DecodeNetworkInterfaces(data.NetworkInterfaces), - VpcID: aws.StringValue(data.VpcId), - } -} - -// DecodeNetworkInterface converts an ec2 InstanceNetworkInterface to a NetworkInterface. -func DecodeNetworkInterface(data *ec2.InstanceNetworkInterface) *provider.NetworkInterface { - if data == nil { - return nil - } - - var publicIP *string - if ass := data.Association; ass != nil { - publicIP = ass.PublicIp - } - - return &provider.NetworkInterface{ - NetworkInterfaceID: aws.StringValue(data.NetworkInterfaceId), - PublicIP: publicIP, - DeviceID: data.Attachment.DeviceIndex, - } -} - -// DecodeNetworkInterfaces converts an ec2 InstanceNetworkInterface slice to a NetworkInterface slice. -func DecodeNetworkInterfaces(data []*ec2.InstanceNetworkInterface) []*provider.NetworkInterface { - if data == nil { - return nil - } - - res := make([]*provider.NetworkInterface, len(data)) - for i, e := range data { - res[i] = DecodeNetworkInterface(e) - } - - return res -} diff --git a/internal/provider/aws/converter/instance_decoder_test.go b/internal/provider/aws/converter/instance_decoder_test.go deleted file mode 100644 index 852bce4..0000000 --- a/internal/provider/aws/converter/instance_decoder_test.go +++ /dev/null @@ -1,91 +0,0 @@ -// Package converter provides conversion methods for AWS models. -package converter - -import ( - "reflect" - "testing" - - "github.com/aws/aws-sdk-go/aws" - "github.com/aws/aws-sdk-go/service/ec2" - "k8s.io/utils/ptr" - - "github.com/quortex/kubestatic/internal/provider" -) - -func TestDecodeInstance(t *testing.T) { - type args struct { - data *ec2.Instance - } - tests := []struct { - name string - args args - want *provider.Instance - }{ - { - name: "nil ec2 instance should return nil", - args: args{ - data: nil, - }, - want: nil, - }, - { - name: "empty ec2 instance should return empty instance", - args: args{ - data: &ec2.Instance{}, - }, - want: &provider.Instance{}, - }, - { - name: "complete ec2 instance should be decoded properly to instance", - args: args{ - data: &ec2.Instance{ - InstanceId: aws.String("InstanceId"), - VpcId: aws.String("VpcId"), - NetworkInterfaces: []*ec2.InstanceNetworkInterface{ - { - NetworkInterfaceId: aws.String("FooNetworkInterfaceId"), - Association: &ec2.InstanceNetworkInterfaceAssociation{ - PublicIp: aws.String("FooPublicIp"), - }, - Attachment: &ec2.InstanceNetworkInterfaceAttachment{ - DeviceIndex: ptr.To(int64(1)), - }, - }, - { - NetworkInterfaceId: aws.String("BarNetworkInterfaceId"), - Association: &ec2.InstanceNetworkInterfaceAssociation{ - PublicIp: aws.String("BarPublicIp"), - }, - Attachment: &ec2.InstanceNetworkInterfaceAttachment{ - DeviceIndex: ptr.To(int64(0)), - }, - }, - }, - }, - }, - want: &provider.Instance{ - InstanceID: "InstanceId", - VpcID: "VpcId", - NetworkInterfaces: []*provider.NetworkInterface{ - { - NetworkInterfaceID: "FooNetworkInterfaceId", - PublicIP: aws.String("FooPublicIp"), - DeviceID: ptr.To(int64(1)), - }, - { - NetworkInterfaceID: "BarNetworkInterfaceId", - PublicIP: aws.String("BarPublicIp"), - DeviceID: ptr.To(int64(0)), - }, - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := DecodeInstance(tt.args.data); !reflect.DeepEqual(got, tt.want) { - t.Errorf("DecodeInstance() = %v, want %v", got, tt.want) - } - }) - } -} diff --git a/internal/provider/aws/converter/security_group_decoder.go b/internal/provider/aws/converter/security_group_decoder.go index d9a8483..02ced7c 100644 --- a/internal/provider/aws/converter/security_group_decoder.go +++ b/internal/provider/aws/converter/security_group_decoder.go @@ -2,73 +2,24 @@ package converter import ( + "github.com/aws/aws-sdk-go-v2/service/ec2/types" "github.com/aws/aws-sdk-go/aws" - "github.com/aws/aws-sdk-go/service/ec2" "github.com/quortex/kubestatic/internal/provider" ) -// DecodeSecurityGroup converts an ec2 SecurityGroup to a Firewall. -func DecodeSecurityGroup(data *ec2.SecurityGroup) *provider.FirewallRule { - if data == nil { - return nil - } - - var permission *provider.IPPermission - var direction provider.Direction - if len(data.IpPermissions) > 0 { - permission = DecodeIpPermission(data.IpPermissions[0]) - direction = provider.DirectionIngress - } else if len(data.IpPermissionsEgress) > 0 { - permission = DecodeIpPermission(data.IpPermissionsEgress[0]) - direction = provider.DirectionEgress - } - - return &provider.FirewallRule{ - FirewallRuleID: aws.StringValue(data.GroupId), - VpcID: aws.StringValue(data.VpcId), - FirewallRuleSpec: provider.FirewallRuleSpec{ - Name: aws.StringValue(data.GroupName), - Description: aws.StringValue(data.Description), - Direction: direction, - IPPermission: permission, - }, - } -} - -// DecodeSecurityGroups converts an ec2 SecurityGroup slice to a Firewall slice. -func DecodeSecurityGroups(data []*ec2.SecurityGroup) []*provider.FirewallRule { - if data == nil { - return make([]*provider.FirewallRule, 0) - } - - res := make([]*provider.FirewallRule, len(data)) - for i, e := range data { - res[i] = DecodeSecurityGroup(e) - } - return res -} - // DecodeIpPermission converts an ec2 IpPermission to an IPPermission. -func DecodeIpPermission(data *ec2.IpPermission) *provider.IPPermission { - if data == nil { - return nil - } - +func DecodeIpPermission(data types.IpPermission) *provider.IPPermission { return &provider.IPPermission{ - FromPort: aws.Int64Value(data.FromPort), + FromPort: int64(aws.Int32Value(data.FromPort)), Protocol: aws.StringValue(data.IpProtocol), IPRanges: DecodeIpRanges(data.IpRanges), - ToPort: data.ToPort, + ToPort: aws.Int64(int64(aws.Int32Value(data.ToPort))), } } // DecodeIpPermissions converts an ec2 IpPermission slice to an IPPermission slice. -func DecodeIpPermissions(data []*ec2.IpPermission) []*provider.IPPermission { - if data == nil { - return make([]*provider.IPPermission, 0) - } - +func DecodeIpPermissions(data []types.IpPermission) []*provider.IPPermission { res := make([]*provider.IPPermission, len(data)) for i, e := range data { res[i] = DecodeIpPermission(e) @@ -77,11 +28,7 @@ func DecodeIpPermissions(data []*ec2.IpPermission) []*provider.IPPermission { } // DecodeIpRange converts an ec2 IpRange to an IPRange. -func DecodeIpRange(data *ec2.IpRange) *provider.IPRange { - if data == nil { - return nil - } - +func DecodeIpRange(data types.IpRange) *provider.IPRange { return &provider.IPRange{ CIDR: aws.StringValue(data.CidrIp), Description: aws.StringValue(data.Description), @@ -89,11 +36,7 @@ func DecodeIpRange(data *ec2.IpRange) *provider.IPRange { } // DecodeIpRanges converts an ec2 IpRange slice to an IPRange slice. -func DecodeIpRanges(data []*ec2.IpRange) []*provider.IPRange { - if data == nil { - return make([]*provider.IPRange, 0) - } - +func DecodeIpRanges(data []types.IpRange) []*provider.IPRange { res := make([]*provider.IPRange, len(data)) for i, e := range data { res[i] = DecodeIpRange(e) diff --git a/internal/provider/aws/converter/security_group_decoder_test.go b/internal/provider/aws/converter/security_group_decoder_test.go deleted file mode 100644 index 4c074c3..0000000 --- a/internal/provider/aws/converter/security_group_decoder_test.go +++ /dev/null @@ -1,133 +0,0 @@ -// Package converter provides conversion methods for ec2 models. -package converter - -import ( - "reflect" - "testing" - - "github.com/aws/aws-sdk-go/aws" - "github.com/aws/aws-sdk-go/service/ec2" - - "github.com/quortex/kubestatic/internal/provider" -) - -func TestDecodeSecurityGroups(t *testing.T) { - type args struct { - data []*ec2.SecurityGroup - } - tests := []struct { - name string - args args - want []*provider.FirewallRule - }{ - { - name: "various ec2 security groups should be decoded properly to firewall rules", - args: args{ - data: []*ec2.SecurityGroup{ - nil, - {}, - { - Description: aws.String("FooDescription"), - GroupId: aws.String("FooGroupId"), - GroupName: aws.String("FooGroupName"), - IpPermissions: []*ec2.IpPermission{ - { - FromPort: aws.Int64(2), - IpProtocol: aws.String("tcp"), - IpRanges: []*ec2.IpRange{ - { - CidrIp: aws.String("0.0.0.0/0"), - Description: aws.String("FooCIDRDescription"), - }, - }, - ToPort: aws.Int64(22), - }, - }, - // If both Ingress and Egress permissions are set, Ingress has priority - IpPermissionsEgress: []*ec2.IpPermission{ - { - FromPort: aws.Int64(2), - }}, - OwnerId: aws.String("FooOwnerId"), - VpcId: aws.String("FooVpcId"), - }, - { - Description: aws.String("BarDescription"), - GroupId: aws.String("BarGroupId"), - GroupName: aws.String("BarGroupName"), - IpPermissionsEgress: []*ec2.IpPermission{ - { - FromPort: aws.Int64(4), - IpProtocol: aws.String("tcp"), - IpRanges: []*ec2.IpRange{ - { - CidrIp: aws.String("1.2.3.4/32"), - Description: aws.String("BarCIDRDescription"), - }, - }, - ToPort: aws.Int64(44), - }, - // Multiple permissions, decode only the first. - { - FromPort: aws.Int64(2), - }, - }, - OwnerId: aws.String("BarOwnerId"), - VpcId: aws.String("BarVpcId"), - }, - }, - }, - want: []*provider.FirewallRule{ - nil, - {}, - { - FirewallRuleID: "FooGroupId", - VpcID: "FooVpcId", - FirewallRuleSpec: provider.FirewallRuleSpec{ - Name: "FooGroupName", - Description: "FooDescription", - Direction: provider.DirectionIngress, - IPPermission: &provider.IPPermission{ - FromPort: 2, - Protocol: "tcp", - IPRanges: []*provider.IPRange{ - { - CIDR: "0.0.0.0/0", - Description: "FooCIDRDescription", - }, - }, - ToPort: aws.Int64(22), - }, - }, - }, - { - FirewallRuleID: "BarGroupId", - VpcID: "BarVpcId", - FirewallRuleSpec: provider.FirewallRuleSpec{ - Name: "BarGroupName", - Description: "BarDescription", - Direction: provider.DirectionEgress, - IPPermission: &provider.IPPermission{ - FromPort: 4, - Protocol: "tcp", - IPRanges: []*provider.IPRange{ - { - CIDR: "1.2.3.4/32", - Description: "BarCIDRDescription", - }, - }, - ToPort: aws.Int64(44), - }, - }, - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := DecodeSecurityGroups(tt.args.data); !reflect.DeepEqual(got, tt.want) { - t.Errorf("DecodeSecurityGroups() = %v, want %v", got, tt.want) - } - }) - } -} diff --git a/internal/provider/aws/converter/security_group_encoder.go b/internal/provider/aws/converter/security_group_encoder.go index aaf1e96..7108c4c 100644 --- a/internal/provider/aws/converter/security_group_encoder.go +++ b/internal/provider/aws/converter/security_group_encoder.go @@ -4,16 +4,16 @@ package converter import ( "slices" + "github.com/aws/aws-sdk-go-v2/service/ec2/types" "github.com/aws/aws-sdk-go/aws" - "github.com/aws/aws-sdk-go/service/ec2" "k8s.io/utils/ptr" "github.com/quortex/kubestatic/internal/provider" ) // EncodeIPPermission converts an IPPermission to an ec2 IpPermission. -func EncodeIPPermission(req provider.IPPermission) *ec2.IpPermission { - res := &ec2.IpPermission{ +func EncodeIPPermission(req provider.IPPermission) types.IpPermission { + res := types.IpPermission{ IpProtocol: aws.String(req.Protocol), IpRanges: EncodeIpRanges(req.IPRanges), } @@ -21,28 +21,28 @@ func EncodeIPPermission(req provider.IPPermission) *ec2.IpPermission { // fromport / toport must be specified for the tcp / udp protocol even if they are zero. // On the contrary, they must be omitted for the other protocols if they are zero. if slices.Contains([]string{"udp", "tcp", "UDP", "TCP"}, req.Protocol) { - res.FromPort = aws.Int64(req.FromPort) - res.ToPort = req.ToPort + res.FromPort = aws.Int32(int32(req.FromPort)) + if req.ToPort != nil { + res.ToPort = aws.Int32(int32(*req.ToPort)) + } if res.ToPort == nil { - res.ToPort = aws.Int64(req.FromPort) + res.ToPort = aws.Int32(int32(req.FromPort)) } } else { if req.FromPort != 0 { - res.FromPort = ptr.To(req.FromPort) + res.FromPort = aws.Int32(int32(req.FromPort)) + } + if req.ToPort != nil { + res.ToPort = aws.Int32(int32(*req.ToPort)) } - res.ToPort = req.ToPort } return res } // EncodeIpRange converts an IPRange to an ec2 IpRange. -func EncodeIpRange(data *provider.IPRange) *ec2.IpRange { - if data == nil { - return nil - } - - res := &ec2.IpRange{ +func EncodeIpRange(data *provider.IPRange) types.IpRange { + res := types.IpRange{ CidrIp: aws.String(data.CIDR), } if data.Description != "" { @@ -52,12 +52,8 @@ func EncodeIpRange(data *provider.IPRange) *ec2.IpRange { } // EncodeIpRanges converts an IPRange slice to an ec2 IpRange slice. -func EncodeIpRanges(data []*provider.IPRange) []*ec2.IpRange { - if data == nil { - return make([]*ec2.IpRange, 0) - } - - res := make([]*ec2.IpRange, len(data)) +func EncodeIpRanges(data []*provider.IPRange) []types.IpRange { + res := make([]types.IpRange, len(data)) for i, e := range data { res[i] = EncodeIpRange(e) } diff --git a/internal/provider/aws/converter/security_group_encoder_test.go b/internal/provider/aws/converter/security_group_encoder_test.go index 4374949..5b6b3ea 100644 --- a/internal/provider/aws/converter/security_group_encoder_test.go +++ b/internal/provider/aws/converter/security_group_encoder_test.go @@ -5,8 +5,8 @@ import ( "reflect" "testing" + "github.com/aws/aws-sdk-go-v2/service/ec2/types" "github.com/aws/aws-sdk-go/aws" - "github.com/aws/aws-sdk-go/service/ec2" "github.com/quortex/kubestatic/internal/provider" ) @@ -18,7 +18,7 @@ func TestEncodeIPPermission(t *testing.T) { tests := []struct { name string args args - want *ec2.IpPermission + want types.IpPermission }{ { name: "empty IPPermissions should be decoded to empty ec2 IPPermission", @@ -30,9 +30,9 @@ func TestEncodeIPPermission(t *testing.T) { ToPort: nil, }, }, - want: &ec2.IpPermission{ + want: types.IpPermission{ IpProtocol: aws.String(""), - IpRanges: []*ec2.IpRange{}, + IpRanges: []types.IpRange{}, }, }, { @@ -54,10 +54,10 @@ func TestEncodeIPPermission(t *testing.T) { ToPort: aws.Int64(44), }, }, - want: &ec2.IpPermission{ - FromPort: aws.Int64(22), + want: types.IpPermission{ + FromPort: aws.Int32(22), IpProtocol: aws.String("udp"), - IpRanges: []*ec2.IpRange{ + IpRanges: []types.IpRange{ { CidrIp: aws.String("FooCIDR"), Description: aws.String("FooDescription"), @@ -67,7 +67,7 @@ func TestEncodeIPPermission(t *testing.T) { Description: aws.String("BarDescription"), }, }, - ToPort: aws.Int64(44), + ToPort: aws.Int32(44), }, }, } diff --git a/internal/provider/aws/imds.go b/internal/provider/aws/imds.go new file mode 100644 index 0000000..da74341 --- /dev/null +++ b/internal/provider/aws/imds.go @@ -0,0 +1,109 @@ +package aws + +import ( + "fmt" + "io" + "net/http" + "time" +) + +const ( + // Retrieve instance metadata for AWS EC2 instance + // https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/instancedata-data-retrieval.html + instanceMetadataEndpoint = "http://169.254.169.254/latest/meta-data" + + // IMDSv2 token related constants + tokenEndpoint = "http://169.254.169.254/latest/api/token" + tokenTTLHeader = "X-aws-ec2-metadata-token-ttl-seconds" + tokenRequestHeader = "X-aws-ec2-metadata-token" +) + +// The VPC identifier +// Automatically retrieved with GetVPCID function. +// For run outside of the cluster, can be set through linker flag, e.g. +// go build -ldflags "-X github.com/quortex/kubestatic/internal/provider/aws.vpcID=$VPC_ID" -a -o manager main.go +var vpcID string + +func init() { + // Get vpc ID from the running instance + id, err := retrieveVPCID() + if err != nil { + panic(err) + } + vpcID = id +} + +// retrieveVPCID retrieves the VPC ID of the instance. +// It first checks if the VPC ID is already cached. If not, it attempts to get an IMDSv2 token. +// If the token retrieval fails, it falls back to IMDSv1. It then retrieves the MAC address +// and uses it to get the VPC ID. +func retrieveVPCID() (string, error) { + if vpcID != "" { + return vpcID, nil + } + + client := http.Client{Timeout: 3 * time.Second} + + token, err := getV2Token(client) + if err != nil { + fmt.Printf("failed getting IMDSv2 token falling back to IMDSv1 : %s", err) + } + + mac, err := retrieveInstanceMetadata(client, "/mac", token) + if err != nil { + return "", err + } + + body, err := retrieveInstanceMetadata(client, "/network/interfaces/macs/"+mac+"/vpc-id", token) + if err != nil { + return "", err + } + + return body, nil +} + +// getV2Token retrieves an IMDSv2 token using the provided HTTP client. +// It sends a PUT request to the token endpoint and returns the token if successful. +func getV2Token(client http.Client) (string, error) { + req, err := http.NewRequest(http.MethodPut, tokenEndpoint, nil) + if err != nil { + return "", err + } + req.Header.Set(tokenTTLHeader, "21600") + res, err := client.Do(req) + if err != nil { + return "", err + } + defer func() { _ = res.Body.Close() }() + + token, err := io.ReadAll(res.Body) + if err != nil { + return "", err + } + + return string(token), nil +} + +// retrieveInstanceMetadata retrieves instance metadata from the specified context path using the provided HTTP client and token. +// It sends a GET request to the instance metadata endpoint and returns the response body if successful. +func retrieveInstanceMetadata(client http.Client, contextPath string, token string) (string, error) { + req, err := http.NewRequest(http.MethodGet, instanceMetadataEndpoint+contextPath, nil) + if err != nil { + return "", err + } + + if token != "" { + req.Header.Set(tokenRequestHeader, token) + } + res, err := client.Do(req) + if err != nil { + return "", err + } + + defer func() { _ = res.Body.Close() }() + body, err := io.ReadAll(res.Body) + if err != nil { + return "", err + } + return string(body), nil +} diff --git a/internal/provider/aws/provider.go b/internal/provider/aws/provider.go index 7e32cd6..c31a9ce 100644 --- a/internal/provider/aws/provider.go +++ b/internal/provider/aws/provider.go @@ -4,442 +4,719 @@ package aws import ( "context" "fmt" - "io" - "net/http" "path" - "time" + "slices" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/ec2" + "github.com/aws/aws-sdk-go-v2/service/ec2/types" "github.com/aws/aws-sdk-go/aws" - "github.com/aws/aws-sdk-go/aws/session" - "github.com/aws/aws-sdk-go/service/ec2" corev1 "k8s.io/api/core/v1" + "github.com/quortex/kubestatic/api/v1alpha1" "github.com/quortex/kubestatic/internal/provider" "github.com/quortex/kubestatic/internal/provider/aws/converter" + "github.com/quortex/kubestatic/internal/utils" ) +// TagKey represents an AWS tag key. +type TagKey string + const ( - // Retrieve instance metadata for AWS EC2 instance - // https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/instancedata-data-retrieval.html - instanceMetadataEndpoint = "http://169.254.169.254/latest/meta-data" - - // IMDSv2 token related constants - tokenEndpoint = "http://169.254.169.254/latest/api/token" - tokenTTLHeader = "X-aws-ec2-metadata-token-ttl-seconds" - tokenRequestHeader = "X-aws-ec2-metadata-token" + TagKeyDomain = "kubestatic.quortex.io" // Tag key domain + TagKeyManaged TagKey = TagKeyDomain + "/managed" // Tag key for kubestatic managed resources + TagKeyNodeName TagKey = TagKeyDomain + "/node-name" // Tag key for node name + TagKeyInstanceID TagKey = TagKeyDomain + "/instance-id" // Tag key for instance ID + TagKeyExternalIPName TagKey = TagKeyDomain + "/external-ip-name" // Tag key for external IP name ) -// The VPC identifier -// Automatically retrieved with GetVPCID function. -// For run outside of the cluster, can be set through linker flag, e.g. -// go build -ldflags "-X github.com/quortex/kubestatic/internalprovider/aws.vpcID=$VPC_ID" -a -o manager main.go -var vpcID string - -type awsProvider struct { - ec2 *ec2.EC2 +// FilterOption is a filter option for AWS API calls. +type FilterOption interface { + Filter() types.Filter } -// NewProvider instantiate a Provider implementation for AWS -func NewProvider() (provider.Provider, error) { - // By default NewSession loads credentials from the shared credentials file (~/.aws/credentials) - // - // The Session will attempt to load configuration and credentials from the environment, - // configuration files, and other credential sources. The order configuration is loaded in is: - // * Environment Variables - // * Shared Credentials file - // * Shared Configuration file (if SharedConfig is enabled) - // * EC2 Instance Metadata (credentials only) - session, err := session.NewSession() - if err != nil { - return nil, err - } +// ManagedFilter is a filter option to get resources managed by kubestatic. +type ManagedFilter struct{} - // Get vpc ID from the running instance - id, err := retrieveVPCID() - if err != nil { - return nil, err +func (f ManagedFilter) Filter() types.Filter { + return types.Filter{ + Name: aws.String(fmt.Sprintf("tag:%s", TagKeyManaged)), + Values: []string{"true"}, } - vpcID = id +} - return &awsProvider{ - ec2: ec2.New(session), - }, nil +func Managed() ManagedFilter { + return ManagedFilter{} } -func getV2Token(client http.Client) (string, error) { - req, err := http.NewRequest(http.MethodPut, tokenEndpoint, nil) - if err != nil { - return "", err - } - req.Header.Set(tokenTTLHeader, "21600") - res, err := client.Do(req) - if err != nil { - return "", err - } - defer func() { _ = res.Body.Close() }() +// VPCFilter is a filter option to get resources in a specific VPC. +type VPCFilter struct { + VPCID string +} - token, err := io.ReadAll(res.Body) - if err != nil { - return "", err +func (f VPCFilter) Filter() types.Filter { + return types.Filter{ + Name: aws.String("vpc-id"), + Values: []string{f.VPCID}, } +} - return string(token), nil +func WithVPCID(vpcID string) VPCFilter { + return VPCFilter{VPCID: vpcID} } -func retrieveInstanceMetadata(client http.Client, contextPath string, token string) (string, error) { - req, err := http.NewRequest(http.MethodGet, instanceMetadataEndpoint+contextPath, nil) - if err != nil { - return "", err - } +// NodeNameFilter is a filter option to get resources associated with a specific node name. +type NodeNameFilter struct { + NodeName string +} - if token != "" { - req.Header.Set(tokenRequestHeader, token) - } - res, err := client.Do(req) - if err != nil { - return "", err +func (f NodeNameFilter) Filter() types.Filter { + return types.Filter{ + Name: aws.String(fmt.Sprintf("tag:%s", TagKeyNodeName)), + Values: []string{f.NodeName}, } +} - defer func() { _ = res.Body.Close() }() - body, err := io.ReadAll(res.Body) - if err != nil { - return "", err - } - return string(body), nil +func WithNodeName(nodeName string) NodeNameFilter { + return NodeNameFilter{NodeName: nodeName} +} + +// SecurityGroupIDFilter is a filter option to filter by security group ID. +type SecurityGroupIDFilter struct { + SecurityGroupID string } -func retrieveVPCID() (string, error) { - if vpcID != "" { - return vpcID, nil +func (f SecurityGroupIDFilter) Filter() types.Filter { + return types.Filter{ + Name: aws.String("group-id"), + Values: []string{f.SecurityGroupID}, } +} - client := http.Client{Timeout: 3 * time.Second} +func WithSecurityGroupID(securityGroupID string) SecurityGroupIDFilter { + return SecurityGroupIDFilter{SecurityGroupID: securityGroupID} +} - token, err := getV2Token(client) - if err != nil { - fmt.Printf("failed getting IMDSv2 token falling back to IMDSv1 : %s", err) +// ExternalIPNameFilter is a filter option to filter by ExternalIP name. +type ExternalIPNameFilter struct { + ExternalIPName string +} + +func (f ExternalIPNameFilter) Filter() types.Filter { + return types.Filter{ + Name: aws.String(fmt.Sprintf("tag:%s", TagKeyExternalIPName)), + Values: []string{f.ExternalIPName}, } +} - mac, err := retrieveInstanceMetadata(client, "/mac", token) - if err != nil { - return "", err +func WithExternalIPName(externalIPName string) ExternalIPNameFilter { + return ExternalIPNameFilter{ExternalIPName: externalIPName} +} + +// AddressIDFilter is a filter option to filter by address ID. +type AddressIDFilter struct { + AddressID string +} + +func (f AddressIDFilter) Filter() types.Filter { + return types.Filter{ + Name: aws.String("allocation-id"), + Values: []string{f.AddressID}, } +} + +func WithAddressID(addressID string) AddressIDFilter { + return AddressIDFilter{AddressID: addressID} +} - body, err := retrieveInstanceMetadata(client, "/network/interfaces/macs/"+mac+"/vpc-id", token) +// awsProvider is an AWS provider implementation for the provider.Provider interface +type awsProvider struct { + ec2 *ec2.Client +} + +// NewProvider instantiate a Provider implementation for AWS +func NewProvider() (provider.Provider, error) { + // Load the Shared AWS Configuration (~/.aws/config) + cfg, err := config.LoadDefaultConfig(context.TODO()) if err != nil { - return "", err + panic(err) } - return body, nil + return &awsProvider{ + ec2: ec2.NewFromConfig(cfg), + }, nil } +// GetInstanceID returns the instance ID from a node func (p *awsProvider) GetInstanceID(node corev1.Node) string { return path.Base(node.Spec.ProviderID) } -// Firewall rule groups are supported by AWS (EC2 SecurityGroups). -func (p *awsProvider) HasGroupedFirewallRules() bool { - return true -} - -func (p *awsProvider) GetInstance(ctx context.Context, instanceID string) (*provider.Instance, error) { - res, err := p.ec2.DescribeInstances(&ec2.DescribeInstancesInput{ - InstanceIds: aws.StringSlice([]string{instanceID}), +func (p *awsProvider) getInstance(ctx context.Context, instanceID string) (*types.Instance, error) { + res, err := p.ec2.DescribeInstances(ctx, &ec2.DescribeInstancesInput{ + InstanceIds: []string{instanceID}, }) if err != nil { return nil, converter.DecodeEC2Error("failed to get instance", err) } - if len(res.Reservations) == 0 || len(res.Reservations[0].Instances) == 0 { return nil, &provider.Error{ Code: provider.NotFoundError, Msg: fmt.Sprintf("failed to get instance: instance with instance-id %s not found", instanceID), } } - - return converter.DecodeInstance(res.Reservations[0].Instances[0]), nil + return &res.Reservations[0].Instances[0], nil } -func (p *awsProvider) GetAddress(ctx context.Context, addressID string) (*provider.Address, error) { - res, err := p.ec2.DescribeAddresses(&ec2.DescribeAddressesInput{ - Filters: []*ec2.Filter{ +func (p *awsProvider) getNetworkInterfaces(ctx context.Context, securityGroupID string) ([]types.NetworkInterface, error) { + res, err := p.ec2.DescribeNetworkInterfaces(ctx, &ec2.DescribeNetworkInterfacesInput{ + Filters: []types.Filter{ { - Name: aws.String("domain"), - Values: aws.StringSlice([]string{"vpc"}), - }, - { - Name: aws.String("allocation-id"), - Values: aws.StringSlice([]string{addressID}), + Name: aws.String("group-id"), + Values: []string{securityGroupID}, }, }, }) if err != nil { - return nil, converter.DecodeEC2Error("failed to get address", err) + return nil, converter.DecodeEC2Error("failed to list network interfaces", err) + } + return res.NetworkInterfaces, nil +} + +func eniWithPublicIP(instance *types.Instance) (*types.InstanceNetworkInterface, error) { + idx := slices.IndexFunc(instance.NetworkInterfaces, func(ni types.InstanceNetworkInterface) bool { + return ni.Association != nil && ni.Association.PublicIp != nil + }) + if idx == -1 { + return nil, fmt.Errorf("no network interface with public IP found for instance %s", aws.StringValue(instance.InstanceId)) + } + return &instance.NetworkInterfaces[idx], nil +} + +func (p *awsProvider) getSecurityGroup( + ctx context.Context, + opts ...FilterOption, +) (*types.SecurityGroup, error) { + filters := make([]types.Filter, len(opts)) + for _, opt := range opts { + filters = append(filters, opt.Filter()) + } + + res, err := p.ec2.DescribeSecurityGroups(ctx, &ec2.DescribeSecurityGroupsInput{Filters: filters}) + if err != nil { + return nil, converter.DecodeEC2Error("failed to list security groups", err) } - if len(res.Addresses) == 0 { + if len(res.SecurityGroups) == 0 { return nil, &provider.Error{ Code: provider.NotFoundError, - Msg: fmt.Sprintf("failed to get address: address with allocation-id %s not found", addressID), + Msg: "failed to get security group: security group not found", } } + return &res.SecurityGroups[0], nil +} + +func (p *awsProvider) createSecurityGroup(ctx context.Context, vpcID, nodeName, instanceID string) (string, error) { + res, err := p.ec2.CreateSecurityGroup(ctx, &ec2.CreateSecurityGroupInput{ + GroupName: aws.String(fmt.Sprintf("kubestatic-%s", utils.RandomString(10))), + Description: aws.String(fmt.Sprintf("Kubestatic managed group for instance %s", instanceID)), + VpcId: aws.String(vpcID), + TagSpecifications: []types.TagSpecification{ + { + ResourceType: types.ResourceTypeSecurityGroup, + Tags: []types.Tag{ + { + Key: aws.String(string(TagKeyManaged)), + Value: aws.String("true"), + }, + { + Key: aws.String(string(TagKeyNodeName)), + Value: aws.String(nodeName), + }, + { + Key: aws.String(string(TagKeyInstanceID)), + Value: aws.String(instanceID), + }, + }, + }, + }, + }) + if err != nil { + return "", converter.DecodeEC2Error("failed to create security group", err) + } - return converter.DecodeAddress(res.Addresses[0]), nil + return aws.StringValue(res.GroupId), nil } -func (p *awsProvider) CreateAddress(ctx context.Context) (*provider.Address, error) { - res, err := p.ec2.AllocateAddress(&ec2.AllocateAddressInput{ - Domain: aws.String("vpc"), +func (p *awsProvider) deleteSecurityGroup(ctx context.Context, securityGroupID string) error { + _, err := p.ec2.DeleteSecurityGroup(ctx, &ec2.DeleteSecurityGroupInput{ + GroupId: aws.String(securityGroupID), }) if err != nil { - return nil, converter.DecodeEC2Error("failed to create address", err) + return converter.DecodeEC2Error("failed to delete security group", err) } - return p.GetAddress(ctx, aws.StringValue(res.AllocationId)) + return nil } -func (p *awsProvider) DeleteAddress(ctx context.Context, addressID string) error { - _, err := p.ec2.ReleaseAddress(&ec2.ReleaseAddressInput{ - AllocationId: aws.String(addressID), +func (p *awsProvider) authorizeSecurityGroupIngress(ctx context.Context, firewallRuleID string, req provider.IPPermission) error { + _, err := p.ec2.AuthorizeSecurityGroupIngress(ctx, &ec2.AuthorizeSecurityGroupIngressInput{ + GroupId: &firewallRuleID, + IpPermissions: []types.IpPermission{ + converter.EncodeIPPermission(req), + }, }) if err != nil { - return converter.DecodeEC2Error("failed to delete address", err) + return converter.DecodeEC2Error("failed to authorize security group ingress permission", err) } return nil } -func (p *awsProvider) AssociateAddress(ctx context.Context, req provider.AssociateAddressRequest) error { - _, err := p.ec2.AssociateAddress(&ec2.AssociateAddressInput{ - AllocationId: aws.String(req.AddressID), - NetworkInterfaceId: aws.String(req.NetworkInterfaceID), +func (p *awsProvider) revokeSecurityGroupIngress(ctx context.Context, firewallRuleID string, req provider.IPPermission) error { + _, err := p.ec2.RevokeSecurityGroupIngress(ctx, &ec2.RevokeSecurityGroupIngressInput{ + GroupId: aws.String(firewallRuleID), + IpPermissions: []types.IpPermission{ + converter.EncodeIPPermission(req), + }, }) if err != nil { - return converter.DecodeEC2Error("failed to associate address", err) + return converter.DecodeEC2Error("failed to revoke security group ingress permission", err) } return nil } -func (p *awsProvider) DisassociateAddress(ctx context.Context, req provider.DisassociateAddressRequest) error { - _, err := p.ec2.DisassociateAddress(&ec2.DisassociateAddressInput{ - AssociationId: aws.String(req.AssociationID), +func (p *awsProvider) authorizeSecurityGroupEgress(ctx context.Context, firewallRuleID string, req provider.IPPermission) error { + _, err := p.ec2.AuthorizeSecurityGroupEgress(ctx, &ec2.AuthorizeSecurityGroupEgressInput{ + GroupId: aws.String(firewallRuleID), + IpPermissions: []types.IpPermission{ + converter.EncodeIPPermission(req), + }, }) if err != nil { - return converter.DecodeEC2Error("failed to disassociate address", err) + return converter.DecodeEC2Error("failed to authorize security group egress permission", err) } return nil } -func (p *awsProvider) getSecurityGroup(_ context.Context, firewallRuleID string) (*ec2.SecurityGroup, error) { - res, err := p.ec2.DescribeSecurityGroups(&ec2.DescribeSecurityGroupsInput{ - GroupIds: aws.StringSlice([]string{firewallRuleID}), +func (p *awsProvider) revokeSecurityGroupEgress(ctx context.Context, firewallRuleID string, req provider.IPPermission) error { + _, err := p.ec2.RevokeSecurityGroupEgress(ctx, &ec2.RevokeSecurityGroupEgressInput{ + GroupId: aws.String(firewallRuleID), + IpPermissions: []types.IpPermission{ + converter.EncodeIPPermission(req), + }, }) if err != nil { - return nil, converter.DecodeEC2Error("failed to get security group", err) + return converter.DecodeEC2Error("failed to revoke security group egress permission", err) } - if len(res.SecurityGroups) == 0 { + return nil +} + +func (p *awsProvider) getAddress( + ctx context.Context, + opts ...FilterOption, +) (*types.Address, error) { + filters := make([]types.Filter, len(opts)) + for _, opt := range opts { + filters = append(filters, opt.Filter()) + } + + res, err := p.ec2.DescribeAddresses(ctx, &ec2.DescribeAddressesInput{Filters: filters}) + if err != nil { + return nil, converter.DecodeEC2Error("failed to list addresses", err) + } + + if len(res.Addresses) == 0 { return nil, &provider.Error{ Code: provider.NotFoundError, - Msg: fmt.Sprintf("failed to get security group: security group with group-id %s not found", firewallRuleID), + Msg: "failed to get address: address not found", } } + return &res.Addresses[0], nil +} + +func (p *awsProvider) createAddress(ctx context.Context, externalIPName, instanceID string) (string, error) { + res, err := p.ec2.AllocateAddress(ctx, &ec2.AllocateAddressInput{ + Domain: "vpc", + TagSpecifications: []types.TagSpecification{ + { + ResourceType: types.ResourceTypeElasticIp, + Tags: []types.Tag{ + { + Key: aws.String(string(TagKeyManaged)), + Value: aws.String("true"), + }, + { + Key: aws.String(string(TagKeyExternalIPName)), + Value: aws.String(externalIPName), + }, + { + Key: aws.String(string(TagKeyInstanceID)), + Value: aws.String(instanceID), + }, + }, + }, + }, + }) + if err != nil { + return "", converter.DecodeEC2Error("failed to create address", err) + } - return res.SecurityGroups[0], nil + return aws.StringValue(res.AllocationId), nil } -func (p *awsProvider) FetchFirewallRule(ctx context.Context, firewallRuleGroupID string) error { - _, err := p.getSecurityGroup(ctx, firewallRuleGroupID) +func (p *awsProvider) associateAddress(ctx context.Context, addressID, networkInterfaceID string) error { + _, err := p.ec2.AssociateAddress(ctx, &ec2.AssociateAddressInput{ + AllocationId: &addressID, + NetworkInterfaceId: &networkInterfaceID, + }) if err != nil { - return converter.DecodeEC2Error("failed to fetch security group", err) + return converter.DecodeEC2Error("failed to associate address", err) } + return nil } -func (p *awsProvider) CreateFirewallRule(ctx context.Context, req provider.CreateFirewallRuleRequest) (string, error) { - panic("unimplemented method for AWS: CreateFirewallRule, use CreateFirewallRuleGroup instead") +func (p *awsProvider) disassociateAddress(ctx context.Context, associationID string) error { + _, err := p.ec2.DisassociateAddress(ctx, &ec2.DisassociateAddressInput{ + AssociationId: &associationID, + }) + if err != nil { + return converter.DecodeEC2Error("failed to disassociate address", err) + } + + return nil } -func (p *awsProvider) CreateFirewallRuleGroup(ctx context.Context, req provider.CreateFirewallRuleGroupRequest) (string, error) { - res, err := p.ec2.CreateSecurityGroup(&ec2.CreateSecurityGroupInput{ - Description: aws.String(req.Description), - GroupName: aws.String(req.Name), - VpcId: aws.String(vpcID), +func (p *awsProvider) deleteAddress(ctx context.Context, addressID string) error { + _, err := p.ec2.ReleaseAddress(ctx, &ec2.ReleaseAddressInput{ + AllocationId: &addressID, }) if err != nil { - return "", converter.DecodeEC2Error("failed to create security group", err) + return converter.DecodeEC2Error("failed to delete address", err) } - return p.UpdateFirewallRuleGroup(ctx, provider.UpdateFirewallRuleGroupRequest{ - FirewallRuleGroupID: *res.GroupId, - FirewallRules: req.FirewallRules, - }) + return nil } -func (p *awsProvider) UpdateFirewallRule(ctx context.Context, req provider.UpdateFirewallRuleRequest) (*provider.FirewallRule, error) { - panic("unimplemented method for AWS: UpdateFirewallRule, use UpdateFirewallRuleGroup instead") -} +// ReconcileFirewallRules ensures that the firewall rules for a given instance are correctly configured. +// It performs the following steps: +// 1. Retrieves the instance information using the provided instance ID. +// 2. Retrieves or creates a security group associated with the instance. +// 3. Associates the security group with the network interface that has a public IP address. +// 4. Disassociates the security group from other network interfaces. +// 5. Applies ingress and egress permissions to the security group based on the provided firewall rules. +// +// Parameters: +// - ctx: The context for the operation. +// - nodeName: The name of the node. +// - instanceID: The ID of the instance. +// - firewallRules: A list of firewall rules to be applied. +// +// Returns: +// - v1alpha1.FirewallRuleStatus: The status of the firewall rule reconciliation. +// - error: An error if the reconciliation fails. +func (p *awsProvider) ReconcileFirewallRules( + ctx context.Context, + nodeName, instanceID string, + firewallRules []v1alpha1.FirewallRule, +) (v1alpha1.FirewallRuleStatus, error) { + status := v1alpha1.FirewallRuleStatus{ + State: v1alpha1.FirewallRuleStateNone, + } + + // Get the instance + instance, err := p.getInstance(ctx, instanceID) + if err != nil { + return status, err + } + + // Get the security group associated with the instance + securityGroup, err := p.getSecurityGroup(ctx, Managed(), WithVPCID(aws.StringValue(instance.VpcId)), WithNodeName(nodeName)) + if err != nil && err.(*provider.Error).Code != provider.NotFoundError { + return status, err + } + + if securityGroup == nil { + securityGroupID, err := p.createSecurityGroup(ctx, aws.StringValue(instance.VpcId), nodeName, instanceID) + if err != nil { + return status, converter.DecodeEC2Error("failed to create security group", err) + } -func (p *awsProvider) UpdateFirewallRuleGroup(ctx context.Context, req provider.UpdateFirewallRuleGroupRequest) (string, error) { - sg, err := p.getSecurityGroup(ctx, req.FirewallRuleGroupID) + securityGroup, err = p.getSecurityGroup( + ctx, + Managed(), + WithVPCID(aws.StringValue(instance.VpcId)), + WithNodeName(nodeName), + WithSecurityGroupID(securityGroupID), + ) + if err != nil { + return status, err + } + } + securityGroupID := aws.StringValue(securityGroup.GroupId) + status.State = v1alpha1.FirewallRuleStateReserved + status.FirewallRuleID = securityGroup.GroupId + + // Get the first network interface with a public IP address + networkInterface, err := eniWithPublicIP(instance) if err != nil { - return "", converter.DecodeEC2Error("failed to get security group", err) + return status, err + } + + // Get all network interfaces associated with the security group + networkInterfaces, err := p.getNetworkInterfaces(ctx, securityGroupID) + if err != nil { + return status, converter.DecodeEC2Error("failed to list network interfaces", err) + } + + isAssociated := false + for _, ni := range networkInterfaces { + if aws.StringValue(ni.NetworkInterfaceId) == aws.StringValue(networkInterface.NetworkInterfaceId) { + isAssociated = true + continue + } + + // Disassociate the security group from other network interfaces + groups := []string{} + for _, group := range ni.Groups { + if aws.StringValue(group.GroupId) != securityGroupID { + groups = append(groups, aws.StringValue(group.GroupId)) + } + } + _, err = p.ec2.ModifyNetworkInterfaceAttribute(ctx, &ec2.ModifyNetworkInterfaceAttributeInput{ + NetworkInterfaceId: ni.NetworkInterfaceId, + Groups: groups, + }) + if err != nil { + return status, converter.DecodeEC2Error("failed to modify network interface attribute", err) + } + } + + if !isAssociated { + // Associate the security group with the network interface + // Disassociate the security group from other network interfaces + groups := []string{} + for _, group := range networkInterface.Groups { + if aws.StringValue(group.GroupId) != securityGroupID { + groups = append(groups, aws.StringValue(group.GroupId)) + } + } + groups = append(groups, securityGroupID) + _, err = p.ec2.ModifyNetworkInterfaceAttribute(ctx, &ec2.ModifyNetworkInterfaceAttributeInput{ + NetworkInterfaceId: networkInterface.NetworkInterfaceId, + Groups: groups, + }) + if err != nil { + return status, converter.DecodeEC2Error("failed to modify network interface attribute", err) + } } + status.InstanceID = &instanceID + status.NetworkInterfaceID = networkInterface.NetworkInterfaceId + status.State = v1alpha1.FirewallRuleStateAssociated + + frSpecs := provider.EncodeFirewallRuleSpecs(firewallRules) // Apply Ingress permissions reconciliation if err := provider.ReconcilePermissions( ctx, - req.FirewallRuleGroupID, + securityGroupID, p.authorizeSecurityGroupIngress, p.revokeSecurityGroupIngress, - provider.GetIngressIPPermissions(req.FirewallRules), - converter.DecodeIpPermissions(sg.IpPermissions), + provider.GetIngressIPPermissions(frSpecs), + converter.DecodeIpPermissions(securityGroup.IpPermissions), ); err != nil { - return "", converter.DecodeEC2Error("failed to apply security group ingress permissions", err) + return status, converter.DecodeEC2Error("failed to apply security group ingress permissions", err) } + // Apply Egress permissions reconciliation if err := provider.ReconcilePermissions( ctx, - req.FirewallRuleGroupID, + securityGroupID, p.authorizeSecurityGroupEgress, p.revokeSecurityGroupEgress, - provider.GetEgressIPPermissions(req.FirewallRules), - converter.DecodeIpPermissions(sg.IpPermissionsEgress), + provider.GetEgressIPPermissions(frSpecs), + converter.DecodeIpPermissions(securityGroup.IpPermissionsEgress), ); err != nil { - return "", converter.DecodeEC2Error("failed to apply security group egress permissions", err) + return status, converter.DecodeEC2Error("failed to apply security group egress permissions", err) } - return req.FirewallRuleGroupID, nil + return status, nil } -func (p *awsProvider) DeleteFirewallRule(ctx context.Context, firewallRuleID string) error { - _, err := p.ec2.DeleteSecurityGroup(&ec2.DeleteSecurityGroupInput{ - GroupId: aws.String(firewallRuleID), - }) +// ReconcileFirewallRulesDeletion reconciles the deletion of firewall rules for a given node. +// It retrieves the security group associated with the instance, disassociates it from all network interfaces, +// and then deletes the security group. +// +// Parameters: +// - ctx: The context for the operation. +// - nodeName: The name of the node for which to reconcile firewall rules deletion. +// +// Returns: +// - error: An error if the reconciliation fails, otherwise nil. +func (p *awsProvider) ReconcileFirewallRulesDeletion(ctx context.Context, nodeName string) error { + // Get the security group associated with the instance + securityGroup, err := p.getSecurityGroup(ctx, Managed(), WithNodeName(nodeName)) if err != nil { - return converter.DecodeEC2Error("failed to delete security group", err) + // The security group does not exist, end of reconciliation + if err.(*provider.Error).Code != provider.NotFoundError { + return nil + } + return err } + securityGroupID := aws.StringValue(securityGroup.GroupId) - return nil -} - -func (p *awsProvider) authorizeSecurityGroupIngress(ctx context.Context, firewallRuleID string, req provider.IPPermission) error { - _, err := p.ec2.AuthorizeSecurityGroupIngress(&ec2.AuthorizeSecurityGroupIngressInput{ - GroupId: aws.String(firewallRuleID), - IpPermissions: []*ec2.IpPermission{ - converter.EncodeIPPermission(req), - }, - }) + // Get all network interfaces associated with the security group + networkInterfaces, err := p.getNetworkInterfaces(ctx, securityGroupID) if err != nil { - return converter.DecodeEC2Error("failed to authorize security group ingress permission", err) + return err } - return nil -} + for _, ni := range networkInterfaces { + // Disassociate the security group from all network interfaces + groups := []string{} + for _, group := range ni.Groups { + if aws.StringValue(group.GroupId) != securityGroupID { + groups = append(groups, aws.StringValue(group.GroupId)) + } + } + _, err = p.ec2.ModifyNetworkInterfaceAttribute(ctx, &ec2.ModifyNetworkInterfaceAttributeInput{ + NetworkInterfaceId: ni.NetworkInterfaceId, + Groups: groups, + }) + if err != nil { + return converter.DecodeEC2Error("failed to modify network interface attribute", err) + } + } -func (p *awsProvider) revokeSecurityGroupIngress(ctx context.Context, firewallRuleID string, req provider.IPPermission) error { - _, err := p.ec2.RevokeSecurityGroupIngress(&ec2.RevokeSecurityGroupIngressInput{ - GroupId: aws.String(firewallRuleID), - IpPermissions: []*ec2.IpPermission{ - converter.EncodeIPPermission(req), - }, - }) - if err != nil { - return converter.DecodeEC2Error("failed to revoke security group ingress permission", err) + if err := p.deleteSecurityGroup(ctx, securityGroupID); err != nil { + return err } return nil } -func (p *awsProvider) authorizeSecurityGroupEgress(ctx context.Context, firewallRuleID string, req provider.IPPermission) error { - _, err := p.ec2.AuthorizeSecurityGroupEgress(&ec2.AuthorizeSecurityGroupEgressInput{ - GroupId: aws.String(firewallRuleID), - IpPermissions: []*ec2.IpPermission{ - converter.EncodeIPPermission(req), - }, - }) - if err != nil { - return converter.DecodeEC2Error("failed to authorize security group egress permission", err) +// ReconcileExternalIP ensures that the external IP is correctly associated with the given instance. +// If the external IP does not exist, it will be created. If the instance ID is empty, the external IP +// will be disassociated from any network interface it is currently associated with. +// +// Parameters: +// - ctx: The context for the operation. +// - instanceID: The ID of the instance to associate the external IP with. If empty, the external IP +// will be disassociated from any network interface. +// - externalIP: The external IP object to reconcile. +// +// Returns: +// - v1alpha1.ExternalIPStatus: The status of the external IP after reconciliation. +// - error: Any error encountered during the reconciliation process. +func (p *awsProvider) ReconcileExternalIP( + ctx context.Context, + instanceID string, + externalIP *v1alpha1.ExternalIP, +) (v1alpha1.ExternalIPStatus, error) { + status := externalIP.Status + + // Get the address associated with the instance + address, err := p.getAddress(ctx, Managed(), WithExternalIPName(externalIP.Name)) + if err != nil && err.(*provider.Error).Code != provider.NotFoundError { + return status, err + } + + if address == nil { + addressID, err := p.createAddress(ctx, externalIP.Name, instanceID) + if err != nil { + return status, err + } + + address, err = p.getAddress(ctx, Managed(), WithExternalIPName(externalIP.Name), WithAddressID(addressID)) + if err != nil { + return status, err + } } + status.State = v1alpha1.ExternalIPStateReserved + status.AddressID = address.AllocationId + status.PublicIPAddress = address.PublicIp - return nil -} + if instanceID == "" { + if address.AssociationId == nil { + return status, nil + } -func (p *awsProvider) revokeSecurityGroupEgress(ctx context.Context, firewallRuleID string, req provider.IPPermission) error { - _, err := p.ec2.RevokeSecurityGroupEgress(&ec2.RevokeSecurityGroupEgressInput{ - GroupId: aws.String(firewallRuleID), - IpPermissions: []*ec2.IpPermission{ - converter.EncodeIPPermission(req), - }, - }) - if err != nil { - return converter.DecodeEC2Error("failed to revoke security group egress permission", err) + // Disassociate the address from the current network interface + if err := p.disassociateAddress(ctx, *address.AssociationId); err != nil { + return status, err + } + status.InstanceID = nil + status.State = v1alpha1.ExternalIPStateReserved + return status, nil } - return nil -} + // Get the instance + instance, err := p.getInstance(ctx, instanceID) + if err != nil { + return status, err + } -func (p *awsProvider) AssociateFirewallRule(ctx context.Context, req provider.AssociateFirewallRuleRequest) error { - res, err := p.ec2.DescribeNetworkInterfaces(&ec2.DescribeNetworkInterfacesInput{ - NetworkInterfaceIds: aws.StringSlice([]string{req.NetworkInterfaceID}), - }) + // Get the first network interface with a public IP address + networkInterface, err := eniWithPublicIP(instance) if err != nil { - return err + return status, err } - if len(res.NetworkInterfaces) == 0 { - return &provider.Error{ - Code: provider.NotFoundError, - Msg: fmt.Sprintf("failed to associate security group: network interface with id %s not found", req.NetworkInterfaceID), + if address.NetworkInterfaceId != nil { + // Address is already associated with the instance + if *address.NetworkInterfaceId == *networkInterface.NetworkInterfaceId { + return status, nil } - } - networkInterface := res.NetworkInterfaces[0] - groups := []*string{} - for _, e := range networkInterface.Groups { - if req.FirewallRuleID != *e.GroupId { - groups = append(groups, e.GroupId) + // Disassociate the address from the current network interface + if err := p.disassociateAddress(ctx, *address.AssociationId); err != nil { + return status, err } + status.InstanceID = nil + status.State = v1alpha1.ExternalIPStateReserved } - groups = append(groups, aws.String(req.FirewallRuleID)) - _, err = p.ec2.ModifyNetworkInterfaceAttribute(&ec2.ModifyNetworkInterfaceAttributeInput{ - Groups: groups, - NetworkInterfaceId: aws.String(req.NetworkInterfaceID), - }) + // Associate the address with the network interface + if err := p.associateAddress(ctx, aws.StringValue(address.AllocationId), *networkInterface.NetworkInterfaceId); err != nil { + return status, err + } + status.InstanceID = &instanceID + status.State = v1alpha1.ExternalIPStateAssociated - return err + return status, nil } -func (p *awsProvider) DisassociateFirewallRule(ctx context.Context, req provider.AssociateFirewallRuleRequest) error { - res, err := p.ec2.DescribeNetworkInterfaces(&ec2.DescribeNetworkInterfacesInput{ - NetworkInterfaceIds: aws.StringSlice([]string{req.NetworkInterfaceID}), - }) +// ReconcileExternalIPDeletion handles the deletion of an external IP address in AWS. +// If the address is associated with a network interface, it disassociates the address +// before deleting it. +// +// Parameters: +// - ctx: The context for managing request deadlines and cancellation signals. +// - externalIP: The ExternalIP resource to be deleted. +// +// Returns: +// - error: An error if the reconciliation fails, otherwise nil. +func (p *awsProvider) ReconcileExternalIPDeletion(ctx context.Context, externalIP *v1alpha1.ExternalIP) error { + // Get the address associated with the instance + address, err := p.getAddress(ctx, Managed(), WithExternalIPName(externalIP.Name)) if err != nil { - return converter.DecodeEC2Error("failed to disassociate security group", err) - } - - if len(res.NetworkInterfaces) == 0 { - return &provider.Error{ - Code: provider.NotFoundError, - Msg: fmt.Sprintf("failed to disassociate security group: network interface with id %s not found", req.NetworkInterfaceID), + // The address does not exist, end of reconciliation + if err.(*provider.Error).Code != provider.NotFoundError { + return nil } + return err } - networkInterface := res.NetworkInterfaces[0] - groups := []*string{} - for _, e := range networkInterface.Groups { - if req.FirewallRuleID != aws.StringValue(e.GroupId) { - groups = append(groups, e.GroupId) + if address.AssociationId != nil { + // Disassociate the address from the network interface + if err := p.disassociateAddress(ctx, *address.AssociationId); err != nil { + return err } } - _, err = p.ec2.ModifyNetworkInterfaceAttribute(&ec2.ModifyNetworkInterfaceAttributeInput{ - Groups: groups, - NetworkInterfaceId: aws.String(req.NetworkInterfaceID), - }) - - return err + return p.deleteAddress(ctx, aws.StringValue(address.AllocationId)) } diff --git a/internal/provider/model.go b/internal/provider/model.go index 19ce2d3..7dbee3f 100644 --- a/internal/provider/model.go +++ b/internal/provider/model.go @@ -1,56 +1,7 @@ // Package provider contains the cloud providers related interfaces and models. package provider -// Instance is a cloud provider compute instance. -type Instance struct { - // The ID of the instance. - InstanceID string - - // The ID of the VPC in which the instance is running. - VpcID string - - // The network interfaces for the instance. - NetworkInterfaces []*NetworkInterface -} - -// NetworkInterface describes a network interface. -type NetworkInterface struct { - // The ID of the network interface. - NetworkInterfaceID string - - // The public IP address bound to the network interface. - PublicIP *string - - // DeviceID of the network interface. - DeviceID *int64 -} - -// Describes an external IP address. -type Address struct { - // The ID of the address. - AddressID string - - // The ID representing the association of the address with a network interface - AssociationID *string - - // The address public IP. - PublicIP string -} - -// AssociateAddressRequest wraps parameters required to associate an Address to a Network interface. -type AssociateAddressRequest struct { - // The ID of the address. - AddressID string - - // The ID of the network interface that the address is associated with. - NetworkInterfaceID string -} - -// DisassociateAddressRequest wraps parameters required to disassociate an Address to a Network interface. -type DisassociateAddressRequest struct { - // The association identifier. - AssociationID string -} +import "github.com/quortex/kubestatic/api/v1alpha1" // Direction describes the traffic direction. // Ingress applies to incoming traffic. Egress applies to outbound traffic. @@ -109,68 +60,75 @@ type FirewallRuleSpec struct { IPPermission *IPPermission } -// FirewallRule describes a set of permissions for a firewall. -type FirewallRule struct { - // The ID of the firewall rule. - FirewallRuleID string - - // The ID of the VPC. - VpcID string - - FirewallRuleSpec -} - -// FirewallRuleGroup describes a group of firewall rules. -type FirewallRuleGroup struct { - // The name of the firewall rule group. - Name string - - // A description for the firewall rule group. This is informational only. - Description string +// UpdateFirewallRuleRequest wraps parameters required to update a firewall rule group. +type UpdateFirewallRuleGroupRequest struct { + // The ID of the firewall rule group. + FirewallRuleGroupID string // The FirewallRules list. FirewallRules []FirewallRuleSpec } -// CreateFirewallRuleRequest wraps parameters required to create a firewall rule. -type CreateFirewallRuleRequest struct { - FirewallRuleSpec +// EncodeFirewallRuleSpecs converts an api FirewallRule slice to a FirewallRuleSpec slice. +func EncodeFirewallRuleSpecs(data []v1alpha1.FirewallRule) []FirewallRuleSpec { + if data == nil { + return make([]FirewallRuleSpec, 0) + } + + res := make([]FirewallRuleSpec, len(data)) + for i, e := range data { + res[i] = encodeFirewallRuleSpec(&e) + } + return res } -// CreateFirewallRuleGroupRequest wraps parameters required to create a firewall rule group. -type CreateFirewallRuleGroupRequest struct { - // The name of the firewall rule group. - Name string - - // A description for the firewall rule group. This is informational only. - Description string - - // The FirewallRules list. - FirewallRules []FirewallRuleSpec +// encodeFirewallRuleSpec converts an api FirewallRule to a FirewallRuleSpec. +func encodeFirewallRuleSpec(data *v1alpha1.FirewallRule) FirewallRuleSpec { + return FirewallRuleSpec{ + Name: data.Name, + Description: data.Spec.Description, + Direction: encodeDirection(data.Spec.Direction), + IPPermission: &IPPermission{ + FromPort: data.Spec.FromPort, + Protocol: data.Spec.Protocol, + IPRanges: encodeIPRanges(data.Spec.IPRanges), + ToPort: data.Spec.ToPort, + }, + } } -// UpdateFirewallRuleRequest wraps parameters required to update a firewall rule. -type UpdateFirewallRuleRequest struct { - FirewallRuleSpec +// encodeIPRange converts an api IPRange to an IPRange. +func encodeIPRange(data *v1alpha1.IPRange) *IPRange { + if data == nil { + return nil + } - // The ID of the firewall rule. - FirewallRuleID string + return &IPRange{ + CIDR: data.CIDR, + Description: data.Description, + } } -// UpdateFirewallRuleRequest wraps parameters required to update a firewall rule group. -type UpdateFirewallRuleGroupRequest struct { - // The ID of the firewall rule group. - FirewallRuleGroupID string - - // The FirewallRules list. - FirewallRules []FirewallRuleSpec +// encodeIPRange converts an api IPRange slice to an IPRange slice. +func encodeIPRanges(data []*v1alpha1.IPRange) []*IPRange { + if data == nil { + return make([]*IPRange, 0) + } + + res := make([]*IPRange, len(data)) + for i, e := range data { + res[i] = encodeIPRange(e) + } + return res } -// AssociateFirewallRuleRequest wraps parameters required to associate a firewall rule to a Network interface. -type AssociateFirewallRuleRequest struct { - // The ID of the firewall rule. - FirewallRuleID string - - // The ID of the network interface that the firewall rule is associated with. - NetworkInterfaceID string +// encodeDirection converts an api Direction to a Direction. +func encodeDirection(data v1alpha1.Direction) Direction { + switch data { + case v1alpha1.DirectionEgress: + return DirectionEgress + case v1alpha1.DirectionIngress: + return DirectionIngress + } + return Direction("") } diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 3f9d06f..129c476 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -5,34 +5,20 @@ import ( "context" corev1 "k8s.io/api/core/v1" + + "github.com/quortex/kubestatic/api/v1alpha1" ) // Provider describes a cloud provider type Provider interface { Client GetInstanceID(corev1.Node) string - // HasGroupedFirewallRules describes wether firewall rule groups are - // supported by the provider or not (e.g. AWS SecurityGroups). - HasGroupedFirewallRules() bool } // The necessary methods for a provider client are described here. -// According of the Provider.HasGroupedFirewallRules implementation, -// one of the CreateFirewallRule / CreateFirewallRuleGroup and -// UpdateFirewallRule / UpdateFirewallRuleGroup methods must be implemented. type Client interface { - GetInstance(ctx context.Context, instanceID string) (*Instance, error) - GetAddress(ctx context.Context, addressID string) (*Address, error) - CreateAddress(ctx context.Context) (*Address, error) - DeleteAddress(ctx context.Context, addressID string) error - AssociateAddress(ctx context.Context, req AssociateAddressRequest) error - DisassociateAddress(ctx context.Context, req DisassociateAddressRequest) error - FetchFirewallRule(ctx context.Context, firewallRuleGroupID string) error - CreateFirewallRule(ctx context.Context, req CreateFirewallRuleRequest) (string, error) - CreateFirewallRuleGroup(ctx context.Context, req CreateFirewallRuleGroupRequest) (string, error) - UpdateFirewallRule(ctx context.Context, req UpdateFirewallRuleRequest) (*FirewallRule, error) - UpdateFirewallRuleGroup(ctx context.Context, req UpdateFirewallRuleGroupRequest) (string, error) - DeleteFirewallRule(ctx context.Context, firewallRuleID string) error - AssociateFirewallRule(ctx context.Context, req AssociateFirewallRuleRequest) error - DisassociateFirewallRule(ctx context.Context, req AssociateFirewallRuleRequest) error + ReconcileFirewallRules(ctx context.Context, nodeName, instanceID string, firewallRules []v1alpha1.FirewallRule) (v1alpha1.FirewallRuleStatus, error) + ReconcileFirewallRulesDeletion(ctx context.Context, nodeName string) error + ReconcileExternalIP(ctx context.Context, instanceID string, externalIP *v1alpha1.ExternalIP) (v1alpha1.ExternalIPStatus, error) + ReconcileExternalIPDeletion(ctx context.Context, externalIP *v1alpha1.ExternalIP) error } diff --git a/internal/provider/utils.go b/internal/provider/utils.go index 2295d41..bdd228c 100644 --- a/internal/provider/utils.go +++ b/internal/provider/utils.go @@ -8,7 +8,7 @@ import ( ) // ReconcilePermissions perform create / delete on given permissions -// to to reach the desired state of firewall rules. +// to reach the desired state of firewall rules. func ReconcilePermissions( ctx context.Context, firewallRuleID string, diff --git a/internal/utils/utils.go b/internal/utils/utils.go new file mode 100644 index 0000000..0d81617 --- /dev/null +++ b/internal/utils/utils.go @@ -0,0 +1,17 @@ +package utils + +import ( + "math/rand" + "time" +) + +const charset = "abcdefghijklmnopqrstuvwxyz" + +func RandomString(length int) string { + seededRand := rand.New(rand.NewSource(time.Now().UnixNano())) + b := make([]byte, length) + for i := range b { + b[i] = charset[seededRand.Intn(len(charset))] + } + return string(b) +}