diff --git a/pkg/asset/cluster/cluster.go b/pkg/asset/cluster/cluster.go index 02c13b5974f..88087fb2b95 100644 --- a/pkg/asset/cluster/cluster.go +++ b/pkg/asset/cluster/cluster.go @@ -77,6 +77,7 @@ func (c *Cluster) Dependencies() []asset.Asset { &machine.Worker{}, &machines.Worker{}, &machines.ClusterAPI{}, + &machines.MasterMAPISync{}, new(rhcos.Image), &manifests.Manifests{}, &tls.RootCA{}, diff --git a/pkg/asset/machines/mastermapisync.go b/pkg/asset/machines/mastermapisync.go new file mode 100644 index 00000000000..56c2029a0c9 --- /dev/null +++ b/pkg/asset/machines/mastermapisync.go @@ -0,0 +1,307 @@ +package machines + +import ( + "context" + "fmt" + "strings" + + "github.com/sirupsen/logrus" + "k8s.io/utils/ptr" + capa "sigs.k8s.io/cluster-api-provider-aws/v2/api/v1beta2" + "sigs.k8s.io/yaml" + + machinev1beta1 "github.com/openshift/api/machine/v1beta1" + "github.com/openshift/installer/pkg/asset" + "github.com/openshift/installer/pkg/asset/installconfig" + "github.com/openshift/installer/pkg/asset/manifests/capiutils" + awstypes "github.com/openshift/installer/pkg/types/aws" +) + +// MasterMAPISync validates and syncs provisioning-relevant fields from +// MAPI master machine manifests (openshift/) to CAPI AWSMachine manifests +// (cluster-api/machines/) when drift is detected. This prevents silent +// misconfiguration when users edit openshift/ master manifests after +// "create manifests" but before "create cluster". +type MasterMAPISync struct{} + +var _ asset.Asset = (*MasterMAPISync)(nil) + +// Name returns the human-friendly name of the asset. +func (a *MasterMAPISync) Name() string { + return "Master Machine MAPI-to-CAPI Sync" +} + +// Dependencies returns the assets upon which this asset directly depends. +func (a *MasterMAPISync) Dependencies() []asset.Asset { + return []asset.Asset{ + &installconfig.InstallConfig{}, + &Master{}, + &ClusterAPI{}, + } +} + +// Generate compares MAPI and CAPI master machine specs and syncs +// provisioning-relevant fields from MAPI to CAPI when drift is detected. +func (a *MasterMAPISync) Generate(ctx context.Context, dependencies asset.Parents) error { + ic := &installconfig.InstallConfig{} + mastersAsset := &Master{} + capiAsset := &ClusterAPI{} + dependencies.Get(ic, mastersAsset, capiAsset) + + if !capiutils.IsEnabled(ic) { + return nil + } + + platform := ic.Config.Platform.Name() + if platform != awstypes.Name { + return nil + } + + masters, err := mastersAsset.Machines() + if err != nil { + logrus.Debugf("MasterMAPISync: skipping, could not parse MAPI machines: %v", err) + return nil + } + if len(masters) == 0 { + return nil + } + + capiFiles := capiAsset.RuntimeFiles() + if len(capiFiles) == 0 { + return nil + } + + awsMachines := indexAWSMachinesByName(capiFiles) + if len(awsMachines) == 0 { + return nil + } + + var driftMessages []string + + for i, m := range masters { + mapiConfig, ok := m.Spec.ProviderSpec.Value.Object.(*machinev1beta1.AWSMachineProviderConfig) + if !ok || mapiConfig == nil { + continue + } + + machineName := m.Name + awsMachine, found := awsMachines[machineName] + if !found { + awsMachine, found = findAWSMachineByIndex(awsMachines, i) + if !found { + continue + } + } + + drifts := syncAWSFields(mapiConfig, awsMachine) + if len(drifts) > 0 { + driftMessages = append(driftMessages, fmt.Sprintf(" Machine %s:", machineName)) + driftMessages = append(driftMessages, drifts...) + } + } + + if len(driftMessages) > 0 { + logrus.Warnf("Detected drift between MAPI master machine manifests (openshift/) and CAPI machine manifests (cluster-api/machines/).\n%s\n"+ + " Syncing MAPI values to CAPI manifests for provisioning.\n"+ + " To avoid this warning, set values in install-config.yaml (controlPlane.platform.aws) before 'create manifests',\n"+ + " or edit cluster-api/machines/10_inframachine_*-master-*.yaml directly.", + strings.Join(driftMessages, "\n")) + + for _, rf := range capiFiles { + if am, ok := rf.Object.(*capa.AWSMachine); ok { + if !strings.Contains(am.Name, "-master-") { + continue + } + objData, err := yaml.Marshal(rf.Object) + if err != nil { + logrus.Debugf("MasterMAPISync: failed to re-serialize AWSMachine %s: %v", am.Name, err) + continue + } + rf.Data = objData + } + } + } + + return nil +} + +// syncAWSFields compares and syncs provisioning-relevant fields from MAPI +// AWSMachineProviderConfig to CAPI AWSMachine. Returns a list of drift +// description strings for logging. +func syncAWSFields(mapi *machinev1beta1.AWSMachineProviderConfig, capi *capa.AWSMachine) []string { + var drifts []string + + if mapi.InstanceType != "" && mapi.InstanceType != capi.Spec.InstanceType { + drifts = append(drifts, fmt.Sprintf(" instanceType: openshift/ has %q, cluster-api/ has %q → syncing", mapi.InstanceType, capi.Spec.InstanceType)) + capi.Spec.InstanceType = mapi.InstanceType + } + + if mapi.AMI.ID != nil && *mapi.AMI.ID != "" { + capiAMI := ptr.Deref(capi.Spec.AMI.ID, "") + if *mapi.AMI.ID != capiAMI { + drifts = append(drifts, fmt.Sprintf(" ami.id: openshift/ has %q, cluster-api/ has %q → syncing", *mapi.AMI.ID, capiAMI)) + capi.Spec.AMI.ID = mapi.AMI.ID + } + } + + if len(mapi.BlockDevices) > 0 && mapi.BlockDevices[0].EBS != nil { + ebs := mapi.BlockDevices[0].EBS + if capi.Spec.RootVolume == nil { + capi.Spec.RootVolume = &capa.Volume{} + } + + if ebs.VolumeSize != nil && *ebs.VolumeSize != capi.Spec.RootVolume.Size { + drifts = append(drifts, fmt.Sprintf(" rootVolume.size: openshift/ has %d, cluster-api/ has %d → syncing", *ebs.VolumeSize, capi.Spec.RootVolume.Size)) + capi.Spec.RootVolume.Size = *ebs.VolumeSize + } + + if ebs.VolumeType != nil && *ebs.VolumeType != "" { + capiType := string(capi.Spec.RootVolume.Type) + if *ebs.VolumeType != capiType { + drifts = append(drifts, fmt.Sprintf(" rootVolume.type: openshift/ has %q, cluster-api/ has %q → syncing", *ebs.VolumeType, capiType)) + capi.Spec.RootVolume.Type = capa.VolumeType(*ebs.VolumeType) + } + } + + if ebs.Iops != nil { + if *ebs.Iops != capi.Spec.RootVolume.IOPS { + drifts = append(drifts, fmt.Sprintf(" rootVolume.iops: openshift/ has %d, cluster-api/ has %d → syncing", *ebs.Iops, capi.Spec.RootVolume.IOPS)) + capi.Spec.RootVolume.IOPS = *ebs.Iops + } + } + + if ebs.ThroughputMib != nil { + capiThroughput := ptr.Deref(capi.Spec.RootVolume.Throughput, 0) + mapiThroughput := int64(*ebs.ThroughputMib) + if mapiThroughput != capiThroughput { + drifts = append(drifts, fmt.Sprintf(" rootVolume.throughput: openshift/ has %d, cluster-api/ has %d → syncing", mapiThroughput, capiThroughput)) + capi.Spec.RootVolume.Throughput = ptr.To(mapiThroughput) + } + } + + kmsARN := ptr.Deref(ebs.KMSKey.ARN, "") + kmsID := ptr.Deref(ebs.KMSKey.ID, "") + mapiKMS := kmsARN + if mapiKMS == "" { + mapiKMS = kmsID + } + if mapiKMS != "" && mapiKMS != capi.Spec.RootVolume.EncryptionKey { + drifts = append(drifts, fmt.Sprintf(" rootVolume.encryptionKey: openshift/ has %q, cluster-api/ has %q → syncing", mapiKMS, capi.Spec.RootVolume.EncryptionKey)) + capi.Spec.RootVolume.EncryptionKey = mapiKMS + } + } + + if mapi.IAMInstanceProfile != nil && mapi.IAMInstanceProfile.ID != nil && *mapi.IAMInstanceProfile.ID != "" { + if *mapi.IAMInstanceProfile.ID != capi.Spec.IAMInstanceProfile { + drifts = append(drifts, fmt.Sprintf(" iamInstanceProfile: openshift/ has %q, cluster-api/ has %q → syncing", *mapi.IAMInstanceProfile.ID, capi.Spec.IAMInstanceProfile)) + capi.Spec.IAMInstanceProfile = *mapi.IAMInstanceProfile.ID + } + } + + if mapi.MetadataServiceOptions.Authentication != "" { + if capi.Spec.InstanceMetadataOptions == nil { + capi.Spec.InstanceMetadataOptions = &capa.InstanceMetadataOptions{} + } + mapiTokens := strings.ToLower(string(mapi.MetadataServiceOptions.Authentication)) + capiTokens := string(capi.Spec.InstanceMetadataOptions.HTTPTokens) + if mapiTokens != capiTokens { + drifts = append(drifts, fmt.Sprintf(" instanceMetadataOptions.httpTokens: openshift/ has %q, cluster-api/ has %q → syncing", mapiTokens, capiTokens)) + capi.Spec.InstanceMetadataOptions.HTTPTokens = capa.HTTPTokensState(mapiTokens) + } + } + + // CPUOptions / ConfidentialCompute + if mapi.CPUOptions != nil && mapi.CPUOptions.ConfidentialCompute != nil && *mapi.CPUOptions.ConfidentialCompute != "" { + mapiCC := capa.AWSConfidentialComputePolicy(*mapi.CPUOptions.ConfidentialCompute) + if mapiCC != capi.Spec.CPUOptions.ConfidentialCompute { + drifts = append(drifts, fmt.Sprintf(" cpuOptions.confidentialCompute: openshift/ has %q, cluster-api/ has %q → syncing", mapiCC, capi.Spec.CPUOptions.ConfidentialCompute)) + capi.Spec.CPUOptions.ConfidentialCompute = mapiCC + } + } + + if mapi.PublicIP != nil { + capiPublicIP := ptr.Deref(capi.Spec.PublicIP, false) + if *mapi.PublicIP != capiPublicIP { + drifts = append(drifts, fmt.Sprintf(" publicIP: openshift/ has %v, cluster-api/ has %v → syncing", *mapi.PublicIP, capiPublicIP)) + capi.Spec.PublicIP = mapi.PublicIP + } + } + + syncSubnet(mapi, capi, &drifts) + + return drifts +} + +func syncSubnet(mapi *machinev1beta1.AWSMachineProviderConfig, capi *capa.AWSMachine, drifts *[]string) { + if mapi.Subnet.ID != nil && *mapi.Subnet.ID != "" { + if capi.Spec.Subnet == nil { + capi.Spec.Subnet = &capa.AWSResourceReference{} + } + capiSubnetID := ptr.Deref(capi.Spec.Subnet.ID, "") + if *mapi.Subnet.ID != capiSubnetID { + *drifts = append(*drifts, fmt.Sprintf(" subnet.id: openshift/ has %q, cluster-api/ has %q → syncing", *mapi.Subnet.ID, capiSubnetID)) + capi.Spec.Subnet.ID = mapi.Subnet.ID + capi.Spec.Subnet.Filters = nil + } + } else if len(mapi.Subnet.Filters) > 0 { + if capi.Spec.Subnet == nil { + capi.Spec.Subnet = &capa.AWSResourceReference{} + } + mapiFilterStr := formatMAPIFilters(mapi.Subnet.Filters) + capiFilterStr := formatCAPIFilters(capi.Spec.Subnet.Filters) + if mapiFilterStr != capiFilterStr { + *drifts = append(*drifts, fmt.Sprintf(" subnet.filters: openshift/ has %q, cluster-api/ has %q → syncing", mapiFilterStr, capiFilterStr)) + capi.Spec.Subnet.ID = nil + capi.Spec.Subnet.Filters = convertMAPIFiltersToCAPI(mapi.Subnet.Filters) + } + } +} + +func indexAWSMachinesByName(files []*asset.RuntimeFile) map[string]*capa.AWSMachine { + result := make(map[string]*capa.AWSMachine) + for _, rf := range files { + if am, ok := rf.Object.(*capa.AWSMachine); ok { + if strings.Contains(am.Name, "-master-") { + result[am.Name] = am + } + } + } + return result +} + +func findAWSMachineByIndex(machines map[string]*capa.AWSMachine, idx int) (*capa.AWSMachine, bool) { + suffix := fmt.Sprintf("-master-%d", idx) + for name, m := range machines { + if strings.HasSuffix(name, suffix) { + return m, true + } + } + return nil, false +} + +func formatMAPIFilters(filters []machinev1beta1.Filter) string { + var parts []string + for _, f := range filters { + parts = append(parts, fmt.Sprintf("%s=%s", f.Name, strings.Join(f.Values, ","))) + } + return strings.Join(parts, ";") +} + +func formatCAPIFilters(filters []capa.Filter) string { + var parts []string + for _, f := range filters { + parts = append(parts, fmt.Sprintf("%s=%s", f.Name, strings.Join(f.Values, ","))) + } + return strings.Join(parts, ";") +} + +func convertMAPIFiltersToCAPI(filters []machinev1beta1.Filter) []capa.Filter { + result := make([]capa.Filter, len(filters)) + for i, f := range filters { + result[i] = capa.Filter{ + Name: f.Name, + Values: f.Values, + } + } + return result +} diff --git a/pkg/asset/machines/mastermapisync_test.go b/pkg/asset/machines/mastermapisync_test.go new file mode 100644 index 00000000000..13c32244edb --- /dev/null +++ b/pkg/asset/machines/mastermapisync_test.go @@ -0,0 +1,249 @@ +package machines + +import ( + "testing" + + "github.com/stretchr/testify/assert" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" + capa "sigs.k8s.io/cluster-api-provider-aws/v2/api/v1beta2" + + machinev1beta1 "github.com/openshift/api/machine/v1beta1" + "github.com/openshift/installer/pkg/asset" +) + +func makeTestMAPIConfig(instanceType string, volumeSize int64, volumeType string) *machinev1beta1.AWSMachineProviderConfig { + return &machinev1beta1.AWSMachineProviderConfig{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "machine.openshift.io/v1beta1", + Kind: "AWSMachineProviderConfig", + }, + InstanceType: instanceType, + AMI: machinev1beta1.AWSResourceReference{ + ID: ptr.To("ami-12345"), + }, + BlockDevices: []machinev1beta1.BlockDeviceMappingSpec{ + { + EBS: &machinev1beta1.EBSBlockDeviceSpec{ + VolumeSize: ptr.To(volumeSize), + VolumeType: ptr.To(volumeType), + Iops: ptr.To(int64(3000)), + Encrypted: ptr.To(true), + }, + }, + }, + IAMInstanceProfile: &machinev1beta1.AWSResourceReference{ + ID: ptr.To("test-cluster-master-profile"), + }, + MetadataServiceOptions: machinev1beta1.MetadataServiceOptions{ + Authentication: machinev1beta1.MetadataServiceAuthentication("Optional"), + }, + Placement: machinev1beta1.Placement{ + Region: "us-east-1", + AvailabilityZone: "us-east-1a", + }, + Subnet: machinev1beta1.AWSResourceReference{ + Filters: []machinev1beta1.Filter{ + {Name: "tag:Name", Values: []string{"test-cluster-subnet-private-us-east-1a"}}, + }, + }, + } +} + +func makeTestCAPIAWSMachine(name, instanceType string, volumeSize int64, volumeType string) *capa.AWSMachine { + return &capa.AWSMachine{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "infrastructure.cluster.x-k8s.io/v1beta2", + Kind: "AWSMachine", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Labels: map[string]string{ + "cluster.x-k8s.io/control-plane": "", + }, + }, + Spec: capa.AWSMachineSpec{ + InstanceType: instanceType, + AMI: capa.AMIReference{ID: ptr.To("ami-12345")}, + IAMInstanceProfile: "test-cluster-master-profile", + PublicIP: ptr.To(false), + SSHKeyName: ptr.To(""), + RootVolume: &capa.Volume{ + Size: volumeSize, + Type: capa.VolumeType(volumeType), + IOPS: 3000, + Encrypted: ptr.To(true), + }, + InstanceMetadataOptions: &capa.InstanceMetadataOptions{ + HTTPTokens: capa.HTTPTokensStateOptional, + HTTPEndpoint: capa.InstanceMetadataEndpointStateEnabled, + }, + Subnet: &capa.AWSResourceReference{ + Filters: []capa.Filter{ + {Name: "tag:Name", Values: []string{"test-cluster-subnet-private-us-east-1a"}}, + }, + }, + }, + } +} + + +func TestSyncAWSFields_NoDrift(t *testing.T) { + mapi := makeTestMAPIConfig("m6i.xlarge", 120, "gp3") + capi := makeTestCAPIAWSMachine("test-cluster-master-0", "m6i.xlarge", 120, "gp3") + + drifts := syncAWSFields(mapi, capi) + + assert.Empty(t, drifts, "expected no drift when specs match") + assert.Equal(t, "m6i.xlarge", capi.Spec.InstanceType) + assert.Equal(t, int64(120), capi.Spec.RootVolume.Size) +} + +func TestSyncAWSFields_InstanceTypeDrift(t *testing.T) { + mapi := makeTestMAPIConfig("m6i.4xlarge", 120, "gp3") + capi := makeTestCAPIAWSMachine("test-cluster-master-0", "m6i.xlarge", 120, "gp3") + + drifts := syncAWSFields(mapi, capi) + + assert.Len(t, drifts, 1) + assert.Contains(t, drifts[0], "instanceType") + assert.Contains(t, drifts[0], "m6i.4xlarge") + assert.Equal(t, "m6i.4xlarge", capi.Spec.InstanceType) +} + +func TestSyncAWSFields_RootVolumeDrift(t *testing.T) { + mapi := makeTestMAPIConfig("m6i.xlarge", 200, "io1") + mapi.BlockDevices[0].EBS.Iops = ptr.To(int64(5000)) + mapi.BlockDevices[0].EBS.ThroughputMib = ptr.To(int32(500)) + + capi := makeTestCAPIAWSMachine("test-cluster-master-0", "m6i.xlarge", 120, "gp3") + + drifts := syncAWSFields(mapi, capi) + + assert.Len(t, drifts, 4) // size, type, iops, throughput + assert.Equal(t, int64(200), capi.Spec.RootVolume.Size) + assert.Equal(t, capa.VolumeType("io1"), capi.Spec.RootVolume.Type) + assert.Equal(t, int64(5000), capi.Spec.RootVolume.IOPS) + assert.Equal(t, ptr.To(int64(500)), capi.Spec.RootVolume.Throughput) +} + +func TestSyncAWSFields_MultipleFieldDrift(t *testing.T) { + mapi := makeTestMAPIConfig("m6i.4xlarge", 200, "io2") + mapi.AMI.ID = ptr.To("ami-custom") + mapi.IAMInstanceProfile = &machinev1beta1.AWSResourceReference{ + ID: ptr.To("custom-profile"), + } + mapi.MetadataServiceOptions.Authentication = "Required" + mapi.PublicIP = ptr.To(true) + + capi := makeTestCAPIAWSMachine("test-cluster-master-0", "m6i.xlarge", 120, "gp3") + + drifts := syncAWSFields(mapi, capi) + + assert.Greater(t, len(drifts), 4) + assert.Equal(t, "m6i.4xlarge", capi.Spec.InstanceType) + assert.Equal(t, ptr.To("ami-custom"), capi.Spec.AMI.ID) + assert.Equal(t, int64(200), capi.Spec.RootVolume.Size) + assert.Equal(t, capa.VolumeType("io2"), capi.Spec.RootVolume.Type) + assert.Equal(t, "custom-profile", capi.Spec.IAMInstanceProfile) + assert.Equal(t, capa.HTTPTokensState("required"), capi.Spec.InstanceMetadataOptions.HTTPTokens) + assert.Equal(t, ptr.To(true), capi.Spec.PublicIP) +} + +func TestSyncAWSFields_AMIDrift(t *testing.T) { + mapi := makeTestMAPIConfig("m6i.xlarge", 120, "gp3") + mapi.AMI.ID = ptr.To("ami-custom-image") + + capi := makeTestCAPIAWSMachine("test-cluster-master-0", "m6i.xlarge", 120, "gp3") + capi.Spec.AMI.ID = ptr.To("ami-12345") + + drifts := syncAWSFields(mapi, capi) + + assert.Len(t, drifts, 1) + assert.Contains(t, drifts[0], "ami.id") + assert.Equal(t, ptr.To("ami-custom-image"), capi.Spec.AMI.ID) +} + +func TestSyncAWSFields_SubnetIDDrift(t *testing.T) { + mapi := makeTestMAPIConfig("m6i.xlarge", 120, "gp3") + mapi.Subnet = machinev1beta1.AWSResourceReference{ + ID: ptr.To("subnet-abc123"), + } + + capi := makeTestCAPIAWSMachine("test-cluster-master-0", "m6i.xlarge", 120, "gp3") + + drifts := syncAWSFields(mapi, capi) + + assert.Len(t, drifts, 1) + assert.Contains(t, drifts[0], "subnet.id") + assert.Equal(t, ptr.To("subnet-abc123"), capi.Spec.Subnet.ID) + assert.Nil(t, capi.Spec.Subnet.Filters) +} + +func TestSyncAWSFields_KMSKeyDrift(t *testing.T) { + mapi := makeTestMAPIConfig("m6i.xlarge", 120, "gp3") + mapi.BlockDevices[0].EBS.KMSKey = machinev1beta1.AWSResourceReference{ + ARN: ptr.To("arn:aws:kms:us-east-1:123456789:key/my-key"), + } + + capi := makeTestCAPIAWSMachine("test-cluster-master-0", "m6i.xlarge", 120, "gp3") + + drifts := syncAWSFields(mapi, capi) + + assert.Len(t, drifts, 1) + assert.Contains(t, drifts[0], "rootVolume.encryptionKey") + assert.Equal(t, "arn:aws:kms:us-east-1:123456789:key/my-key", capi.Spec.RootVolume.EncryptionKey) +} + +func TestIndexAWSMachinesByName(t *testing.T) { + master0 := makeTestCAPIAWSMachine("test-cluster-master-0", "m6i.xlarge", 120, "gp3") + master1 := makeTestCAPIAWSMachine("test-cluster-master-1", "m6i.xlarge", 120, "gp3") + bootstrap := makeTestCAPIAWSMachine("test-cluster-bootstrap", "m6i.xlarge", 120, "gp3") + + files := []*asset.RuntimeFile{ + {Object: master0}, + {Object: master1}, + {Object: bootstrap}, + } + + result := indexAWSMachinesByName(files) + + assert.Len(t, result, 2) + assert.Contains(t, result, "test-cluster-master-0") + assert.Contains(t, result, "test-cluster-master-1") + assert.NotContains(t, result, "test-cluster-bootstrap") +} + +func TestSyncAWSFields_CPUOptionsDrift(t *testing.T) { + mapi := makeTestMAPIConfig("m6i.xlarge", 120, "gp3") + cc := machinev1beta1.AWSConfidentialComputePolicy("AMDEncryptedVirtualizationNestedPaging") + mapi.CPUOptions = &machinev1beta1.CPUOptions{ + ConfidentialCompute: &cc, + } + + capi := makeTestCAPIAWSMachine("test-cluster-master-0", "m6i.xlarge", 120, "gp3") + + drifts := syncAWSFields(mapi, capi) + + assert.Len(t, drifts, 1) + assert.Contains(t, drifts[0], "cpuOptions.confidentialCompute") + assert.Equal(t, capa.AWSConfidentialComputePolicy("AMDEncryptedVirtualizationNestedPaging"), capi.Spec.CPUOptions.ConfidentialCompute) +} + +func TestFindAWSMachineByIndex(t *testing.T) { + machines := map[string]*capa.AWSMachine{ + "test-cluster-master-0": makeTestCAPIAWSMachine("test-cluster-master-0", "m6i.xlarge", 120, "gp3"), + "test-cluster-master-1": makeTestCAPIAWSMachine("test-cluster-master-1", "m6i.xlarge", 120, "gp3"), + } + + m, found := findAWSMachineByIndex(machines, 0) + assert.True(t, found) + assert.Equal(t, "test-cluster-master-0", m.Name) + + m, found = findAWSMachineByIndex(machines, 1) + assert.True(t, found) + assert.Equal(t, "test-cluster-master-1", m.Name) + + _, found = findAWSMachineByIndex(machines, 5) + assert.False(t, found) +}