diff --git a/.codespellignore b/.codespellignore index ce223b56..202f388b 100644 --- a/.codespellignore +++ b/.codespellignore @@ -1,3 +1,5 @@ capi capic decorder +reterr +ionos \ No newline at end of file diff --git a/api/v1alpha1/ionoscloudcluster_types.go b/api/v1alpha1/ionoscloudcluster_types.go index 559ccdcc..fe3b341c 100644 --- a/api/v1alpha1/ionoscloudcluster_types.go +++ b/api/v1alpha1/ionoscloudcluster_types.go @@ -54,8 +54,12 @@ type IonosCloudClusterStatus struct { // Conditions defines current service state of the IonosCloudCluster. // +optional Conditions clusterv1.Conditions `json:"conditions,omitempty"` + + PendingRequests PendingRequests `json:"PendingRequests,omitEmpty"` } +type PendingRequests map[string]*ProvisioningRequest + //+kubebuilder:object:root=true //+kubebuilder:subresource:status //+kubebuilder:printcolumn:name="Cluster",type="string",JSONPath=".metadata.labels['cluster\\.x-k8s\\.io/cluster-name']",description="Cluster" diff --git a/api/v1alpha1/ionoscloudmachine_types.go b/api/v1alpha1/ionoscloudmachine_types.go index a6e550b5..1d7ebc99 100644 --- a/api/v1alpha1/ionoscloudmachine_types.go +++ b/api/v1alpha1/ionoscloudmachine_types.go @@ -185,10 +185,6 @@ type IonosCloudMachineStatus struct { // Conditions defines current service state of the IonosCloudMachine. // +optional Conditions clusterv1.Conditions `json:"conditions,omitempty"` - - // CurrentRequest shows the current provisioning request for any - // cloud resource, that is being created. - CurrentRequest *ProvisioningRequest `json:"currentRequest,omitempty"` } //+kubebuilder:object:root=true diff --git a/hack/boilerplate.go.txt b/hack/boilerplate.go.txt index cc3f609d..7ecb9d36 100644 --- a/hack/boilerplate.go.txt +++ b/hack/boilerplate.go.txt @@ -1,5 +1,5 @@ /* -Copyright 2023 IONOS Cloud. +Copyright 2023-2024 IONOS Cloud. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/internal/controller/ionoscloudmachine_controller.go b/internal/controller/ionoscloudmachine_controller.go index 30003e1b..942655b6 100644 --- a/internal/controller/ionoscloudmachine_controller.go +++ b/internal/controller/ionoscloudmachine_controller.go @@ -18,6 +18,15 @@ package controller import ( "context" + "errors" + "fmt" + "github.com/go-logr/logr" + "github.com/ionos-cloud/cluster-api-provider-ionoscloud/pkg/scope" + sdk "github.com/ionos-cloud/sdk-go/v6" + "k8s.io/klog/v2" + "k8s.io/utils/pointer" + "net/http" + "sigs.k8s.io/cluster-api/util/annotations" clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" "sigs.k8s.io/cluster-api/util" @@ -52,10 +61,9 @@ type IonosCloudMachineReconciler struct { // // For more details, check Reconcile and its Result here: // - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.16.0/pkg/reconcile -func (r *IonosCloudMachineReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { - _ = ctrl.LoggerFrom(ctx) +func (r *IonosCloudMachineReconciler) Reconcile(ctx context.Context, req ctrl.Request) (_ ctrl.Result, reterr error) { + logger := ctrl.LoggerFrom(ctx) - // TODO(user): your logic here ionosCloudMachine := &infrav1.IonosCloudMachine{} if err := r.Client.Get(ctx, req.NamespacedName, ionosCloudMachine); err != nil { if apierrors.IsNotFound(err) { @@ -63,6 +71,93 @@ func (r *IonosCloudMachineReconciler) Reconcile(ctx context.Context, req ctrl.Re } return ctrl.Result{}, err } + + // Fetch the Machine. + machine, err := util.GetOwnerMachine(ctx, r.Client, ionosCloudMachine.ObjectMeta) + if err != nil { + return ctrl.Result{}, err + } + if machine == nil { + logger.Info("machine controller has not yet set OwnerRef") + return ctrl.Result{}, nil + } + + logger = logger.WithValues("machine", klog.KObj(machine)) + + // Fetch the Cluster. + cluster, err := util.GetClusterFromMetadata(ctx, r.Client, machine.ObjectMeta) + if err != nil { + logger.Info("machine is missing cluster label or cluster does not exist") + return ctrl.Result{}, nil + } + + if annotations.IsPaused(cluster, ionosCloudMachine) { + logger.Info("ionos cloud machine or linked cluster is marked as paused, not reconciling") + return ctrl.Result{}, nil + } + + logger = logger.WithValues("cluster", klog.KObj(cluster)) + + infraCluster, err := r.getInfraCluster(ctx, &logger, cluster, ionosCloudMachine) + if err != nil { + return ctrl.Result{}, fmt.Errorf("error getting infra provider cluster or control plane object: %w", err) + } + if infraCluster == nil { + logger.Info("ionos cloud machine is not ready yet") + return ctrl.Result{}, nil + } + + // Create the machine scope + machineScope, err := scope.NewMachineScope(scope.MachineScopeParams{ + Client: r.Client, + Cluster: cluster, + Machine: machine, + InfraCluster: infraCluster, + IonosCloudMachine: ionosCloudMachine, + Logger: &logger, + }) + if err != nil { + logger.Error(err, "failed to create scope") + return ctrl.Result{}, err + } + + //// Always close the scope when exiting this function, so we can persist any ProxmoxMachine changes. + //defer func() { + // if err := machineScope.Close(); err != nil && reterr == nil { + // reterr = err + // } + //}() + + if !ionosCloudMachine.ObjectMeta.DeletionTimestamp.IsZero() { + return r.reconcileDelete(ctx, machineScope) + } + + return r.reconcileNormal(ctx, machineScope) +} + +func (r *IonosCloudMachineReconciler) reconcileNormal( + ctx context.Context, machineScope *scope.MachineScope, +) (ctrl.Result, error) { + lan, err := r.reconcileLAN(ctx, machineScope) + if err != nil { + return ctrl.Result{}, fmt.Errorf("could not ensure lan: %w", err) + } + if lan == nil { + return ctrl.Result{Requeue: true}, nil + } + return ctrl.Result{}, nil +} + +func (r *IonosCloudMachineReconciler) reconcileDelete( + ctx context.Context, machineScope *scope.MachineScope, +) (ctrl.Result, error) { + shouldProceed, err := r.reconcileLANDelete(ctx, machineScope) + if err != nil { + return ctrl.Result{}, fmt.Errorf("could not ensure lan: %w", err) + } + if err == nil && !shouldProceed { + return ctrl.Result{Requeue: true}, nil + } return ctrl.Result{}, nil } @@ -75,3 +170,208 @@ func (r *IonosCloudMachineReconciler) SetupWithManager(mgr ctrl.Manager) error { handler.EnqueueRequestsFromMapFunc(util.MachineToInfrastructureMapFunc(infrav1.GroupVersion.WithKind(infrav1.IonosCloudMachineType)))). Complete(r) } + +func (r *IonosCloudMachineReconciler) getInfraCluster( + ctx context.Context, logger *logr.Logger, cluster *clusterv1.Cluster, ionosCloudMachine *infrav1.IonosCloudMachine, +) (*scope.ClusterScope, error) { + var clusterScope *scope.ClusterScope + var err error + + ionosCloudCluster := &infrav1.IonosCloudCluster{} + + infraClusterName := client.ObjectKey{ + Namespace: ionosCloudMachine.Namespace, + Name: cluster.Spec.InfrastructureRef.Name, + } + + if err := r.Client.Get(ctx, infraClusterName, ionosCloudCluster); err != nil { + // IonosCloudCluster is not ready + return nil, nil //nolint:nilerr + } + + // Create the cluster scope + clusterScope, err = scope.NewClusterScope(scope.ClusterScopeParams{ + Client: r.Client, + Logger: logger, + Cluster: cluster, + IonosCluster: ionosCloudCluster, + IonosClient: r.IonosCloudClient, + }) + if err != nil { + return nil, fmt.Errorf("failed to creat cluster scope: %w", err) + } + + return clusterScope, nil +} + +const lanFormatString = "%s-k8s-lan" + +func (r *IonosCloudMachineReconciler) reconcileLAN( + ctx context.Context, machineScope *scope.MachineScope, +) (*sdk.Lan, error) { + logger := machineScope.Logger + dataCenterID := machineScope.IonosCloudMachine.Spec.DatacenterID + ionos := r.IonosCloudClient + clusterScope := machineScope.ClusterScope + clusterName := clusterScope.Cluster.Name + var err error + var lan *sdk.Lan + + // try to find available LAN + lan, err = r.findLANWithinDatacenterLANs(ctx, machineScope) + if err != nil { + return nil, fmt.Errorf("could not search for LAN within LAN list: %w", err) + } + if lan == nil { + // check if there is a provisioning request + reqStatus, err := r.checkProvisioningRequest(ctx, machineScope) + if err != nil && reqStatus == "" { + return nil, fmt.Errorf("could not check status of provisioning request: %w", err) + } + if reqStatus != "" { + req := clusterScope.IonosCluster.Status.PendingRequests[dataCenterID] + l := logger.WithValues( + "requestURL", req.RequestPath, + "requestMethod", req.Method, + "requestStatus", req.State) + switch reqStatus { + case string(infrav1.RequestStatusFailed): + delete(clusterScope.IonosCluster.Status.PendingRequests, dataCenterID) + return nil, fmt.Errorf("provisioning request has failed: %w", err) + case string(infrav1.RequestStatusQueued), string(infrav1.RequestStatusRunning): + l.Info("provisioning request hasn't finished yet. trying again later.") + return nil, nil + case string(infrav1.RequestStatusDone): + lan, err = r.findLANWithinDatacenterLANs(ctx, machineScope) + if err != nil { + return nil, fmt.Errorf("could not search for lan within lan list: %w", err) + } + if lan == nil { + l.Info("pending provisioning request has finished, but lan could not be found. trying again later.") + return nil, nil + } + } + } + } else { + return lan, nil + } + // request LAN creation + requestURL, err := ionos.CreateLAN(ctx, dataCenterID, sdk.LanPropertiesPost{ + Name: pointer.String(fmt.Sprintf(lanFormatString, clusterName)), + Public: pointer.Bool(true), + }) + if err != nil { + return nil, fmt.Errorf("could not create a new LAN: %w ", err) + } + clusterScope.IonosCluster.Status.PendingRequests[dataCenterID] = &infrav1.ProvisioningRequest{Method: requestURL} + logger.WithValues("requestURL", requestURL).Info("new LAN creation was requested") + + return nil, nil +} + +func (r *IonosCloudMachineReconciler) findLANWithinDatacenterLANs( + ctx context.Context, machineScope *scope.MachineScope, +) (lan *sdk.Lan, err error) { + dataCenterID := machineScope.IonosCloudMachine.Spec.DatacenterID + ionos := r.IonosCloudClient + clusterScope := machineScope.ClusterScope + clusterName := clusterScope.Cluster.Name + + lans, err := ionos.ListLANs(ctx, dataCenterID) + if err != nil { + return nil, fmt.Errorf("could not list lans: %w", err) + } + if lans.Items != nil { + for _, lan := range *(lans.Items) { + if name := lan.Properties.Name; name != nil && *name == fmt.Sprintf(lanFormatString, clusterName) { + return &lan, nil + } + } + } + return nil, nil +} + +func (r *IonosCloudMachineReconciler) checkProvisioningRequest( + ctx context.Context, machineScope *scope.MachineScope, +) (string, error) { + clusterScope := machineScope.ClusterScope + ionos := r.IonosCloudClient + dataCenterID := machineScope.IonosCloudMachine.Spec.DatacenterID + request, requestExists := clusterScope.IonosCluster.Status.PendingRequests[dataCenterID] + + if requestExists { + reqStatus, err := ionos.CheckRequestStatus(ctx, request.RequestPath) + if err != nil { + return "", fmt.Errorf("could not check status of provisioning request: %w", err) + } + clusterScope.IonosCluster.Status.PendingRequests[dataCenterID].State = infrav1.RequestStatus(*reqStatus.Metadata.Status) + clusterScope.IonosCluster.Status.PendingRequests[dataCenterID].Message = *reqStatus.Metadata.Message + if *reqStatus.Metadata.Status != sdk.RequestStatusDone { + if metadata := *reqStatus.Metadata; *metadata.Status == sdk.RequestStatusFailed { + return sdk.RequestStatusFailed, errors.New(*metadata.Message) + } + return *reqStatus.Metadata.Status, nil + } + } + return "", nil +} + +func (r *IonosCloudMachineReconciler) reconcileLANDelete(ctx context.Context, machineScope *scope.MachineScope) (bool, error) { + logger := machineScope.Logger + clusterScope := machineScope.ClusterScope + dataCenterID := machineScope.IonosCloudMachine.Spec.DatacenterID + lan, err := r.findLANWithinDatacenterLANs(ctx, machineScope) + if err != nil { + return false, fmt.Errorf("error while trying to find lan: %w", err) + } + // Check if there is a provisioning request going on + if lan != nil { + reqStatus, err := r.checkProvisioningRequest(ctx, machineScope) + if err != nil && reqStatus == "" { + return false, fmt.Errorf("could not check status of provisioning request: %w", err) + } + if reqStatus != "" { + req := clusterScope.IonosCluster.Status.PendingRequests[dataCenterID] + l := logger.WithValues( + "requestURL", req.RequestPath, + "requestMethod", req.Method, + "requestStatus", req.State) + switch reqStatus { + case string(infrav1.RequestStatusFailed): + delete(clusterScope.IonosCluster.Status.PendingRequests, dataCenterID) + return false, fmt.Errorf("provisioning request has failed: %w", err) + case string(infrav1.RequestStatusQueued), string(infrav1.RequestStatusRunning): + l.Info("provisioning request hasn't finished yet. trying again later.") + return false, nil + case string(infrav1.RequestStatusDone): + lan, err = r.findLANWithinDatacenterLANs(ctx, machineScope) + if err != nil { + return false, fmt.Errorf("could not search for lan within lan list: %w", err) + } + if lan != nil { + l.Info("pending provisioning request has finished, but lan could still be found. trying again later.") + return false, nil + } + } + } + } + if lan == nil { + logger.Info("lan seems to be deleted.") + return true, nil + } + if lan.Entities.HasNics() { + logger.Info("lan seems like it is still being used. let whoever still uses it delete it.") + // NOTE: the LAN isn't deleted, but we can use the bool to signalize that we can proceed with the machine deletion. + return true, nil + } + requestURL, err := r.IonosCloudClient.DestroyLAN(ctx, dataCenterID, *lan.Id) + if err != nil { + return false, fmt.Errorf("could not destroy lan: %w", err) + } + machineScope.ClusterScope.IonosCluster.Status.PendingRequests[dataCenterID] = &infrav1.ProvisioningRequest{ + Method: http.MethodDelete, + RequestPath: requestURL, + } + logger.WithValues("requestURL", requestURL).Info("requested LAN deletion") + return false, nil +} diff --git a/internal/ionoscloud/client.go b/internal/ionoscloud/client.go index 7fb08f05..469861cd 100644 --- a/internal/ionoscloud/client.go +++ b/internal/ionoscloud/client.go @@ -40,8 +40,7 @@ type Client interface { // DestroyServer deletes the server that matches the provided serverID in the specified data center. DestroyServer(ctx context.Context, dataCenterID, serverID string) error // CreateLAN creates a new LAN with the provided properties in the specified data center. - CreateLAN(ctx context.Context, dataCenterID string, properties ionoscloud.LanPropertiesPost) ( - *ionoscloud.LanPost, error) + CreateLAN(ctx context.Context, dataCenterID string, properties ionoscloud.LanPropertiesPost) (string, error) // UpdateLAN updates a LAN with the provided properties in the specified data center. UpdateLAN(ctx context.Context, dataCenterID string, lanID string, properties ionoscloud.LanProperties) ( *ionoscloud.Lan, error) @@ -53,7 +52,9 @@ type Client interface { // GetLAN returns the LAN that matches lanID in the specified data center. GetLAN(ctx context.Context, dataCenterID, lanID string) (*ionoscloud.Lan, error) // DestroyLAN deletes the LAN that matches the provided lanID in the specified data center. - DestroyLAN(ctx context.Context, dataCenterID, lanID string) error + DestroyLAN(ctx context.Context, dataCenterID, lanID string) (string, error) + // CheckRequestStatus checks the status of a provided request identified by requestID + CheckRequestStatus(ctx context.Context, requestID string) (*ionoscloud.RequestStatus, error) // ListVolumes returns a list of volumes in a specified data center. ListVolumes(ctx context.Context, dataCenterID string) (*ionoscloud.Volumes, error) // GetVolume returns the volume that matches volumeID in the specified data center. diff --git a/internal/ionoscloud/client/client.go b/internal/ionoscloud/client/client.go index eab70d40..3844dbe8 100644 --- a/internal/ionoscloud/client/client.go +++ b/internal/ionoscloud/client/client.go @@ -143,20 +143,23 @@ func (c *IonosCloudClient) DestroyServer(ctx context.Context, dataCenterID, serv return err } -// CreateLAN creates a new LAN with the provided properties in the specified data center. +// CreateLAN creates a new LAN with the provided properties in the specified data center, returning the request ID. func (c *IonosCloudClient) CreateLAN(ctx context.Context, dataCenterID string, properties sdk.LanPropertiesPost, -) (*sdk.LanPost, error) { +) (string, error) { if dataCenterID == "" { - return nil, errDataCenterIDIsEmpty + return "", errDataCenterIDIsEmpty } lanPost := sdk.LanPost{ Properties: &properties, } - lp, _, err := c.API.LANsApi.DatacentersLansPost(ctx, dataCenterID).Lan(lanPost).Execute() + _, req, err := c.API.LANsApi.DatacentersLansPost(ctx, dataCenterID).Lan(lanPost).Execute() if err != nil { - return nil, fmt.Errorf(apiCallErrWrapper, err) + return "", fmt.Errorf(apiCallErrWrapper, err) + } + if location := req.Header.Get("Location"); location != "" { + return location, nil } - return &lp, nil + return "", errors.New(apiNoLocationErrWrapper) } // UpdateLAN updates a LAN with the provided properties in the specified data center. @@ -221,18 +224,18 @@ func (c *IonosCloudClient) GetLAN(ctx context.Context, dataCenterID, lanID strin } // DestroyLAN deletes the LAN that matches the provided lanID in the specified data center. -func (c *IonosCloudClient) DestroyLAN(ctx context.Context, dataCenterID, lanID string) error { +func (c *IonosCloudClient) DestroyLAN(ctx context.Context, dataCenterID, lanID string) (string, error) { if dataCenterID == "" { - return errDataCenterIDIsEmpty + return "", errDataCenterIDIsEmpty } if lanID == "" { - return errLanIDIsEmpty + return "", errLanIDIsEmpty } - _, err := c.API.LANsApi.DatacentersLansDelete(ctx, dataCenterID, lanID).Execute() + req, err := c.API.LANsApi.DatacentersLansDelete(ctx, dataCenterID, lanID).Execute() if err != nil { - return fmt.Errorf(apiCallErrWrapper, err) + return "", fmt.Errorf(apiCallErrWrapper, err) } - return nil + return req.Header.Get("Location"), nil } // ListVolumes returns a list of volumes in the specified data center. @@ -278,3 +281,25 @@ func (c *IonosCloudClient) DestroyVolume(ctx context.Context, dataCenterID, volu } return nil } + +func (c *IonosCloudClient) CheckRequestStatus(ctx context.Context, requestURL string) (*sdk.RequestStatus, error) { + if requestURL == "" { + return nil, errRequestURLIsEmpty + } + requestStatus, _, err := c.API.GetRequestStatus(ctx, requestURL) + if err != nil { + return nil, fmt.Errorf(apiCallErrWrapper, err) + } + return requestStatus, nil +} + +func (c *IonosCloudClient) WaitForRequest(ctx context.Context, requestURL string) error { + if requestURL == "" { + return errRequestURLIsEmpty + } + _, err := c.API.WaitForRequest(ctx, requestURL) + if err != nil { + return fmt.Errorf(apiCallErrWrapper, err) + } + return nil +} diff --git a/internal/ionoscloud/client/errors.go b/internal/ionoscloud/client/errors.go index 6f7baf72..a89335ad 100644 --- a/internal/ionoscloud/client/errors.go +++ b/internal/ionoscloud/client/errors.go @@ -23,8 +23,10 @@ var ( errServerIDIsEmpty = errors.New("error parsing server ID: value cannot be empty") errLanIDIsEmpty = errors.New("error parsing lan ID: value cannot be empty") errVolumeIDIsEmpty = errors.New("error parsing volume ID: value cannot be empty") + errRequestURLIsEmpty = errors.New("a request url is necessary for the operation") ) const ( - apiCallErrWrapper = "request to Cloud API has failed: %w" + apiCallErrWrapper = "request to Cloud API has failed: %w" + apiNoLocationErrWrapper = "request to Cloud API did not return the request url" ) diff --git a/pkg/scope/machine.go b/pkg/scope/machine.go new file mode 100644 index 00000000..9e13f550 --- /dev/null +++ b/pkg/scope/machine.go @@ -0,0 +1,86 @@ +/* + * Copyright 2024 IONOS Cloud. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package scope + +import ( + "context" + "errors" + "fmt" + "github.com/go-logr/logr" + "github.com/ionos-cloud/cluster-api-provider-ionoscloud/api/v1alpha1" + clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" + "sigs.k8s.io/cluster-api/util/patch" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" +) + +type MachineScope struct { + *logr.Logger + + client client.Client + patchHelper *patch.Helper + Cluster *clusterv1.Cluster + Machine *clusterv1.Machine + + ClusterScope *ClusterScope + IonosCloudMachine *v1alpha1.IonosCloudMachine +} + +type MachineScopeParams struct { + Client client.Client + Logger *logr.Logger + Cluster *clusterv1.Cluster + Machine *clusterv1.Machine + InfraCluster *ClusterScope + IonosCloudMachine *v1alpha1.IonosCloudMachine +} + +func NewMachineScope(params MachineScopeParams) (*MachineScope, error) { + if params.Client == nil { + return nil, errors.New("machine scope params lack a client") + } + if params.Cluster == nil { + return nil, errors.New("machine scope params lack a cluster") + } + if params.Machine == nil { + return nil, errors.New("machine scope params lack a cluster api machine") + } + if params.IonosCloudMachine == nil { + return nil, errors.New("machine scope params lack a ionos cloud machine") + } + if params.InfraCluster == nil { + return nil, errors.New("machine scope params need a ionos cloud cluster scope") + } + if params.Logger == nil { + logger := log.FromContext(context.Background()) + params.Logger = &logger + } + helper, err := patch.NewHelper(params.IonosCloudMachine, params.Client) + if err != nil { + return nil, fmt.Errorf("failed to init patch helper: %w", err) + } + return &MachineScope{ + Logger: params.Logger, + client: params.Client, + patchHelper: helper, + Cluster: params.Cluster, + Machine: params.Machine, + ClusterScope: params.InfraCluster, + IonosCloudMachine: params.IonosCloudMachine, + }, nil +}