diff --git a/api/v1beta2/ibmvpcmachine_types.go b/api/v1beta2/ibmvpcmachine_types.go index b36e31afa..24aeed668 100644 --- a/api/v1beta2/ibmvpcmachine_types.go +++ b/api/v1beta2/ibmvpcmachine_types.go @@ -193,6 +193,16 @@ type IBMVPCMachine struct { Status IBMVPCMachineStatus `json:"status,omitempty"` } +// GetConditions returns the observations of the operational state of the IBMVPCMachine resource. +func (r *IBMVPCMachine) GetConditions() capiv1beta1.Conditions { + return r.Status.Conditions +} + +// SetConditions sets the underlying service state of the IBMVPCMachine to the predescribed clusterv1.Conditions. +func (r *IBMVPCMachine) SetConditions(conditions capiv1beta1.Conditions) { + r.Status.Conditions = conditions +} + //+kubebuilder:object:root=true // IBMVPCMachineList contains a list of IBMVPCMachine. diff --git a/cloud/scope/machine.go b/cloud/scope/machine.go index 2372b3521..f176440ec 100644 --- a/cloud/scope/machine.go +++ b/cloud/scope/machine.go @@ -20,10 +20,12 @@ import ( "context" "errors" "fmt" + "net/http" "github.com/go-logr/logr" "github.com/IBM/go-sdk-core/v5/core" + "github.com/IBM/platform-services-go-sdk/globaltaggingv1" "github.com/IBM/vpc-go-sdk/vpcv1" corev1 "k8s.io/api/core/v1" @@ -34,9 +36,11 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" capiv1beta1 "sigs.k8s.io/cluster-api/api/v1beta1" + capierrors "sigs.k8s.io/cluster-api/errors" "sigs.k8s.io/cluster-api/util/patch" infrav1beta2 "sigs.k8s.io/cluster-api-provider-ibmcloud/api/v1beta2" + "sigs.k8s.io/cluster-api-provider-ibmcloud/pkg/cloud/services/globaltagging" "sigs.k8s.io/cluster-api-provider-ibmcloud/pkg/cloud/services/utils" "sigs.k8s.io/cluster-api-provider-ibmcloud/pkg/cloud/services/vpc" "sigs.k8s.io/cluster-api-provider-ibmcloud/pkg/endpoints" @@ -62,12 +66,13 @@ type MachineScope struct { Client client.Client patchHelper *patch.Helper - IBMVPCClient vpc.Vpc - Cluster *capiv1beta1.Cluster - Machine *capiv1beta1.Machine - IBMVPCCluster *infrav1beta2.IBMVPCCluster - IBMVPCMachine *infrav1beta2.IBMVPCMachine - ServiceEndpoint []endpoints.ServiceEndpoint + IBMVPCClient vpc.Vpc + GlobalTaggingClient globaltagging.GlobalTagging + Cluster *capiv1beta1.Cluster + Machine *capiv1beta1.Machine + IBMVPCCluster *infrav1beta2.IBMVPCCluster + IBMVPCMachine *infrav1beta2.IBMVPCMachine + ServiceEndpoint []endpoints.ServiceEndpoint } // NewMachineScope creates a new MachineScope from the supplied parameters. @@ -100,20 +105,33 @@ func NewMachineScope(params MachineScopeParams) (*MachineScope, error) { core.SetLoggingLevel(core.LevelDebug) } + // Create Global Tagging client. + gtOptions := globaltagging.ServiceOptions{} + // Override the Global Tagging endpoint if provided. + if gtEndpoint := endpoints.FetchEndpoints(string(endpoints.GlobalTagging), params.ServiceEndpoint); gtEndpoint != "" { + gtOptions.URL = gtEndpoint + params.Logger.Info("Overriding the default global tagging endpoint", "GlobalTaggingEndpoint", gtEndpoint) + } + globalTaggingClient, err := globaltagging.NewService(gtOptions) + if err != nil { + return nil, fmt.Errorf("failed to create global tagging client: %w", err) + } + return &MachineScope{ - Logger: params.Logger, - Client: params.Client, - IBMVPCClient: vpcClient, - Cluster: params.Cluster, - IBMVPCCluster: params.IBMVPCCluster, - patchHelper: helper, - Machine: params.Machine, - IBMVPCMachine: params.IBMVPCMachine, + Logger: params.Logger, + Client: params.Client, + IBMVPCClient: vpcClient, + GlobalTaggingClient: globalTaggingClient, + Cluster: params.Cluster, + IBMVPCCluster: params.IBMVPCCluster, + patchHelper: helper, + Machine: params.Machine, + IBMVPCMachine: params.IBMVPCMachine, }, nil } // CreateMachine creates a vpc machine. -func (m *MachineScope) CreateMachine() (*vpcv1.Instance, error) { +func (m *MachineScope) CreateMachine() (*vpcv1.Instance, error) { //nolint: gocyclo instanceReply, err := m.ensureInstanceUnique(m.IBMVPCMachine.Name) if err != nil { return nil, err @@ -127,37 +145,131 @@ func (m *MachineScope) CreateMachine() (*vpcv1.Instance, error) { return nil, err } - imageID, err := fetchImageID(m.IBMVPCMachine.Spec.Image, m) - if err != nil { - record.Warnf(m.IBMVPCMachine, "FailedRetriveImage", "Failed image retrival - %v", err) - return nil, fmt.Errorf("error while fetching image ID: %v", err) + options := &vpcv1.CreateInstanceOptions{} + // Build common field resources, as unique InstancePrototype's are defined based on machine source. + // TODO(cjschaef): Replace with webhook validation + if m.IBMVPCMachine.Spec.Profile == "" { + return nil, fmt.Errorf("error profile is empty for machine %s", m.IBMVPCMachine.Name) + } + profile := &vpcv1.InstanceProfileIdentity{ + Name: &m.IBMVPCMachine.Spec.Profile, } - options := &vpcv1.CreateInstanceOptions{} - instancePrototype := &vpcv1.InstancePrototype{ - Name: &m.IBMVPCMachine.Name, - Image: &vpcv1.ImageIdentity{ - ID: imageID, - }, - Profile: &vpcv1.InstanceProfileIdentity{ - Name: &m.IBMVPCMachine.Spec.Profile, - }, - Zone: &vpcv1.ZoneIdentity{ - Name: &m.IBMVPCMachine.Spec.Zone, - }, - PrimaryNetworkInterface: &vpcv1.NetworkInterfacePrototype{ - Subnet: &vpcv1.SubnetIdentity{ - ID: &m.IBMVPCMachine.Spec.PrimaryNetworkInterface.Subnet, - }, - }, - ResourceGroup: &vpcv1.ResourceGroupIdentity{ + subnetIdentity := &vpcv1.SubnetIdentity{} + // If Network Status is available, attempt to retrieve subnet ID from there. + if m.IBMVPCCluster.Status.Network != nil { + if m.IBMVPCCluster.Status.Network.ControlPlaneSubnets != nil { + if subnet, ok := m.IBMVPCCluster.Status.Network.ControlPlaneSubnets[m.IBMVPCMachine.Spec.PrimaryNetworkInterface.Subnet]; ok { + subnetIdentity.ID = ptr.To(subnet.ID) + } + } + if m.IBMVPCCluster.Status.Network.WorkerSubnets != nil { + if subnet, ok := m.IBMVPCCluster.Status.Network.WorkerSubnets[m.IBMVPCMachine.Spec.PrimaryNetworkInterface.Subnet]; ok { + subnetIdentity.ID = ptr.To(subnet.ID) + } + } + } + // If the ID hasn't been set yet, rely on Machine Spec for lookup, and finally falling back to previous logic of using the subnet value directly as an ID. + if subnetIdentity.ID == nil { + // For Machines not reliant directly on Cluster managed subnets, lookup subnet ID by name. + subnetDetails, err := m.IBMVPCClient.GetVPCSubnetByName(m.IBMVPCMachine.Spec.PrimaryNetworkInterface.Subnet) + if err != nil { + return nil, fmt.Errorf("error retrieving subnet ID for machine %s: %w", m.IBMVPCMachine.Name, err) + } else if subnetDetails != nil { + subnetIdentity.ID = subnetDetails.ID + } else { + subnetIdentity.ID = &m.IBMVPCMachine.Spec.PrimaryNetworkInterface.Subnet + } + } + primaryNetworkInterface := &vpcv1.NetworkInterfacePrototype{ + Subnet: subnetIdentity, + } + + // Populate the PrimaryNetworkInterface's SecurityGroups, if provided. + if len(m.IBMVPCMachine.Spec.PrimaryNetworkInterface.SecurityGroups) > 0 { + securityGroups := make([]vpcv1.SecurityGroupIdentityIntf, 0, len(m.IBMVPCMachine.Spec.PrimaryNetworkInterface.SecurityGroups)) + for _, sg := range m.IBMVPCMachine.Spec.PrimaryNetworkInterface.SecurityGroups { + // Try using Security Group name if provided. + if sg.Name != nil { + // If Network Status is available, attempt to retrieve Security Group ID from there. + if m.IBMVPCCluster.Status.Network != nil { + if sgStatus, ok := m.IBMVPCCluster.Status.Network.SecurityGroups[*sg.Name]; ok { + securityGroups = append(securityGroups, &vpcv1.SecurityGroupIdentityByID{ + ID: ptr.To(sgStatus.ID), + }) + continue + } + } + // If not found in Network Status, try looking up the Security Group via API. + sgDetails, err := m.IBMVPCClient.GetSecurityGroupByName(*sg.Name) + if err != nil { + return nil, fmt.Errorf("error retrieving security group id with name %s for machine %s: %w", *sg.Name, m.IBMVPCMachine.Name, err) + } else if sgDetails != nil { + securityGroups = append(securityGroups, &vpcv1.SecurityGroupIdentityByID{ + ID: sgDetails.ID, + }) + continue + } + // If Name was provided but it cannot be found in Network Status or via API, return an error. + return nil, fmt.Errorf("error cannot find security group %s for machine %s", *sg.Name, m.IBMVPCMachine.Name) + } + // If ID is provided for Security Group, attempt lookup to confirm it exists. + if sg.ID != nil { + sgOptions := &vpcv1.GetSecurityGroupOptions{ + ID: sg.ID, + } + sgDetails, _, err := m.IBMVPCClient.GetSecurityGroup(sgOptions) + if err != nil { + return nil, fmt.Errorf("error retrieving security by id %s for machine %s: %w", *sg.ID, m.IBMVPCMachine.Name, err) + } else if sgDetails == nil { + return nil, fmt.Errorf("error security group not found with id %s for machine %s", *sg.ID, m.IBMVPCMachine.Name) + } + securityGroups = append(securityGroups, &vpcv1.SecurityGroupIdentityByID{ + ID: sg.ID, + }) + continue + } + // TODO(cjschaef): Replace with webhook validation check. + return nil, fmt.Errorf("error no name or id provided for security group for machine %s", m.IBMVPCMachine.Name) + } + // After processing all Security Groups, add them to the PrimaryNetworkInterface. + primaryNetworkInterface.SecurityGroups = securityGroups + } + + var resourceGroupIdentity *vpcv1.ResourceGroupIdentity + if m.IBMVPCCluster.Status.ResourceGroup != nil { + resourceGroupIdentity = &vpcv1.ResourceGroupIdentity{ + ID: &m.IBMVPCCluster.Status.ResourceGroup.ID, + } + } else { + resourceGroupIdentity = &vpcv1.ResourceGroupIdentity{ ID: &m.IBMVPCCluster.Spec.ResourceGroup, - }, - UserData: &cloudInitData, + } + } + + var vpcIdentity *vpcv1.VPCIdentityByID + if m.IBMVPCCluster.Status.Network != nil && m.IBMVPCCluster.Status.Network.VPC != nil { + vpcIdentity = &vpcv1.VPCIdentityByID{ + ID: ptr.To(m.IBMVPCCluster.Status.Network.VPC.ID), + } + } + + zone := &vpcv1.ZoneIdentity{ + Name: &m.IBMVPCMachine.Spec.Zone, + } + + // Populate Placement target details, if provided. + var placementTarget vpcv1.InstancePlacementTargetPrototypeIntf + if m.IBMVPCMachine.Spec.PlacementTarget != nil { + placementTarget, err = m.configurePlacementTarget() + if err != nil { + return nil, fmt.Errorf("error configuration machine placement target: %w", err) + } } + // Populate any SSH Keys, if provided. + sshKeys := make([]vpcv1.KeyIdentityIntf, 0) if m.IBMVPCMachine.Spec.SSHKeys != nil { - instancePrototype.Keys = []vpcv1.KeyIdentityIntf{} for _, sshKey := range m.IBMVPCMachine.Spec.SSHKeys { keyID, err := fetchKeyID(sshKey, m) if err != nil { @@ -166,25 +278,140 @@ func (m *MachineScope) CreateMachine() (*vpcv1.Instance, error) { key := &vpcv1.KeyIdentity{ ID: keyID, } - instancePrototype.Keys = append(instancePrototype.Keys, key) + sshKeys = append(sshKeys, key) } } + // Populate boot volume attachment, if provided. + var bootVolumeAttachment *vpcv1.VolumeAttachmentPrototypeInstanceByImageContext if m.IBMVPCMachine.Spec.BootVolume != nil { - instancePrototype.BootVolumeAttachment = volumeToVPCVolumeAttachment(m.IBMVPCMachine.Spec.BootVolume) + bootVolumeAttachment = m.volumeToVPCVolumeAttachment(m.IBMVPCMachine.Spec.BootVolume) + } + + // Configure the Machine's Image or CatalogOffering based on provided fields. + // If an Image was provided, use that, if a Catalog Offering was provided use that (based on details provided), otherwise return an error. + if m.IBMVPCMachine.Spec.Image != nil { + imageInstancePrototype := &vpcv1.InstancePrototype{ + Name: ptr.To(m.IBMVPCMachine.Name), + Profile: profile, + PrimaryNetworkInterface: primaryNetworkInterface, + ResourceGroup: resourceGroupIdentity, + UserData: ptr.To(cloudInitData), + VPC: vpcIdentity, + Zone: zone, + } + imageID, err := fetchImageID(m.IBMVPCMachine.Spec.Image, m) + if err != nil { + record.Warnf(m.IBMVPCMachine, "FailedRetrieveImage", "Failed image retrieval - %w", err) + return nil, fmt.Errorf("error while fetching image ID: %w", err) + } + imageInstancePrototype.Image = &vpcv1.ImageIdentity{ + ID: imageID, + } + + // Configure additional fields if they were populated. + if placementTarget != nil { + imageInstancePrototype.PlacementTarget = placementTarget + } + if len(sshKeys) > 0 { + imageInstancePrototype.Keys = sshKeys + } + if bootVolumeAttachment != nil { + imageInstancePrototype.BootVolumeAttachment = bootVolumeAttachment + } + + m.Logger.Info("machine creation configured with existing image", "machineName", m.IBMVPCMachine.Name, "imageID", *imageID) + options.SetInstancePrototype(imageInstancePrototype) + } else if m.IBMVPCMachine.Spec.CatalogOffering != nil { + catalogInstancePrototype := &vpcv1.InstancePrototypeInstanceByCatalogOffering{ + Name: ptr.To(m.IBMVPCMachine.Name), + Profile: profile, + PrimaryNetworkInterface: primaryNetworkInterface, + ResourceGroup: resourceGroupIdentity, + UserData: ptr.To(cloudInitData), + VPC: vpcIdentity, + Zone: zone, + } + catalogOfferingPrototype := &vpcv1.InstanceCatalogOfferingPrototype{} + if m.IBMVPCMachine.Spec.CatalogOffering.OfferingCRN != nil { + // TODO(cjschaef): Perform lookup or use webhook validation to confirm Catalog Offering CRN. + catalogOfferingPrototype.Offering = &vpcv1.CatalogOfferingIdentityCatalogOfferingByCRN{ + CRN: m.IBMVPCMachine.Spec.CatalogOffering.OfferingCRN, + } + m.Logger.Info("machine creation configured with catalog offering", "machineName", m.IBMVPCMachine.Name, "offeringCRN", *m.IBMVPCMachine.Spec.CatalogOffering.OfferingCRN) + } else if m.IBMVPCMachine.Spec.CatalogOffering.VersionCRN != nil { + // TODO(cjschaef): Perform lookup or use webhook validation to confirm Catalog Offering Version CRN. + catalogOfferingPrototype.Version = &vpcv1.CatalogOfferingVersionIdentityCatalogOfferingVersionByCRN{ + CRN: m.IBMVPCMachine.Spec.CatalogOffering.VersionCRN, + } + m.Logger.Info("machine creation configured with catalog version", "machineName", m.IBMVPCMachine.Name, "versionCRN", *m.IBMVPCMachine.Spec.CatalogOffering.VersionCRN) + } else { + // TODO(cjschaef): Look to add webhook validation to ensure one is provided. + return nil, fmt.Errorf("error catalog offering missing offering crn and version crn, one must be provided") + } + if m.IBMVPCMachine.Spec.CatalogOffering.PlanCRN != nil { + // TODO(cjschaef): Perform lookup or use webhook validation to confirm Catalog Offering Plan CRN. + catalogOfferingPrototype.Plan = &vpcv1.CatalogOfferingVersionPlanIdentityCatalogOfferingVersionPlanByCRN{ + CRN: m.IBMVPCMachine.Spec.CatalogOffering.PlanCRN, + } + m.Logger.Info("machine creation configured with catalog plan", "machineName", m.IBMVPCMachine.Name, "planCRN", *m.IBMVPCMachine.Spec.CatalogOffering.PlanCRN) + } + + // Configure additional fields if they were populated. + if placementTarget != nil { + catalogInstancePrototype.PlacementTarget = placementTarget + } + if len(sshKeys) > 0 { + catalogInstancePrototype.Keys = sshKeys + } + if bootVolumeAttachment != nil { + catalogInstancePrototype.BootVolumeAttachment = bootVolumeAttachment + } + + catalogInstancePrototype.CatalogOffering = catalogOfferingPrototype + options.SetInstancePrototype(catalogInstancePrototype) + } else { + // TODO(cjschaef): Move this to webhook validation. + return nil, fmt.Errorf("error no machine image or catalog offering provided to build: %s", m.IBMVPCMachine.Spec.Name) } - options.SetInstancePrototype(instancePrototype) + m.Logger.Info("creating instance", "createOptions", options, "name", m.IBMVPCMachine.Name, "profile", *profile.Name, "resourceGroup", resourceGroupIdentity, "vpc", vpcIdentity, "zone", zone) instance, _, err := m.IBMVPCClient.CreateInstance(options) if err != nil { - record.Warnf(m.IBMVPCMachine, "FailedCreateInstance", "Failed instance creation - %v", err) + record.Warnf(m.IBMVPCMachine, "FailedCreateInstance", "Failed instance creation - %s, %v", options, err) } else { record.Eventf(m.IBMVPCMachine, "SuccessfulCreateInstance", "Created Instance %q", *instance.Name) } return instance, err } -func volumeToVPCVolumeAttachment(volume *infrav1beta2.VPCVolume) *vpcv1.VolumeAttachmentPrototypeInstanceByImageContext { +// configurePlacementTarget will configure a Machine's Placement Target based on the Machine's provided configuration, if supplied. +func (m *MachineScope) configurePlacementTarget() (vpcv1.InstancePlacementTargetPrototypeIntf, error) { + // TODO(cjschaef): We currently don't support the other placement target options (Dedicated Host Group, Placement Group), they need to be added. + if m.IBMVPCMachine.Spec.PlacementTarget.DedicatedHost != nil { + // Lookup Dedicated Host ID by Name if it was provided. + var dedicatedHostID *string + if m.IBMVPCMachine.Spec.PlacementTarget.DedicatedHost.ID != nil { + dedicatedHostID = m.IBMVPCMachine.Spec.PlacementTarget.DedicatedHost.ID + } else if m.IBMVPCMachine.Spec.PlacementTarget.DedicatedHost.Name != nil { + dHost, err := m.IBMVPCClient.GetDedicatedHostByName(*m.IBMVPCMachine.Spec.PlacementTarget.DedicatedHost.Name) + if err != nil { + return nil, fmt.Errorf("error failed lookup of dedicated host by name %s: %w", *m.IBMVPCMachine.Spec.PlacementTarget.DedicatedHost.Name, err) + } else if dHost == nil { + return nil, fmt.Errorf("error no dedicated host found with name %s", *m.IBMVPCMachine.Spec.PlacementTarget.DedicatedHost.Name) + } + dedicatedHostID = dHost.ID + } + + m.Logger.Info("machine creation configured with dedicated host placement", "machineName", m.IBMVPCMachine.Name, "dedicatedHostID", *dedicatedHostID) + return &vpcv1.InstancePlacementTargetPrototypeDedicatedHostGroupIdentityDedicatedHostGroupIdentityByID{ + ID: dedicatedHostID, + }, nil + } + return nil, nil +} + +func (m *MachineScope) volumeToVPCVolumeAttachment(volume *infrav1beta2.VPCVolume) *vpcv1.VolumeAttachmentPrototypeInstanceByImageContext { bootVolume := &vpcv1.VolumeAttachmentPrototypeInstanceByImageContext{ DeleteVolumeOnInstanceDelete: core.BoolPtr(volume.DeleteVolumeOnInstanceDelete), Volume: &vpcv1.VolumePrototypeInstanceByImageContext{}, @@ -212,6 +439,7 @@ func volumeToVPCVolumeAttachment(volume *infrav1beta2.VPCVolume) *vpcv1.VolumeAt bootVolume.Volume.EncryptionKey = &vpcv1.EncryptionKeyIdentity{ CRN: core.StringPtr(volume.EncryptionKeyCRN), } + m.Logger.Info("machine creation configured with volumn encryption key", "machineName", m.IBMVPCMachine.Name, "encryptionKeyCRN", volume.EncryptionKeyCRN) } return bootVolume @@ -271,6 +499,167 @@ func (m *MachineScope) ensureInstanceUnique(instanceName string) (*vpcv1.Instanc return instance, nil } +// getLoadBalancerID will return the ID of a Load Balancer. +func (m *MachineScope) getLoadBalancerID(loadBalancer *infrav1beta2.VPCResource) (*string, error) { + // Lookup Load Balancer ID by Name if necessary + if loadBalancer.ID != nil { + return loadBalancer.ID, nil + } else if loadBalancer.Name != nil { + loadBalancerDetails, err := m.IBMVPCClient.GetLoadBalancerByName(*loadBalancer.Name) + if err != nil { + return nil, fmt.Errorf("error failed to lookup load balancer id by name %s: %w", *loadBalancer.Name, err) + } else if loadBalancerDetails == nil || loadBalancerDetails.ID == nil { + return nil, fmt.Errorf("error unable to find load balancer id with name: %s", *loadBalancer.Name) + } + return loadBalancerDetails.ID, nil + } + + return nil, fmt.Errorf("error no load balancer id or name provided") +} + +// getLoadBalancerPoolID will return the ID of a Load Balancer Pool. +func (m *MachineScope) getLoadBalancerPoolID(pool *infrav1beta2.VPCResource, loadBalancerID string) (*string, error) { + // Lookup Load Balancer Pool ID by Name if necessary + if pool.ID != nil { + return pool.ID, nil + } else if pool.Name != nil { + loadBalancerPoolDetails, err := m.IBMVPCClient.GetLoadBalancerPoolByName(loadBalancerID, *pool.Name) + if err != nil { + return nil, fmt.Errorf("error failed to lookup load balancer pool id by name %s: %w", *pool.Name, err) + } else if loadBalancerPoolDetails == nil || loadBalancerPoolDetails.ID == nil { + return nil, fmt.Errorf("error unable to find load balancer pool id with name: %s", *pool.Name) + } + return loadBalancerPoolDetails.ID, nil + } + + return nil, fmt.Errorf("error no load balancer pool id or name provided") +} + +// ReconcileVPCLoadBalancerPoolMember reconciles a Machine's Load Balancer Pool membership. +func (m *MachineScope) ReconcileVPCLoadBalancerPoolMember(poolMember infrav1beta2.VPCLoadBalancerBackendPoolMember) (bool, error) { + // Collect the Machine's internal IP. + internalIP := m.GetMachineInternalIP() + if internalIP == nil { + // TODO(cjschaef): Allow options for adding Machines to the pool without an internal IP + return false, fmt.Errorf("error unable to find machine's internal ip to use for load balancer pool") + } + + // Check if Instance is already a member of Load Balancer Backend Pool. + existingMember, err := m.checkVPCLoadBalancerPoolMemberExists(poolMember, internalIP) + if err != nil { + return false, fmt.Errorf("error failed to check if member exists in pool") + } else if existingMember != nil { + // If the member already exists in the pool, check whether it is ready (active). + if *existingMember.ProvisioningStatus == vpcv1.LoadBalancerPoolMemberProvisioningStatusActiveConst { + return false, nil + } + // If not ready, trigger requeue. + return true, nil + } + + // Otherwise, create VPC Load Balancer Backend Pool Member + return m.createVPCLoadBalancerPoolMember(poolMember, internalIP) +} + +// checkVPCLoadBalancerPoolMemberExists determines whether a Machine's Load Balancer Pool membership already exists. +func (m *MachineScope) checkVPCLoadBalancerPoolMemberExists(poolMember infrav1beta2.VPCLoadBalancerBackendPoolMember, internalIP *string) (*vpcv1.LoadBalancerPoolMember, error) { + loadBalancerID, err := m.getLoadBalancerID(&poolMember.LoadBalancer) + if err != nil { + return nil, fmt.Errorf("error checking if load balancer pool member exists: %w", err) + } + + poolID, err := m.getLoadBalancerPoolID(&poolMember.Pool, *loadBalancerID) + if err != nil { + return nil, fmt.Errorf("error checking if load balancer pool member exists: %w", err) + } + + // Check if the Pool has a member matching the Machine's internal IP. + listLoadBalancerPoolMembersOptions := &vpcv1.ListLoadBalancerPoolMembersOptions{} + listLoadBalancerPoolMembersOptions.SetLoadBalancerID(*loadBalancerID) + listLoadBalancerPoolMembersOptions.SetPoolID(*poolID) + + poolMembers, detailedResponse, err := m.IBMVPCClient.ListLoadBalancerPoolMembers(listLoadBalancerPoolMembersOptions) + if err != nil { + return nil, fmt.Errorf("error listing members for load balancer pool: %w", err) + } else if detailedResponse != nil && detailedResponse.StatusCode == http.StatusNotFound { + return nil, fmt.Errorf("error unable to find load balancer or pool members") + } else if poolMembers == nil { + return nil, fmt.Errorf("error no load balancer pool members returned") + } + + for _, member := range poolMembers.Members { + if target, ok := member.Target.(*vpcv1.LoadBalancerPoolMemberTarget); ok { + // Verify the target address matches the Machine's internal IP. + if *target.Address == *internalIP { + m.Logger.Info("found existing load balancer pool member for machine", "machineName", m.IBMVPCMachine.Spec.Name, "internalIP", *internalIP, "poolID", *poolID, "loadBalancerID", *loadBalancerID) + return ptr.To(member), nil + } + } + } + + // If a match was not found at this point, expect that it doesn't exist. + return nil, nil +} + +// createVPCLoadBalancerPoolMember will create a new member within a Load Balancer Pool for the Machine's internal IP. +func (m *MachineScope) createVPCLoadBalancerPoolMember(poolMember infrav1beta2.VPCLoadBalancerBackendPoolMember, internalIP *string) (bool, error) { + // Retrieve the Load Balancer ID. + loadBalancerID, err := m.getLoadBalancerID(&poolMember.LoadBalancer) + if err != nil { + return false, fmt.Errorf("error creating load balancer pool member: %w", err) + } + + loadBalancerBackendPoolID, err := m.getLoadBalancerPoolID(&poolMember.Pool, *loadBalancerID) + if err != nil { + return false, fmt.Errorf("error creating load balancer pool member: %w", err) + } + + // Populate the LB Pool Member options. + options := &vpcv1.CreateLoadBalancerPoolMemberOptions{ + LoadBalancerID: loadBalancerID, + PoolID: loadBalancerBackendPoolID, + Port: ptr.To(poolMember.Port), + Target: &vpcv1.LoadBalancerPoolMemberTargetPrototypeIP{ + Address: internalIP, + }, + } + + // Set the weight if it was provided. + // TODO(cjschaef): Weight only affects weightroundrobin algorithm on a LB. We may wish to validate this via webhook, unless API ignores this field for other algorithms (and it doesn't matter if we provide it). + if poolMember.Weight != nil { + options.Weight = poolMember.Weight + } + + // Create Machine Load Balancer Pool Member. + loadBalancerPoolMember, _, err := m.IBMVPCClient.CreateLoadBalancerPoolMember(options) + if err != nil { + return false, fmt.Errorf("error failed creating load balancer backend pool member: %w", err) + } + m.Logger.Info("created load balancer backend pool member", "instanceID", m.IBMVPCMachine.Status.InstanceID, "loadBalancerID", loadBalancerID, "loadBalancerBackendPoolID", loadBalancerBackendPoolID, "port", poolMember.Port, "loadBalancerBackendPoolMemberID", loadBalancerPoolMember.ID) + + // Add the new pool member details to the Machine Status. + // To prevent additional API calls, only use ID's and not Name's, as reconciliation does not rely on Name's for these resources in Status. + newMember := infrav1beta2.VPCLoadBalancerBackendPoolMember{ + LoadBalancer: infrav1beta2.VPCResource{ + ID: loadBalancerID, + }, + Pool: infrav1beta2.VPCResource{ + ID: loadBalancerBackendPoolID, + }, + Port: poolMember.Port, + } + if poolMember.Weight != nil { + newMember.Weight = poolMember.Weight + } + + m.IBMVPCMachine.Status.LoadBalancerPoolMembers = append(m.IBMVPCMachine.Status.LoadBalancerPoolMembers, newMember) + + // TODO(cjschaef): Tagging does not appear valid for this resource, so we currently skip it. + + // Trigger a requeue after creating the pool member to confirm it exists on next round. + return true, nil +} + // CreateVPCLoadBalancerPoolMember creates a new pool member and adds it to the load balancer pool. func (m *MachineScope) CreateVPCLoadBalancerPoolMember(internalIP *string, targetPort int64) (*vpcv1.LoadBalancerPoolMember, error) { loadBalancer, _, err := m.IBMVPCClient.GetLoadBalancer(&vpcv1.GetLoadBalancerOptions{ @@ -281,11 +670,11 @@ func (m *MachineScope) CreateVPCLoadBalancerPoolMember(internalIP *string, targe } if *loadBalancer.ProvisioningStatus != string(infrav1beta2.VPCLoadBalancerStateActive) { - return nil, fmt.Errorf("load balancer is not in active state") + return nil, fmt.Errorf("error load balancer is not in active state") } if len(loadBalancer.Pools) == 0 { - return nil, fmt.Errorf("no pools exist for the load balancer") + return nil, fmt.Errorf("error no pools exist for the load balancer") } options := &vpcv1.CreateLoadBalancerPoolMemberOptions{} @@ -301,7 +690,7 @@ func (m *MachineScope) CreateVPCLoadBalancerPoolMember(internalIP *string, targe listOptions.SetPoolID(*loadBalancer.Pools[0].ID) listLoadBalancerPoolMembers, _, err := m.IBMVPCClient.ListLoadBalancerPoolMembers(listOptions) if err != nil { - return nil, fmt.Errorf("failed to bind ListLoadBalancerPoolMembers to control plane %s/%s: %w", m.IBMVPCMachine.Namespace, m.IBMVPCMachine.Name, err) + return nil, fmt.Errorf("error failed to bind ListLoadBalancerPoolMembers to control plane %s/%s: %w", m.IBMVPCMachine.Namespace, m.IBMVPCMachine.Name, err) } for _, member := range listLoadBalancerPoolMembers.Members { @@ -470,8 +859,12 @@ func fetchImageID(image *infrav1beta2.IBMVPCResourceReference, m *MachineScope) var img *vpcv1.Image f := func(start string) (bool, string, error) { // check for existing images + resourceGroupID := ptr.To(m.IBMVPCCluster.Spec.ResourceGroup) + if m.IBMVPCCluster.Status.ResourceGroup != nil { + resourceGroupID = ptr.To(m.IBMVPCCluster.Status.ResourceGroup.ID) + } listImagesOptions := &vpcv1.ListImagesOptions{ - ResourceGroupID: &m.IBMVPCCluster.Spec.ResourceGroup, + ResourceGroupID: resourceGroupID, } if start != "" { listImagesOptions.Start = &start @@ -512,6 +905,77 @@ func fetchImageID(image *infrav1beta2.IBMVPCResourceReference, m *MachineScope) return nil, fmt.Errorf("image does not exist - failed to find an image ID") } +// GetInstanceID will return the Machine's Instance ID. +func (m *MachineScope) GetInstanceID() string { + return m.IBMVPCMachine.Status.InstanceID +} + +// GetInstanceStatus will return the Machine's Instance Status. +func (m *MachineScope) GetInstanceStatus() string { + return m.IBMVPCMachine.Status.InstanceStatus +} + +// GetMachineInternalIP returns the machine's internal IP. +func (m *MachineScope) GetMachineInternalIP() *string { + for _, address := range m.IBMVPCMachine.Status.Addresses { + if address.Type == corev1.NodeInternalIP { + return ptr.To(address.Address) + } + } + return nil +} + +// IsReady returns whether the machine is ready. +func (m *MachineScope) IsReady() bool { + return m.IBMVPCMachine.Status.Ready +} + +// SetAddresses sets the Machine's addresses. +func (m *MachineScope) SetAddresses(instance *vpcv1.Instance) { + addresses := make([]corev1.NodeAddress, 0) + addresses = append(addresses, corev1.NodeAddress{ + Type: corev1.NodeInternalDNS, + Address: *instance.Name, + }) + addresses = append(addresses, corev1.NodeAddress{ + Type: corev1.NodeHostName, + Address: *instance.Name, + }) + + // Currently, only the single network interface designated by a subnet, is expected for the Instance (as its primary/internal IP). + addresses = append(addresses, corev1.NodeAddress{ + Type: corev1.NodeInternalIP, + Address: *instance.PrimaryNetworkInterface.PrimaryIP.Address, + }) + + m.IBMVPCMachine.Status.Addresses = addresses +} + +// SetFailureMessage will set the Machine's Failure Message. +func (m *MachineScope) SetFailureMessage(message string) { + m.IBMVPCMachine.Status.FailureMessage = ptr.To(message) +} + +// SetFailureReason will set the Machine's Failure Reason. +func (m *MachineScope) SetFailureReason(reason capierrors.MachineStatusError) { + m.IBMVPCMachine.Status.FailureReason = ptr.To(reason) +} + +// SetInstanceID sets the Machine's Instance ID. +func (m *MachineScope) SetInstanceID(id string) { + m.IBMVPCMachine.Status.InstanceID = id +} + +// SetInstanceStatus sets the Machine's Instance Status. +func (m *MachineScope) SetInstanceStatus(status string) { + m.IBMVPCMachine.Status.InstanceStatus = status +} + +// SetNotReady sets the Machine Status as not ready. +func (m *MachineScope) SetNotReady() { + m.IBMVPCMachine.Status.Ready = false +} + // SetProviderID will set the provider id for the machine. func (m *MachineScope) SetProviderID(id *string) error { // Based on the ProviderIDFormat version the providerID format will be decided. @@ -528,6 +992,55 @@ func (m *MachineScope) SetProviderID(id *string) error { return nil } +// SetReady sets the Machine Status as ready. +func (m *MachineScope) SetReady() { + m.IBMVPCMachine.Status.Ready = true +} + +// CheckTagExists checks whether a user tag already exists. +func (m *MachineScope) CheckTagExists(tagName string) (bool, error) { + exists, err := m.GlobalTaggingClient.GetTagByName(tagName) + if err != nil { + return false, fmt.Errorf("failed checking for tag: %w", err) + } + + return exists != nil, nil +} + +// TagResource will attach a user Tag to a resource. +func (m *MachineScope) TagResource(tagName string, resourceCRN string) error { + // Verify the Tag to use exists, otherwise create it. + exists, err := m.CheckTagExists(tagName) + if err != nil { + return fmt.Errorf("failure checking if tag %s exists: %w", tagName, err) + } + + // Create tag if it doesn't exist. + if !exists { + createOptions := &globaltaggingv1.CreateTagOptions{} + createOptions.SetTagNames([]string{tagName}) + if _, _, err := m.GlobalTaggingClient.CreateTag(createOptions); err != nil { + return fmt.Errorf("failure creating tag: %w", err) + } + } + + // Finally, tag the resource. + tagOptions := &globaltaggingv1.AttachTagOptions{} + tagOptions.SetResources([]globaltaggingv1.Resource{ + { + ResourceID: ptr.To(resourceCRN), + }, + }) + tagOptions.SetTagName(tagName) + tagOptions.SetTagType(globaltaggingv1.AttachTagOptionsTagTypeUserConst) + + if _, _, err = m.GlobalTaggingClient.AttachTag(tagOptions); err != nil { + return fmt.Errorf("failure tagging resource: %w", err) + } + + return nil +} + // APIServerPort returns the APIServerPort. func (m *MachineScope) APIServerPort() int32 { if m.Cluster.Spec.ClusterNetwork != nil && m.Cluster.Spec.ClusterNetwork.APIServerPort != nil { diff --git a/cloud/scope/machine_test.go b/cloud/scope/machine_test.go index 92a6864b3..2a153e857 100644 --- a/cloud/scope/machine_test.go +++ b/cloud/scope/machine_test.go @@ -58,6 +58,16 @@ func setupMachineScope(clusterName string, machineName string, mockvpc *mock.Moc secret := newBootstrapSecret(clusterName, machineName) vpcMachine := newVPCMachine(clusterName, machineName) vpcCluster := newVPCCluster(clusterName) + vpcCluster.Status = infrav1beta2.IBMVPCClusterStatus{ + Network: &infrav1beta2.VPCNetworkStatus{ + VPC: &infrav1beta2.ResourceStatus{ + ID: "vpc-id", + }, + }, + ResourceGroup: &infrav1beta2.ResourceStatus{ + ID: "resource-group-id", + }, + } initObjects := []client.Object{ cluster, machine, secret, vpcCluster, vpcMachine, @@ -130,6 +140,7 @@ func TestCreateMachine(t *testing.T) { Image: &infrav1beta2.IBMVPCResourceReference{ ID: core.StringPtr("foo-image-id"), }, + Profile: "machine-profile", }, } @@ -147,6 +158,7 @@ func TestCreateMachine(t *testing.T) { Name: &scope.Machine.Name, } mockvpc.EXPECT().ListInstances(gomock.AssignableToTypeOf(&vpcv1.ListInstancesOptions{})).Return(&vpcv1.InstanceCollection{}, &core.DetailedResponse{}, nil) + mockvpc.EXPECT().GetVPCSubnetByName(vpcMachine.Spec.PrimaryNetworkInterface.Subnet).Return(&vpcv1.Subnet{ID: core.StringPtr("subnet-name")}, nil) mockvpc.EXPECT().CreateInstance(gomock.AssignableToTypeOf(&vpcv1.CreateInstanceOptions{})).Return(instance, &core.DetailedResponse{}, nil) out, err := scope.CreateMachine() g.Expect(err).To(BeNil()) @@ -235,10 +247,195 @@ func TestCreateMachine(t *testing.T) { scope := setupMachineScope(clusterName, machineName, mockvpc) scope.IBMVPCMachine.Spec = vpcMachine.Spec mockvpc.EXPECT().ListInstances(gomock.AssignableToTypeOf(&vpcv1.ListInstancesOptions{})).Return(&vpcv1.InstanceCollection{}, &core.DetailedResponse{}, nil) + mockvpc.EXPECT().GetVPCSubnetByName(vpcMachine.Spec.PrimaryNetworkInterface.Subnet).Return(&vpcv1.Subnet{ID: core.StringPtr("subnet-id")}, nil) mockvpc.EXPECT().CreateInstance(gomock.AssignableToTypeOf(&vpcv1.CreateInstanceOptions{})).Return(nil, &core.DetailedResponse{}, errors.New("Failed when creating instance")) _, err := scope.CreateMachine() g.Expect(err).To(Not(BeNil())) }) + + t.Run("Create machine using network status subnets", func(t *testing.T) { + g := NewWithT(t) + mockController, mockvpc := setup(t) + t.Cleanup(mockController.Finish) + scope := setupMachineScope(clusterName, machineName, mockvpc) + expectedOutput := &vpcv1.Instance{ + Name: core.StringPtr("foo-machine"), + } + scope.IBMVPCMachine.Spec = vpcMachine.Spec + scope.IBMVPCMachine.Spec.PrimaryNetworkInterface = infrav1beta2.NetworkInterface{ + Subnet: "subnet-name-1", + } + scope.IBMVPCCluster.Status = infrav1beta2.IBMVPCClusterStatus{ + Network: &infrav1beta2.VPCNetworkStatus{ + ControlPlaneSubnets: map[string]*infrav1beta2.ResourceStatus{ + "subnet-name-1": { + ID: "subnet-id", + }, + }, + }, + } + instance := &vpcv1.Instance{ + Name: &scope.Machine.Name, + } + + mockvpc.EXPECT().ListInstances(gomock.AssignableToTypeOf(&vpcv1.ListInstancesOptions{})).Return(&vpcv1.InstanceCollection{}, &core.DetailedResponse{}, nil) + // TODO(cjschaef): Enhance the mock Options parameter to validate the Network Status ControlPlaneSubnets ID was used. + mockvpc.EXPECT().CreateInstance(gomock.AssignableToTypeOf(&vpcv1.CreateInstanceOptions{})).Return(instance, &core.DetailedResponse{}, nil) + + out, err := scope.CreateMachine() + g.Expect(err).To(BeNil()) + require.Equal(t, expectedOutput, out) + }) + + t.Run("Create machine using network status security groups", func(t *testing.T) { + g := NewWithT(t) + mockController, mockvpc := setup(t) + t.Cleanup(mockController.Finish) + scope := setupMachineScope(clusterName, machineName, mockvpc) + expectedOutput := &vpcv1.Instance{ + Name: core.StringPtr("foo-machine"), + } + scope.IBMVPCMachine.Spec = vpcMachine.Spec + scope.IBMVPCMachine.Spec.PrimaryNetworkInterface = infrav1beta2.NetworkInterface{ + SecurityGroups: []infrav1beta2.VPCResource{ + { + Name: core.StringPtr("security-group-1"), + }, + }, + Subnet: "subnet-name", + } + scope.IBMVPCCluster.Status = infrav1beta2.IBMVPCClusterStatus{ + Network: &infrav1beta2.VPCNetworkStatus{ + ControlPlaneSubnets: map[string]*infrav1beta2.ResourceStatus{ + "subnet-name": { + ID: "subnet-id", + }, + }, + SecurityGroups: map[string]*infrav1beta2.ResourceStatus{ + "security-group-1": { + ID: "security-group-id-1", + }, + }, + }, + } + instance := &vpcv1.Instance{ + Name: &scope.Machine.Name, + } + + mockvpc.EXPECT().ListInstances(gomock.AssignableToTypeOf(&vpcv1.ListInstancesOptions{})).Return(&vpcv1.InstanceCollection{}, &core.DetailedResponse{}, nil) + // TODO(cjschaef): Enhance the mock Options parameter to validate the Network Status Security Group ID was used. + mockvpc.EXPECT().CreateInstance(gomock.AssignableToTypeOf(&vpcv1.CreateInstanceOptions{})).Return(instance, &core.DetailedResponse{}, nil) + + out, err := scope.CreateMachine() + g.Expect(err).To(BeNil()) + require.Equal(t, expectedOutput, out) + }) + + t.Run("Create machine using name lookup security groups", func(t *testing.T) { + g := NewWithT(t) + mockController, mockvpc := setup(t) + t.Cleanup(mockController.Finish) + scope := setupMachineScope(clusterName, machineName, mockvpc) + expectedOutput := &vpcv1.Instance{ + Name: core.StringPtr("foo-machine"), + } + scope.IBMVPCMachine.Spec = vpcMachine.Spec + scope.IBMVPCMachine.Spec.PrimaryNetworkInterface = infrav1beta2.NetworkInterface{ + SecurityGroups: []infrav1beta2.VPCResource{ + { + Name: core.StringPtr("security-group-1"), + }, + }, + Subnet: "subnet-name", + } + instance := &vpcv1.Instance{ + Name: &scope.Machine.Name, + } + + mockvpc.EXPECT().ListInstances(gomock.AssignableToTypeOf(&vpcv1.ListInstancesOptions{})).Return(&vpcv1.InstanceCollection{}, &core.DetailedResponse{}, nil) + mockvpc.EXPECT().GetVPCSubnetByName("subnet-name").Return(&vpcv1.Subnet{ID: core.StringPtr("subnet-id")}, nil) + mockvpc.EXPECT().GetSecurityGroupByName("security-group-1").Return(&vpcv1.SecurityGroup{ID: core.StringPtr("security-group-id-1")}, nil) + mockvpc.EXPECT().CreateInstance(gomock.AssignableToTypeOf(&vpcv1.CreateInstanceOptions{})).Return(instance, &core.DetailedResponse{}, nil) + + out, err := scope.CreateMachine() + g.Expect(err).To(BeNil()) + require.Equal(t, expectedOutput, out) + }) + + t.Run("Create machine using id lookup security groups", func(t *testing.T) { + g := NewWithT(t) + mockController, mockvpc := setup(t) + t.Cleanup(mockController.Finish) + scope := setupMachineScope(clusterName, machineName, mockvpc) + expectedOutput := &vpcv1.Instance{ + Name: core.StringPtr("foo-machine"), + } + scope.IBMVPCMachine.Spec = vpcMachine.Spec + scope.IBMVPCMachine.Spec.PrimaryNetworkInterface = infrav1beta2.NetworkInterface{ + SecurityGroups: []infrav1beta2.VPCResource{ + { + ID: core.StringPtr("security-group-id-1"), + }, + }, + Subnet: "subnet-name", + } + instance := &vpcv1.Instance{ + Name: &scope.Machine.Name, + } + + mockvpc.EXPECT().ListInstances(gomock.AssignableToTypeOf(&vpcv1.ListInstancesOptions{})).Return(&vpcv1.InstanceCollection{}, &core.DetailedResponse{}, nil) + mockvpc.EXPECT().GetVPCSubnetByName("subnet-name").Return(&vpcv1.Subnet{ID: core.StringPtr("subnet-id")}, nil) + mockvpc.EXPECT().GetSecurityGroup(gomock.AssignableToTypeOf(&vpcv1.GetSecurityGroupOptions{})).Return(&vpcv1.SecurityGroup{ID: core.StringPtr("security-group-id-1")}, &core.DetailedResponse{}, nil) + mockvpc.EXPECT().CreateInstance(gomock.AssignableToTypeOf(&vpcv1.CreateInstanceOptions{})).Return(instance, &core.DetailedResponse{}, nil) + + out, err := scope.CreateMachine() + g.Expect(err).To(BeNil()) + require.Equal(t, expectedOutput, out) + }) + + t.Run("Create machine using network status vpc", func(t *testing.T) { + g := NewWithT(t) + mockController, mockvpc := setup(t) + t.Cleanup(mockController.Finish) + scope := setupMachineScope(clusterName, machineName, mockvpc) + expectedOutput := &vpcv1.Instance{ + Name: core.StringPtr("foo-machine"), + } + scope.IBMVPCMachine.Spec = vpcMachine.Spec + scope.IBMVPCCluster.Status = infrav1beta2.IBMVPCClusterStatus{ + Network: &infrav1beta2.VPCNetworkStatus{ + VPC: &infrav1beta2.ResourceStatus{ + ID: "network-vpc-id", + }, + }, + } + instance := &vpcv1.Instance{ + Name: &scope.Machine.Name, + } + + mockvpc.EXPECT().ListInstances(gomock.AssignableToTypeOf(&vpcv1.ListInstancesOptions{})).Return(&vpcv1.InstanceCollection{}, &core.DetailedResponse{}, nil) + mockvpc.EXPECT().GetVPCSubnetByName(vpcMachine.Spec.PrimaryNetworkInterface.Subnet).Return(&vpcv1.Subnet{ID: core.StringPtr("subnet-name")}, nil) + // TODO(cjschaef): Enhance the mock Options parameter to validate the Network Status VPC ID was used. + mockvpc.EXPECT().CreateInstance(gomock.AssignableToTypeOf(&vpcv1.CreateInstanceOptions{})).Return(instance, &core.DetailedResponse{}, nil) + + out, err := scope.CreateMachine() + g.Expect(err).To(BeNil()) + require.Equal(t, expectedOutput, out) + }) + }) + + t.Run("Error when machine profile is empty", func(t *testing.T) { + g := NewWithT(t) + mockController, mockvpc := setup(t) + t.Cleanup(mockController.Finish) + scope := setupMachineScope(clusterName, machineName, mockvpc) + vpcMachine := infrav1beta2.IBMVPCMachine{ + Spec: infrav1beta2.IBMVPCMachineSpec{}, + } + scope.IBMVPCMachine.Spec = vpcMachine.Spec + mockvpc.EXPECT().ListInstances(gomock.AssignableToTypeOf(&vpcv1.ListInstancesOptions{})).Return(&vpcv1.InstanceCollection{}, &core.DetailedResponse{}, nil) + _, err := scope.CreateMachine() + g.Expect(err).To(Not(BeNil())) }) t.Run("Error when both SSHKeys ID and Name are nil", func(t *testing.T) { @@ -254,10 +451,15 @@ func TestCreateMachine(t *testing.T) { Image: &infrav1beta2.IBMVPCResourceReference{ ID: core.StringPtr("foo-image-id"), }, + PrimaryNetworkInterface: infrav1beta2.NetworkInterface{ + Subnet: "subnet-name", + }, + Profile: "machine-profile", }, } scope.IBMVPCMachine.Spec = vpcMachine.Spec mockvpc.EXPECT().ListInstances(gomock.AssignableToTypeOf(&vpcv1.ListInstancesOptions{})).Return(&vpcv1.InstanceCollection{}, &core.DetailedResponse{}, nil) + mockvpc.EXPECT().GetVPCSubnetByName(vpcMachine.Spec.PrimaryNetworkInterface.Subnet).Return(&vpcv1.Subnet{ID: core.StringPtr("subnet-id")}, nil) _, err := scope.CreateMachine() g.Expect(err).To(Not(BeNil())) }) @@ -277,10 +479,15 @@ func TestCreateMachine(t *testing.T) { Image: &infrav1beta2.IBMVPCResourceReference{ ID: core.StringPtr("foo-image-id"), }, + PrimaryNetworkInterface: infrav1beta2.NetworkInterface{ + Subnet: "subnet-name", + }, + Profile: "machine-profile", }, } scope.IBMVPCMachine.Spec = vpcMachine.Spec mockvpc.EXPECT().ListInstances(gomock.AssignableToTypeOf(&vpcv1.ListInstancesOptions{})).Return(&vpcv1.InstanceCollection{}, &core.DetailedResponse{}, nil) + mockvpc.EXPECT().GetVPCSubnetByName(vpcMachine.Spec.PrimaryNetworkInterface.Subnet).Return(&vpcv1.Subnet{ID: core.StringPtr("subnet-id")}, nil) mockvpc.EXPECT().ListKeys(gomock.AssignableToTypeOf(&vpcv1.ListKeysOptions{})).Return(nil, &core.DetailedResponse{}, errors.New("Failed when creating instance")) _, err := scope.CreateMachine() g.Expect(err).To(Not(BeNil())) @@ -309,10 +516,15 @@ func TestCreateMachine(t *testing.T) { Image: &infrav1beta2.IBMVPCResourceReference{ ID: core.StringPtr("foo-image-id"), }, + PrimaryNetworkInterface: infrav1beta2.NetworkInterface{ + Subnet: "subnet-name", + }, + Profile: "machine-profile", }, } scope.IBMVPCMachine.Spec = vpcMachine.Spec mockvpc.EXPECT().ListInstances(gomock.AssignableToTypeOf(&vpcv1.ListInstancesOptions{})).Return(&vpcv1.InstanceCollection{}, &core.DetailedResponse{}, nil) + mockvpc.EXPECT().GetVPCSubnetByName(vpcMachine.Spec.PrimaryNetworkInterface.Subnet).Return(&vpcv1.Subnet{ID: core.StringPtr("subnet-id")}, nil) mockvpc.EXPECT().ListKeys(gomock.AssignableToTypeOf(&vpcv1.ListKeysOptions{})).Return(keyCollection, &core.DetailedResponse{}, nil) _, err := scope.CreateMachine() g.Expect(err).To(Not(BeNil())) @@ -352,6 +564,10 @@ func TestCreateMachine(t *testing.T) { Image: &infrav1beta2.IBMVPCResourceReference{ Name: core.StringPtr("foo-image"), }, + PrimaryNetworkInterface: infrav1beta2.NetworkInterface{ + Subnet: "subnet-name", + }, + Profile: "machine-profile", }, } scope.IBMVPCMachine.Spec = vpcMachine.Spec @@ -359,6 +575,7 @@ func TestCreateMachine(t *testing.T) { Name: &scope.Machine.Name, } mockvpc.EXPECT().ListInstances(gomock.AssignableToTypeOf(&vpcv1.ListInstancesOptions{})).Return(&vpcv1.InstanceCollection{}, &core.DetailedResponse{}, nil) + mockvpc.EXPECT().GetVPCSubnetByName(vpcMachine.Spec.PrimaryNetworkInterface.Subnet).Return(&vpcv1.Subnet{ID: core.StringPtr("subnet-id")}, nil) mockvpc.EXPECT().ListImages(gomock.AssignableToTypeOf(&vpcv1.ListImagesOptions{})).Return(imageCollection, &core.DetailedResponse{}, nil) mockvpc.EXPECT().ListKeys(gomock.AssignableToTypeOf(&vpcv1.ListKeysOptions{})).Return(keyCollection, &core.DetailedResponse{}, nil) mockvpc.EXPECT().CreateInstance(gomock.AssignableToTypeOf(&vpcv1.CreateInstanceOptions{})).Return(instance, &core.DetailedResponse{}, nil) @@ -375,10 +592,15 @@ func TestCreateMachine(t *testing.T) { vpcMachine := infrav1beta2.IBMVPCMachine{ Spec: infrav1beta2.IBMVPCMachineSpec{ Image: &infrav1beta2.IBMVPCResourceReference{}, + PrimaryNetworkInterface: infrav1beta2.NetworkInterface{ + Subnet: "subnet-name", + }, + Profile: "machine-profile", }, } scope.IBMVPCMachine.Spec = vpcMachine.Spec mockvpc.EXPECT().ListInstances(gomock.AssignableToTypeOf(&vpcv1.ListInstancesOptions{})).Return(&vpcv1.InstanceCollection{}, &core.DetailedResponse{}, nil) + mockvpc.EXPECT().GetVPCSubnetByName(vpcMachine.Spec.PrimaryNetworkInterface.Subnet).Return(&vpcv1.Subnet{ID: core.StringPtr("subnet-id")}, nil) _, err := scope.CreateMachine() g.Expect(err).To(Not(BeNil())) }) @@ -393,10 +615,15 @@ func TestCreateMachine(t *testing.T) { Image: &infrav1beta2.IBMVPCResourceReference{ Name: core.StringPtr("foo-image"), }, + PrimaryNetworkInterface: infrav1beta2.NetworkInterface{ + Subnet: "subnet-name", + }, + Profile: "machine-profile", }, } scope.IBMVPCMachine.Spec = vpcMachine.Spec mockvpc.EXPECT().ListInstances(gomock.AssignableToTypeOf(&vpcv1.ListInstancesOptions{})).Return(&vpcv1.InstanceCollection{}, &core.DetailedResponse{}, nil) + mockvpc.EXPECT().GetVPCSubnetByName(vpcMachine.Spec.PrimaryNetworkInterface.Subnet).Return(&vpcv1.Subnet{ID: core.StringPtr("subnet-id")}, nil) mockvpc.EXPECT().ListImages(gomock.AssignableToTypeOf(&vpcv1.ListImagesOptions{})).Return(nil, &core.DetailedResponse{}, errors.New("Failed when listing Images")) _, err := scope.CreateMachine() g.Expect(err).To(Not(BeNil())) @@ -420,10 +647,15 @@ func TestCreateMachine(t *testing.T) { Image: &infrav1beta2.IBMVPCResourceReference{ Name: core.StringPtr("foo-image"), }, + PrimaryNetworkInterface: infrav1beta2.NetworkInterface{ + Subnet: "subnet-name", + }, + Profile: "machine-profile", }, } scope.IBMVPCMachine.Spec = vpcMachine.Spec mockvpc.EXPECT().ListInstances(gomock.AssignableToTypeOf(&vpcv1.ListInstancesOptions{})).Return(&vpcv1.InstanceCollection{}, &core.DetailedResponse{}, nil) + mockvpc.EXPECT().GetVPCSubnetByName(vpcMachine.Spec.PrimaryNetworkInterface.Subnet).Return(&vpcv1.Subnet{ID: core.StringPtr("subnet-id")}, nil) mockvpc.EXPECT().ListImages(gomock.AssignableToTypeOf(&vpcv1.ListImagesOptions{})).Return(imageCollection, &core.DetailedResponse{}, nil) _, err := scope.CreateMachine() g.Expect(err).To(Not(BeNil())) @@ -449,6 +681,10 @@ func TestCreateMachine(t *testing.T) { Name: core.StringPtr("foo-image"), ID: core.StringPtr("foo-image-id"), }, + PrimaryNetworkInterface: infrav1beta2.NetworkInterface{ + Subnet: "subnet-name", + }, + Profile: "machine-profile", }, } scope.IBMVPCMachine.Spec = vpcMachine.Spec @@ -456,6 +692,7 @@ func TestCreateMachine(t *testing.T) { Name: &scope.Machine.Name, } mockvpc.EXPECT().ListInstances(gomock.AssignableToTypeOf(&vpcv1.ListInstancesOptions{})).Return(&vpcv1.InstanceCollection{}, &core.DetailedResponse{}, nil) + mockvpc.EXPECT().GetVPCSubnetByName(vpcMachine.Spec.PrimaryNetworkInterface.Subnet).Return(&vpcv1.Subnet{ID: core.StringPtr("subnet-id")}, nil) mockvpc.EXPECT().CreateInstance(gomock.AssignableToTypeOf(&vpcv1.CreateInstanceOptions{})).Return(instance, &core.DetailedResponse{}, nil) out, err := scope.CreateMachine() g.Expect(err).To(BeNil()) diff --git a/controllers/ibmvpcmachine_controller.go b/controllers/ibmvpcmachine_controller.go index 1a4535426..788999c45 100644 --- a/controllers/ibmvpcmachine_controller.go +++ b/controllers/ibmvpcmachine_controller.go @@ -25,7 +25,6 @@ import ( "github.com/IBM/vpc-go-sdk/vpcv1" - corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/tools/record" @@ -35,11 +34,14 @@ import ( "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" capiv1beta1 "sigs.k8s.io/cluster-api/api/v1beta1" + capierrors "sigs.k8s.io/cluster-api/errors" "sigs.k8s.io/cluster-api/util" + "sigs.k8s.io/cluster-api/util/conditions" infrav1beta2 "sigs.k8s.io/cluster-api-provider-ibmcloud/api/v1beta2" "sigs.k8s.io/cluster-api-provider-ibmcloud/cloud/scope" "sigs.k8s.io/cluster-api-provider-ibmcloud/pkg/endpoints" + capibmrecord "sigs.k8s.io/cluster-api-provider-ibmcloud/pkg/record" ) // IBMVPCMachineReconciler reconciles a IBMVPCMachine object. @@ -139,7 +141,7 @@ func (r *IBMVPCMachineReconciler) SetupWithManager(mgr ctrl.Manager) error { Complete(r) } -func (r *IBMVPCMachineReconciler) reconcileNormal(machineScope *scope.MachineScope) (ctrl.Result, error) { +func (r *IBMVPCMachineReconciler) reconcileNormal(machineScope *scope.MachineScope) (ctrl.Result, error) { //nolint:gocyclo if controllerutil.AddFinalizer(machineScope.IBMVPCMachine, infrav1beta2.MachineFinalizer) { return ctrl.Result{}, nil } @@ -161,14 +163,79 @@ func (r *IBMVPCMachineReconciler) reconcileNormal(machineScope *scope.MachineSco return ctrl.Result{}, fmt.Errorf("failed to reconcile VSI for IBMVPCMachine %s/%s: %w", machineScope.IBMVPCMachine.Namespace, machineScope.IBMVPCMachine.Name, err) } + machineRunning := false if instance != nil { - machineScope.IBMVPCMachine.Status.InstanceID = *instance.ID - machineScope.IBMVPCMachine.Status.Addresses = []corev1.NodeAddress{ - { - Type: corev1.NodeInternalIP, - Address: *instance.PrimaryNetworkInterface.PrimaryIP.Address, - }, + // Attempt to tag the Instance. + if err := machineScope.TagResource(machineScope.IBMVPCCluster.Name, *instance.CRN); err != nil { + return ctrl.Result{}, fmt.Errorf("error failed to tag machine: %w", err) } + + // Set available status' for Machine. + machineScope.SetInstanceID(*instance.ID) + if err := machineScope.SetProviderID(instance.ID); err != nil { + return ctrl.Result{}, fmt.Errorf("error failed to set machine provider id: %w", err) + } + machineScope.SetAddresses(instance) + machineScope.SetInstanceStatus(*instance.Status) + + // Depending on the state of the Machine, update status, conditions, etc. + switch machineScope.GetInstanceStatus() { + case vpcv1.InstanceStatusPendingConst: + machineScope.SetNotReady() + conditions.MarkFalse(machineScope.IBMVPCMachine, infrav1beta2.InstanceReadyCondition, infrav1beta2.InstanceNotReadyReason, capiv1beta1.ConditionSeverityWarning, "") + case vpcv1.InstanceStatusStoppedConst: + machineScope.SetNotReady() + conditions.MarkFalse(machineScope.IBMVPCMachine, infrav1beta2.InstanceReadyCondition, infrav1beta2.InstanceStoppedReason, capiv1beta1.ConditionSeverityError, "") + case vpcv1.InstanceStatusFailedConst: + msg := "" + healthReasonsLen := len(instance.HealthReasons) + if healthReasonsLen > 0 { + // Create a failure message using the last entry's Code and Message fields. + // TODO(cjschaef): Consider adding the MoreInfo field as well, as it contains a link to IBM Cloud docs. + msg = fmt.Sprintf("%s: %s", *instance.HealthReasons[healthReasonsLen-1].Code, *instance.HealthReasons[healthReasonsLen-1].Message) + } + machineScope.SetNotReady() + machineScope.SetFailureReason(capierrors.UpdateMachineError) + machineScope.SetFailureMessage(msg) + conditions.MarkFalse(machineScope.IBMVPCMachine, infrav1beta2.InstanceReadyCondition, infrav1beta2.InstanceErroredReason, capiv1beta1.ConditionSeverityError, "%s", msg) + capibmrecord.Warnf(machineScope.IBMVPCMachine, "FailedBuildInstance", "Failed to build the instance - %s", msg) + return ctrl.Result{}, nil + case vpcv1.InstanceStatusRunningConst: + machineRunning = true + default: + machineScope.SetNotReady() + machineScope.V(3).Info("unexpected vpc instance status", "instanceStatus", *instance.Status, "instanceID", machineScope.GetInstanceID()) + conditions.MarkUnknown(machineScope.IBMVPCMachine, infrav1beta2.InstanceReadyCondition, "", "") + } + } else { + machineScope.SetNotReady() + conditions.MarkUnknown(machineScope.IBMVPCMachine, infrav1beta2.InstanceReadyCondition, infrav1beta2.InstanceStateUnknownReason, "") + } + + // Check if the Machine is running. + if !machineRunning { + // Requeue after 1 minute if machine is not running. + return ctrl.Result{RequeueAfter: 1 * time.Minute}, nil + } + + // Rely on defined VPC Load Balancer Pool Members first before falling back to hardcoded defaults. + if len(machineScope.IBMVPCMachine.Spec.LoadBalancerPoolMembers) > 0 { + needsRequeue := false + for _, poolMember := range machineScope.IBMVPCMachine.Spec.LoadBalancerPoolMembers { + requeue, err := machineScope.ReconcileVPCLoadBalancerPoolMember(poolMember) + if err != nil { + return ctrl.Result{}, fmt.Errorf("error failed to reconcile machine's pool member: %w", err) + } else if requeue { + needsRequeue = true + } + } + + // If any VPC Load Balancer Pool Member needs reconciliation, requeue. + if needsRequeue { + return ctrl.Result{RequeueAfter: 1 * time.Minute}, nil + } + } else { + // Otherwise, default to previous Load Balancer Pool Member configuration. _, ok := machineScope.IBMVPCMachine.Labels[capiv1beta1.MachineControlPlaneNameLabel] if err = machineScope.SetProviderID(instance.ID); err != nil { return ctrl.Result{}, fmt.Errorf("failed to set provider id IBMVPCMachine %s/%s: %w", machineScope.IBMVPCMachine.Namespace, machineScope.IBMVPCMachine.Name, err) @@ -187,9 +254,11 @@ func (r *IBMVPCMachineReconciler) reconcileNormal(machineScope *scope.MachineSco return ctrl.Result{RequeueAfter: 1 * time.Minute}, nil } } - machineScope.IBMVPCMachine.Status.Ready = true } + // With a running machine and all Load Balancer Pool Members reconciled, mark machine as ready. + machineScope.SetReady() + conditions.MarkTrue(machineScope.IBMVPCMachine, infrav1beta2.InstanceReadyCondition) return ctrl.Result{}, nil } diff --git a/controllers/ibmvpcmachine_controller_test.go b/controllers/ibmvpcmachine_controller_test.go index 27f4892ab..f72ad601f 100644 --- a/controllers/ibmvpcmachine_controller_test.go +++ b/controllers/ibmvpcmachine_controller_test.go @@ -23,6 +23,7 @@ import ( "time" "github.com/IBM/go-sdk-core/v5/core" + "github.com/IBM/platform-services-go-sdk/globaltaggingv1" "github.com/IBM/vpc-go-sdk/vpcv1" "go.uber.org/mock/gomock" @@ -37,7 +38,8 @@ import ( infrav1beta2 "sigs.k8s.io/cluster-api-provider-ibmcloud/api/v1beta2" "sigs.k8s.io/cluster-api-provider-ibmcloud/cloud/scope" - "sigs.k8s.io/cluster-api-provider-ibmcloud/pkg/cloud/services/vpc/mock" + gtmock "sigs.k8s.io/cluster-api-provider-ibmcloud/pkg/cloud/services/globaltagging/mock" + vpcmock "sigs.k8s.io/cluster-api-provider-ibmcloud/pkg/cloud/services/vpc/mock" . "github.com/onsi/gomega" ) @@ -211,7 +213,7 @@ func TestIBMVPCMachineReconciler_Reconcile(t *testing.T) { func TestIBMVPCMachineReconciler_reconcile(t *testing.T) { var ( - mockvpc *mock.MockVpc + mockvpc *vpcmock.MockVpc mockCtrl *gomock.Controller machineScope *scope.MachineScope reconciler IBMVPCMachineReconciler @@ -220,7 +222,7 @@ func TestIBMVPCMachineReconciler_reconcile(t *testing.T) { setup := func(t *testing.T) { t.Helper() mockCtrl = gomock.NewController(t) - mockvpc = mock.NewMockVpc(mockCtrl) + mockvpc = vpcmock.NewMockVpc(mockCtrl) reconciler = IBMVPCMachineReconciler{ Client: testEnv.Client, Log: klog.Background(), @@ -276,9 +278,10 @@ func TestIBMVPCMachineReconciler_reconcile(t *testing.T) { } func TestIBMVPCMachineLBReconciler_reconcile(t *testing.T) { - setup := func(t *testing.T) (*gomock.Controller, *mock.MockVpc, *scope.MachineScope, IBMVPCMachineReconciler) { + setup := func(t *testing.T) (*gomock.Controller, *vpcmock.MockVpc, *gtmock.MockGlobalTagging, *scope.MachineScope, IBMVPCMachineReconciler) { t.Helper() - mockvpc := mock.NewMockVpc(gomock.NewController(t)) + mockvpc := vpcmock.NewMockVpc(gomock.NewController(t)) + mockgt := gtmock.NewMockGlobalTagging(gomock.NewController(t)) reconciler := IBMVPCMachineReconciler{ Client: testEnv.Client, Log: klog.Background(), @@ -317,10 +320,11 @@ func TestIBMVPCMachineLBReconciler_reconcile(t *testing.T) { }, }, }, - Cluster: &capiv1beta1.Cluster{}, - IBMVPCClient: mockvpc, + Cluster: &capiv1beta1.Cluster{}, + IBMVPCClient: mockvpc, + GlobalTaggingClient: mockgt, } - return gomock.NewController(t), mockvpc, machineScope, reconciler + return gomock.NewController(t), mockvpc, mockgt, machineScope, reconciler } t.Run("Reconcile creating IBMVPCMachine associated with LoadBalancer", func(t *testing.T) { @@ -329,12 +333,14 @@ func TestIBMVPCMachineLBReconciler_reconcile(t *testing.T) { { Name: ptr.To("capi-machine"), ID: ptr.To("capi-machine-id"), + CRN: ptr.To("capi-machine-crn"), PrimaryNetworkInterface: &vpcv1.NetworkInterfaceInstanceContextReference{ PrimaryIP: &vpcv1.ReservedIPReference{ Address: ptr.To("192.129.11.50"), }, ID: ptr.To("capi-net"), }, + Status: ptr.To(vpcv1.InstanceStatusRunningConst), }, }, } @@ -347,81 +353,222 @@ func TestIBMVPCMachineLBReconciler_reconcile(t *testing.T) { }, }, } + existingTag := &globaltaggingv1.Tag{ + Name: ptr.To("capi-cluster"), + } t.Run("Invalid primary ip address", func(t *testing.T) { g := NewWithT(t) - mockController, mockvpc, machineScope, reconciler := setup(t) + mockController, mockvpc, mockgt, machineScope, reconciler := setup(t) t.Cleanup(mockController.Finish) + customInstancelist := &vpcv1.InstanceCollection{ Instances: []vpcv1.Instance{ { Name: ptr.To("capi-machine"), ID: ptr.To("capi-machine-id"), + CRN: ptr.To("capi-machine-crn"), PrimaryNetworkInterface: &vpcv1.NetworkInterfaceInstanceContextReference{ PrimaryIP: &vpcv1.ReservedIPReference{ Address: ptr.To("0.0.0.0"), }, ID: ptr.To("capi-net"), }, + Status: ptr.To(vpcv1.InstanceStatusRunningConst), }, }, } mockvpc.EXPECT().ListInstances(gomock.AssignableToTypeOf(&vpcv1.ListInstancesOptions{})).Return(customInstancelist, &core.DetailedResponse{}, nil) + mockgt.EXPECT().GetTagByName(gomock.AssignableToTypeOf("capi-cluster")).Return(existingTag, nil) + mockgt.EXPECT().AttachTag(gomock.AssignableToTypeOf(&globaltaggingv1.AttachTagOptions{})).Return(nil, &core.DetailedResponse{}, nil) + _, err := reconciler.reconcileNormal(machineScope) g.Expect(err).To((Not(BeNil()))) g.Expect(machineScope.IBMVPCMachine.Finalizers).To(ContainElement(infrav1beta2.MachineFinalizer)) }) t.Run("Should fail to bind loadBalancer IP to control plane", func(t *testing.T) { g := NewWithT(t) - mockController, mockvpc, machineScope, reconciler := setup(t) + mockController, mockvpc, mockgt, machineScope, reconciler := setup(t) t.Cleanup(mockController.Finish) + mockvpc.EXPECT().ListInstances(gomock.AssignableToTypeOf(&vpcv1.ListInstancesOptions{})).Return(instancelist, &core.DetailedResponse{}, nil) + mockgt.EXPECT().GetTagByName(gomock.AssignableToTypeOf("capi-cluster")).Return(existingTag, nil) + mockgt.EXPECT().AttachTag(gomock.AssignableToTypeOf(&globaltaggingv1.AttachTagOptions{})).Return(nil, &core.DetailedResponse{}, nil) mockvpc.EXPECT().GetLoadBalancer(gomock.AssignableToTypeOf(&vpcv1.GetLoadBalancerOptions{})).Return(loadBalancer, &core.DetailedResponse{}, nil) mockvpc.EXPECT().ListLoadBalancerPoolMembers(gomock.AssignableToTypeOf(&vpcv1.ListLoadBalancerPoolMembersOptions{})).Return(&vpcv1.LoadBalancerPoolMemberCollection{}, &core.DetailedResponse{}, errors.New("failed to list loadBalancerPoolMembers")) + _, err := reconciler.reconcileNormal(machineScope) g.Expect(err).To(Not(BeNil())) g.Expect(machineScope.IBMVPCMachine.Finalizers).To(ContainElement(infrav1beta2.MachineFinalizer)) }) - t.Run("Should successfully reconcile IBMVPCMachine and set machine status as NotReady when PoolMember is not in active state", func(t *testing.T) { + t.Run("Should successfully reconcile IBMVPCMachine but its status should be set to Not Ready when the PoolMember is not yet in the active state requiring a requeue", func(t *testing.T) { g := NewWithT(t) - mockController, mockvpc, machineScope, reconciler := setup(t) + mockController, mockvpc, mockgt, machineScope, reconciler := setup(t) t.Cleanup(mockController.Finish) customloadBalancerPoolMember := &vpcv1.LoadBalancerPoolMember{ ID: core.StringPtr("foo-member-id"), ProvisioningStatus: core.StringPtr("create_pending"), } + mockvpc.EXPECT().ListInstances(gomock.AssignableToTypeOf(&vpcv1.ListInstancesOptions{})).Return(instancelist, &core.DetailedResponse{}, nil) + mockgt.EXPECT().GetTagByName(gomock.AssignableToTypeOf("capi-cluster")).Return(existingTag, nil) + mockgt.EXPECT().AttachTag(gomock.AssignableToTypeOf(&globaltaggingv1.AttachTagOptions{})).Return(nil, &core.DetailedResponse{}, nil) mockvpc.EXPECT().GetLoadBalancer(gomock.AssignableToTypeOf(&vpcv1.GetLoadBalancerOptions{})).Return(loadBalancer, &core.DetailedResponse{}, nil) mockvpc.EXPECT().ListLoadBalancerPoolMembers(gomock.AssignableToTypeOf(&vpcv1.ListLoadBalancerPoolMembersOptions{})).Return(&vpcv1.LoadBalancerPoolMemberCollection{}, &core.DetailedResponse{}, nil) mockvpc.EXPECT().CreateLoadBalancerPoolMember(gomock.AssignableToTypeOf(&vpcv1.CreateLoadBalancerPoolMemberOptions{})).Return(customloadBalancerPoolMember, &core.DetailedResponse{}, nil) - _, err := reconciler.reconcileNormal(machineScope) + + result, err := reconciler.reconcileNormal(machineScope) + // Requeue should be set when the Pool Member is found, but not yet ready (active). + g.Expect(result.RequeueAfter).To(Not(BeZero())) g.Expect(err).To(BeNil()) g.Expect(machineScope.IBMVPCMachine.Finalizers).To(ContainElement(infrav1beta2.MachineFinalizer)) + // Machine Status should not be ready (running but LB Member Pools not active). g.Expect(machineScope.IBMVPCMachine.Status.Ready).To(Equal(false)) }) t.Run("Should successfully reconcile IBMVPCMachine", func(t *testing.T) { g := NewWithT(t) - mockController, mockvpc, machineScope, reconciler := setup(t) + mockController, mockvpc, mockgt, machineScope, reconciler := setup(t) t.Cleanup(mockController.Finish) loadBalancerPoolMember := &vpcv1.LoadBalancerPoolMember{ ID: core.StringPtr("foo-member-id"), ProvisioningStatus: core.StringPtr("active"), } + mockvpc.EXPECT().ListInstances(gomock.AssignableToTypeOf(&vpcv1.ListInstancesOptions{})).Return(instancelist, &core.DetailedResponse{}, nil) + mockgt.EXPECT().GetTagByName(gomock.AssignableToTypeOf("capi-cluster")).Return(existingTag, nil) + mockgt.EXPECT().AttachTag(gomock.AssignableToTypeOf(&globaltaggingv1.AttachTagOptions{})).Return(nil, &core.DetailedResponse{}, nil) mockvpc.EXPECT().GetLoadBalancer(gomock.AssignableToTypeOf(&vpcv1.GetLoadBalancerOptions{})).Return(loadBalancer, &core.DetailedResponse{}, nil) mockvpc.EXPECT().ListLoadBalancerPoolMembers(gomock.AssignableToTypeOf(&vpcv1.ListLoadBalancerPoolMembersOptions{})).Return(&vpcv1.LoadBalancerPoolMemberCollection{}, &core.DetailedResponse{}, nil) mockvpc.EXPECT().CreateLoadBalancerPoolMember(gomock.AssignableToTypeOf(&vpcv1.CreateLoadBalancerPoolMemberOptions{})).Return(loadBalancerPoolMember, &core.DetailedResponse{}, nil) + _, err := reconciler.reconcileNormal(machineScope) g.Expect(err).To(BeNil()) g.Expect(machineScope.IBMVPCMachine.Finalizers).To(ContainElement(infrav1beta2.MachineFinalizer)) g.Expect(machineScope.IBMVPCMachine.Status.Ready).To(Equal(true)) }) + + t.Run("Should reconcile IBMVPCMachine instance creation in different states", func(t *testing.T) { + g := NewWithT(t) + mockController, mockvpc, mockgt, machineScope, reconciler := setup(t) + t.Cleanup(mockController.Finish) + + loadBalancerPoolMember := &vpcv1.LoadBalancerPoolMember{ + ID: core.StringPtr("foo-member-id"), + ProvisioningStatus: core.StringPtr("active"), + } + + // Mocks setup for each test (4) below. + mockgt.EXPECT().GetTagByName(gomock.AssignableToTypeOf("capi-cluster")).Return(existingTag, nil).MaxTimes(4) + mockgt.EXPECT().AttachTag(gomock.AssignableToTypeOf(&globaltaggingv1.AttachTagOptions{})).Return(nil, &core.DetailedResponse{}, nil).MaxTimes(4) + mockvpc.EXPECT().GetLoadBalancer(gomock.AssignableToTypeOf(&vpcv1.GetLoadBalancerOptions{})).Return(loadBalancer, &core.DetailedResponse{}, nil).MaxTimes(4) + mockvpc.EXPECT().ListLoadBalancerPoolMembers(gomock.AssignableToTypeOf(&vpcv1.ListLoadBalancerPoolMembersOptions{})).Return(&vpcv1.LoadBalancerPoolMemberCollection{}, &core.DetailedResponse{}, nil).MaxTimes(4) + mockvpc.EXPECT().CreateLoadBalancerPoolMember(gomock.AssignableToTypeOf(&vpcv1.CreateLoadBalancerPoolMemberOptions{})).Return(loadBalancerPoolMember, &core.DetailedResponse{}, nil).MaxTimes(4) + + t.Run("When VPC instance is pending", func(_ *testing.T) { + customInstancelist := &vpcv1.InstanceCollection{ + Instances: []vpcv1.Instance{ + { + Name: ptr.To("capi-machine"), + ID: ptr.To("capi-machine-id"), + CRN: ptr.To("capi-machine-crn"), + PrimaryNetworkInterface: &vpcv1.NetworkInterfaceInstanceContextReference{ + PrimaryIP: &vpcv1.ReservedIPReference{ + Address: ptr.To("10.0.0.0"), + }, + ID: ptr.To("capi-net"), + }, + Status: ptr.To(vpcv1.InstanceStatusPendingConst), + }, + }, + } + mockvpc.EXPECT().ListInstances(gomock.AssignableToTypeOf(&vpcv1.ListInstancesOptions{})).Return(customInstancelist, &core.DetailedResponse{}, nil) + + result, err := reconciler.reconcileNormal(machineScope) + g.Expect(err).To(BeNil()) + g.Expect(result.RequeueAfter).To(Not(BeZero())) + g.Expect(machineScope.IBMVPCMachine.Status.Ready).To(Equal(false)) + }) + + t.Run("When VPC instance is running", func(_ *testing.T) { + customInstancelist := &vpcv1.InstanceCollection{ + Instances: []vpcv1.Instance{ + { + Name: ptr.To("capi-machine"), + ID: ptr.To("capi-machine-id"), + CRN: ptr.To("capi-machine-crn"), + PrimaryNetworkInterface: &vpcv1.NetworkInterfaceInstanceContextReference{ + PrimaryIP: &vpcv1.ReservedIPReference{ + Address: ptr.To("10.0.0.0"), + }, + ID: ptr.To("capi-net"), + }, + Status: ptr.To(vpcv1.InstanceStatusRunningConst), + }, + }, + } + mockvpc.EXPECT().ListInstances(gomock.AssignableToTypeOf(&vpcv1.ListInstancesOptions{})).Return(customInstancelist, &core.DetailedResponse{}, nil) + + _, err := reconciler.reconcileNormal(machineScope) + g.Expect(err).To(BeNil()) + g.Expect(machineScope.IBMVPCMachine.Status.Ready).To(Equal(true)) + }) + + t.Run("When VPC instance is stopped", func(_ *testing.T) { + customInstancelist := &vpcv1.InstanceCollection{ + Instances: []vpcv1.Instance{ + { + Name: ptr.To("capi-machine"), + ID: ptr.To("capi-machine-id"), + CRN: ptr.To("capi-machine-crn"), + PrimaryNetworkInterface: &vpcv1.NetworkInterfaceInstanceContextReference{ + PrimaryIP: &vpcv1.ReservedIPReference{ + Address: ptr.To("10.0.0.0"), + }, + ID: ptr.To("capi-net"), + }, + Status: ptr.To(vpcv1.InstanceStatusStoppedConst), + }, + }, + } + mockvpc.EXPECT().ListInstances(gomock.AssignableToTypeOf(&vpcv1.ListInstancesOptions{})).Return(customInstancelist, &core.DetailedResponse{}, nil) + + result, err := reconciler.reconcileNormal(machineScope) + g.Expect(err).To(BeNil()) + g.Expect(result.RequeueAfter).To(Not(BeZero())) + g.Expect(machineScope.IBMVPCMachine.Status.Ready).To(Equal(false)) + }) + + t.Run("When VPC instance is failed", func(_ *testing.T) { + customInstancelist := &vpcv1.InstanceCollection{ + Instances: []vpcv1.Instance{ + { + Name: ptr.To("capi-machine"), + ID: ptr.To("capi-machine-id"), + CRN: ptr.To("capi-machine-crn"), + PrimaryNetworkInterface: &vpcv1.NetworkInterfaceInstanceContextReference{ + PrimaryIP: &vpcv1.ReservedIPReference{ + Address: ptr.To("10.0.0.0"), + }, + ID: ptr.To("capi-net"), + }, + Status: ptr.To(vpcv1.InstanceStatusFailedConst), + }, + }, + } + mockvpc.EXPECT().ListInstances(gomock.AssignableToTypeOf(&vpcv1.ListInstancesOptions{})).Return(customInstancelist, &core.DetailedResponse{}, nil) + + result, err := reconciler.reconcileNormal(machineScope) + g.Expect(err).To(BeNil()) + g.Expect(result.RequeueAfter).To(BeZero()) + g.Expect(machineScope.IBMVPCMachine.Status.Ready).To(Equal(false)) + }) + }) }) } func TestIBMVPCMachineReconciler_Delete(t *testing.T) { var ( - mockvpc *mock.MockVpc + mockvpc *vpcmock.MockVpc mockCtrl *gomock.Controller machineScope *scope.MachineScope reconciler IBMVPCMachineReconciler @@ -430,7 +577,7 @@ func TestIBMVPCMachineReconciler_Delete(t *testing.T) { setup := func(t *testing.T) { t.Helper() mockCtrl = gomock.NewController(t) - mockvpc = mock.NewMockVpc(mockCtrl) + mockvpc = vpcmock.NewMockVpc(mockCtrl) reconciler = IBMVPCMachineReconciler{ Client: testEnv.Client, Log: klog.Background(), @@ -478,9 +625,9 @@ func TestIBMVPCMachineReconciler_Delete(t *testing.T) { } func TestIBMVPCMachineLBReconciler_Delete(t *testing.T) { - setup := func(t *testing.T) (*gomock.Controller, *mock.MockVpc, *scope.MachineScope, IBMVPCMachineReconciler) { + setup := func(t *testing.T) (*gomock.Controller, *vpcmock.MockVpc, *scope.MachineScope, IBMVPCMachineReconciler) { t.Helper() - mockvpc := mock.NewMockVpc(gomock.NewController(t)) + mockvpc := vpcmock.NewMockVpc(gomock.NewController(t)) reconciler := IBMVPCMachineReconciler{ Client: testEnv.Client, Log: klog.Background(), diff --git a/pkg/cloud/services/vpc/mock/vpc_generated.go b/pkg/cloud/services/vpc/mock/vpc_generated.go index cb49b89a8..baa495664 100644 --- a/pkg/cloud/services/vpc/mock/vpc_generated.go +++ b/pkg/cloud/services/vpc/mock/vpc_generated.go @@ -304,6 +304,21 @@ func (mr *MockVpcMockRecorder) DeleteVPC(options any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteVPC", reflect.TypeOf((*MockVpc)(nil).DeleteVPC), options) } +// GetDedicatedHostByName mocks base method. +func (m *MockVpc) GetDedicatedHostByName(dHostName string) (*vpcv1.DedicatedHost, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetDedicatedHostByName", dHostName) + ret0, _ := ret[0].(*vpcv1.DedicatedHost) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetDedicatedHostByName indicates an expected call of GetDedicatedHostByName. +func (mr *MockVpcMockRecorder) GetDedicatedHostByName(dHostName any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDedicatedHostByName", reflect.TypeOf((*MockVpc)(nil).GetDedicatedHostByName), dHostName) +} + // GetImage mocks base method. func (m *MockVpc) GetImage(options *vpcv1.GetImageOptions) (*vpcv1.Image, *core.DetailedResponse, error) { m.ctrl.T.Helper() @@ -398,6 +413,21 @@ func (mr *MockVpcMockRecorder) GetLoadBalancerByName(loadBalancerName any) *gomo return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetLoadBalancerByName", reflect.TypeOf((*MockVpc)(nil).GetLoadBalancerByName), loadBalancerName) } +// GetLoadBalancerPoolByName mocks base method. +func (m *MockVpc) GetLoadBalancerPoolByName(loadBalancerID, poolName string) (*vpcv1.LoadBalancerPool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetLoadBalancerPoolByName", loadBalancerID, poolName) + ret0, _ := ret[0].(*vpcv1.LoadBalancerPool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetLoadBalancerPoolByName indicates an expected call of GetLoadBalancerPoolByName. +func (mr *MockVpcMockRecorder) GetLoadBalancerPoolByName(loadBalancerID, poolName any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetLoadBalancerPoolByName", reflect.TypeOf((*MockVpc)(nil).GetLoadBalancerPoolByName), loadBalancerID, poolName) +} + // GetSecurityGroup mocks base method. func (m *MockVpc) GetSecurityGroup(options *vpcv1.GetSecurityGroupOptions) (*vpcv1.SecurityGroup, *core.DetailedResponse, error) { m.ctrl.T.Helper() diff --git a/pkg/cloud/services/vpc/service.go b/pkg/cloud/services/vpc/service.go index 5134edb17..0bba96038 100644 --- a/pkg/cloud/services/vpc/service.go +++ b/pkg/cloud/services/vpc/service.go @@ -60,6 +60,45 @@ func (s *Service) ListInstances(options *vpcv1.ListInstancesOptions) (*vpcv1.Ins return s.vpcService.ListInstances(options) } +// GetDedicatedHostByName returns Dedicated Host with given name. If not found, returns nil. +func (s *Service) GetDedicatedHostByName(dHostName string) (*vpcv1.DedicatedHost, error) { + var dHost *vpcv1.DedicatedHost + f := func(start string) (bool, string, error) { + // check for existing Dedicated Hosts + listDedicatedHostsOptions := &vpcv1.ListDedicatedHostsOptions{} + if start != "" { + listDedicatedHostsOptions.Start = &start + } + + dHostsList, _, err := s.vpcService.ListDedicatedHosts(listDedicatedHostsOptions) + if err != nil { + return false, "", err + } + + if dHostsList == nil { + return false, "", fmt.Errorf("dedicated hosts list returned is nil") + } + + for index, dH := range dHostsList.DedicatedHosts { + if *dH.Name == dHostName { + dHost = &dHostsList.DedicatedHosts[index] + return true, "", nil + } + } + + if dHostsList.Next != nil && *dHostsList.Next.Href != "" { + return false, *dHostsList.Next.Href, nil + } + return true, "", nil + } + + if err := utils.PagingHelper(f); err != nil { + return nil, err + } + + return dHost, nil +} + // CreateVPC creates a new VPC. func (s *Service) CreateVPC(options *vpcv1.CreateVPCOptions) (*vpcv1.VPC, *core.DetailedResponse, error) { return s.vpcService.CreateVPC(options) @@ -351,6 +390,27 @@ func (s *Service) GetVPCSubnetByName(subnetName string) (*vpcv1.Subnet, error) { return subnet, nil } +// GetLoadBalancerPoolByName returns a Load Balancer Pool with the given name, in the provided Load Balancer. If not found, returns nil. +func (s *Service) GetLoadBalancerPoolByName(loadBalancerID string, poolName string) (*vpcv1.LoadBalancerPool, error) { + listLoadBalancerPoolsOptions := &vpcv1.ListLoadBalancerPoolsOptions{} + listLoadBalancerPoolsOptions.SetLoadBalancerID(loadBalancerID) + + pools, _, err := s.vpcService.ListLoadBalancerPools(listLoadBalancerPoolsOptions) + if err != nil { + return nil, fmt.Errorf("error listing pools for load balancer %s: %w", loadBalancerID, err) + } else if pools == nil { + return nil, fmt.Errorf("error no pools for load balancer: %s", loadBalancerID) + } + + for _, pool := range pools.Pools { + if pool.Name != nil && *pool.Name == poolName { + return &pool, nil + } + } + + return nil, nil +} + // GetLoadBalancerByName returns loadBalancer with given name. If not found, returns nil. func (s *Service) GetLoadBalancerByName(loadBalancerName string) (*vpcv1.LoadBalancer, error) { var loadBalancer *vpcv1.LoadBalancer diff --git a/pkg/cloud/services/vpc/vpc.go b/pkg/cloud/services/vpc/vpc.go index 63202a47e..88ee4a1fa 100644 --- a/pkg/cloud/services/vpc/vpc.go +++ b/pkg/cloud/services/vpc/vpc.go @@ -30,6 +30,7 @@ type Vpc interface { DeleteInstance(options *vpcv1.DeleteInstanceOptions) (*core.DetailedResponse, error) GetInstance(options *vpcv1.GetInstanceOptions) (*vpcv1.Instance, *core.DetailedResponse, error) ListInstances(options *vpcv1.ListInstancesOptions) (*vpcv1.InstanceCollection, *core.DetailedResponse, error) + GetDedicatedHostByName(dHostName string) (*vpcv1.DedicatedHost, error) CreateVPC(options *vpcv1.CreateVPCOptions) (*vpcv1.VPC, *core.DetailedResponse, error) DeleteVPC(options *vpcv1.DeleteVPCOptions) (response *core.DetailedResponse, err error) ListVpcs(options *vpcv1.ListVpcsOptions) (*vpcv1.VPCCollection, *core.DetailedResponse, error) @@ -61,6 +62,7 @@ type Vpc interface { GetVPCPublicGatewayByName(publicGatewayName string, resourceGroupID string) (*vpcv1.PublicGateway, error) GetSubnet(*vpcv1.GetSubnetOptions) (*vpcv1.Subnet, *core.DetailedResponse, error) GetVPCSubnetByName(subnetName string) (*vpcv1.Subnet, error) + GetLoadBalancerPoolByName(loadBalancerID string, poolName string) (*vpcv1.LoadBalancerPool, error) GetLoadBalancerByName(loadBalancerName string) (*vpcv1.LoadBalancer, error) GetSubnetAddrPrefix(vpcID, zone string) (string, error) CreateSecurityGroup(options *vpcv1.CreateSecurityGroupOptions) (*vpcv1.SecurityGroup, *core.DetailedResponse, error)