From e08cfd55235480fb3201d0c788b75ad9be8c4acc Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Tue, 16 Jun 2026 08:29:04 -0700 Subject: [PATCH 1/5] validations: featuregate edge pool ClusterAPI management Note: we use the same feature gate ClusterAPIComputeInstall as worker compute pool. --- pkg/types/validation/featuregate_test.go | 20 ++++++++++++++++++++ pkg/types/validation/featuregates.go | 11 +++++++++-- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/pkg/types/validation/featuregate_test.go b/pkg/types/validation/featuregate_test.go index efffd9a3e4d..e71a8215110 100644 --- a/pkg/types/validation/featuregate_test.go +++ b/pkg/types/validation/featuregate_test.go @@ -260,6 +260,16 @@ func TestFeatureGates(t *testing.T) { return c }(), }, + { + name: "Edge Compute CAPI machine management is allowed with DevPreviewNoUpgrade Feature Set", + installConfig: func() *types.InstallConfig { + c := validInstallConfig() + c.FeatureSet = v1.DevPreviewNoUpgrade + c.Compute = append(c.Compute, *validMachinePool("edge")) + c.Compute[1].Management = types.ClusterAPI + return c + }(), + }, { name: "Control Plane CAPI machine management is not allowed with Default Feature Set", installConfig: func() *types.InstallConfig { @@ -278,6 +288,16 @@ func TestFeatureGates(t *testing.T) { }(), expected: `^compute.management: Forbidden: this field is protected by the ClusterAPIComputeInstall feature gate which must be enabled through either the TechPreviewNoUpgrade or CustomNoUpgrade feature set$`, }, + { + name: "Edge compute CAPI machine management is not allowed with the Default Feature Set", + installConfig: func() *types.InstallConfig { + c := validInstallConfig() + c.Compute = append(c.Compute, *validMachinePool("edge")) + c.Compute[1].Management = types.ClusterAPI + return c + }(), + expected: `^compute.management: Forbidden: this field is protected by the ClusterAPIComputeInstall feature gate which must be enabled through either the TechPreviewNoUpgrade or CustomNoUpgrade feature set$`, + }, } for _, tc := range cases { diff --git a/pkg/types/validation/featuregates.go b/pkg/types/validation/featuregates.go index c66d521d30d..8cf6f43ec10 100644 --- a/pkg/types/validation/featuregates.go +++ b/pkg/types/validation/featuregates.go @@ -48,8 +48,15 @@ func validateMachinePoolFeatureGates(c *types.InstallConfig) []featuregates.Gate }, { FeatureGateName: features.FeatureGateClusterAPIComputeInstall, - Condition: len(c.Compute) > 0 && c.Compute[0].Management == types.ClusterAPI, - Field: field.NewPath("compute", "management"), + Condition: func() bool { + for _, compute := range c.Compute { + if compute.Management == types.ClusterAPI { + return true + } + } + return false + }(), + Field: field.NewPath("compute", "management"), }, } } From bfbfc12110ccf991718dca2edf39c72d1a3af3f4 Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Mon, 15 Jun 2026 11:50:39 -0700 Subject: [PATCH 2/5] pkg/types: default CAPI edge machine management Defaults the edge machine pool management to CAPI when the appropriate feature gate is enabled. --- pkg/types/defaults/machinepools.go | 3 +++ pkg/types/defaults/machinepools_test.go | 21 +++++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/pkg/types/defaults/machinepools.go b/pkg/types/defaults/machinepools.go index 5f0c0107601..957068dde74 100644 --- a/pkg/types/defaults/machinepools.go +++ b/pkg/types/defaults/machinepools.go @@ -48,6 +48,9 @@ func SetMachinePoolDefaults(p *types.MachinePool, platform *types.Platform, fgat if p.Name == types.MachinePoolComputeRoleName && fgates.Enabled(features.FeatureGateClusterAPIComputeInstall) { p.Management = types.ClusterAPI } + if p.Name == types.MachinePoolEdgeRoleName && fgates.Enabled(features.FeatureGateClusterAPIComputeInstall) { + p.Management = types.ClusterAPI + } } switch platform.Name() { diff --git a/pkg/types/defaults/machinepools_test.go b/pkg/types/defaults/machinepools_test.go index 7a2db9073a0..b0064c18b05 100644 --- a/pkg/types/defaults/machinepools_test.go +++ b/pkg/types/defaults/machinepools_test.go @@ -178,6 +178,20 @@ func TestSetMachinePoolDefaultsWithFeatureGates(t *testing.T) { featureSet: configv1.Default, expectedManagement: "", }, + { + name: "edge compute with DevPreviewNoUpgrade feature set", + pool: &types.MachinePool{Name: types.MachinePoolEdgeRoleName}, + platform: &types.Platform{}, + featureSet: configv1.DevPreviewNoUpgrade, + expectedManagement: types.ClusterAPI, + }, + { + name: "edge compute with default feature set", + pool: &types.MachinePool{Name: types.MachinePoolEdgeRoleName}, + platform: &types.Platform{}, + featureSet: configv1.Default, + expectedManagement: "", + }, { name: "control plane with management already set", pool: &types.MachinePool{Name: types.MachinePoolControlPlaneRoleName, Management: types.MachineAPI}, @@ -192,6 +206,13 @@ func TestSetMachinePoolDefaultsWithFeatureGates(t *testing.T) { featureSet: configv1.DevPreviewNoUpgrade, expectedManagement: types.MachineAPI, }, + { + name: "edge compute with management already set", + pool: &types.MachinePool{Name: types.MachinePoolEdgeRoleName, Management: types.MachineAPI}, + platform: &types.Platform{}, + featureSet: configv1.DevPreviewNoUpgrade, + expectedManagement: types.MachineAPI, + }, } for _, tc := range cases { From 1c83eb21788d437563bdc8518a2636b61c8f6f95 Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Fri, 12 Jun 2026 14:56:05 -0700 Subject: [PATCH 3/5] validation: unblock edge compute pools from using Cluster API management Edge compute pools require MachineTaintPropagation, which is now available after we bump CAPI to v1.12. --- pkg/types/validation/installconfig.go | 3 --- pkg/types/validation/installconfig_test.go | 13 ------------- 2 files changed, 16 deletions(-) diff --git a/pkg/types/validation/installconfig.go b/pkg/types/validation/installconfig.go index a43e29711b1..0c06c9a73d5 100644 --- a/pkg/types/validation/installconfig.go +++ b/pkg/types/validation/installconfig.go @@ -899,9 +899,6 @@ func validateCompute(platform *types.Platform, control *types.MachinePool, pools case types.MachinePoolComputeRoleName: case types.MachinePoolEdgeRoleName: allErrs = append(allErrs, validateComputeEdge(platform, p.Name, poolFldPath, poolFldPath)...) - if p.Management == types.ClusterAPI { - allErrs = append(allErrs, field.Invalid(poolFldPath.Child("management"), p.Management, "edge compute pools cannot be managed by Cluster API")) - } default: allErrs = append(allErrs, field.NotSupported(poolFldPath.Child("name"), p.Name, []string{types.MachinePoolComputeRoleName, types.MachinePoolEdgeRoleName})) } diff --git a/pkg/types/validation/installconfig_test.go b/pkg/types/validation/installconfig_test.go index 61dbf481ab1..05bc17b4b59 100644 --- a/pkg/types/validation/installconfig_test.go +++ b/pkg/types/validation/installconfig_test.go @@ -856,19 +856,6 @@ func TestValidateInstallConfig(t *testing.T) { }(), expectedError: `^compute\[1\]\.name: Duplicate value: "worker"$`, }, - { - name: "edge compute with cluster api", - installConfig: func() *types.InstallConfig { - c := validInstallConfig() - c.Compute = append(c.Compute, func() types.MachinePool { - p := *validMachinePool("edge") - p.Management = types.ClusterAPI - return p - }()) - return c - }(), - expectedError: `^compute\[1\]\.management: Invalid value: "ClusterAPI": edge compute pools cannot be managed by Cluster API$`, - }, { name: "no compute replicas", installConfig: func() *types.InstallConfig { From 28fbcc7de6e2da38c7fd3e27db013c86d2268a32 Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Fri, 12 Jun 2026 14:54:03 -0700 Subject: [PATCH 4/5] CORS-4507: generate CAPI machineset manifest for edge compute pool --- .../machines/aws/clusterapi_machinesets.go | 30 +++++++++++++++---- 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/pkg/asset/machines/aws/clusterapi_machinesets.go b/pkg/asset/machines/aws/clusterapi_machinesets.go index 657c6448f40..5f46d05a004 100644 --- a/pkg/asset/machines/aws/clusterapi_machinesets.go +++ b/pkg/asset/machines/aws/clusterapi_machinesets.go @@ -10,6 +10,7 @@ import ( capa "sigs.k8s.io/cluster-api-provider-aws/v2/api/v1beta2" capi "sigs.k8s.io/cluster-api/api/core/v1beta2" + "github.com/openshift/installer/pkg/types" "github.com/openshift/installer/pkg/types/aws" "github.com/openshift/installer/pkg/utils" ) @@ -56,6 +57,7 @@ func ClusterAPIMachineSets(in *MachineSetInput) ([]capa.AWSMachineTemplate, []ca nodeLabels := map[string]string{ "node-role.kubernetes.io/worker": "", } + nodeTaints := []capi.MachineTaint{} instanceType := mpool.InstanceType publicSubnet := in.PublicSubnet subnetRef := &capa.AWSResourceReference{} @@ -80,12 +82,27 @@ func ClusterAPIMachineSets(in *MachineSetInput) ([]capa.AWSMachineTemplate, []ca } } - // TODO: edge pools do not share same instance type and regular cluster workloads. - // The instance type is selected based in the offerings for the location. - // The labels and taints are set to prevent regular workloads. - // https://github.com/openshift/enhancements/blob/master/enhancements/installer/aws-custom-edge-machineset-local-zones.md - // FIXME: node taints on Machine/MachineSet is only supported in CAPI v1.12+ with feature gate MachineTaintPropagation. - // Until we bump the CAPI version, edge machines can only be provisioned via MAPI. + if in.Pool.Name == types.MachinePoolEdgeRoleName { + // edge pools do not share same instance type and regular cluster workloads. + // The instance type is selected based in the offerings for the location. + zone := in.Zones[az] + if zone.PreferredInstanceType != "" { + instanceType = zone.PreferredInstanceType + } + + nodeLabels["node-role.kubernetes.io/edge"] = "" + nodeLabels["machine.openshift.io/zone-type"] = zone.Type + nodeLabels["machine.openshift.io/zone-group"] = zone.GroupName + nodeLabels["machine.openshift.io/parent-zone-name"] = zone.ParentZoneName + + // The labels and taints are set to prevent regular workloads. + // https://github.com/openshift/enhancements/blob/master/enhancements/installer/aws-custom-edge-machineset-local-zones.md + nodeTaints = append(nodeTaints, capi.MachineTaint{ + Key: "node-role.kubernetes.io/edge", + Effect: "NoSchedule", + Propagation: capi.MachineTaintPropagationAlways, + }) + } dedicatedHost := DedicatedHost(in.Hosts, mpool.HostPlacement, az) @@ -185,6 +202,7 @@ func ClusterAPIMachineSets(in *MachineSetInput) ([]capa.AWSMachineTemplate, []ca Name: name, }, FailureDomain: az, + Taints: nodeTaints, }, }, }, From 8c75dbfbb01fc4e37a4324f13737a2ff62116ea7 Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Mon, 15 Jun 2026 13:25:32 -0700 Subject: [PATCH 5/5] tests: add unit tests for clusterapi_machinesets.go --- .../aws/clusterapi_machinesets_test.go | 533 ++++++++++++++++++ 1 file changed, 533 insertions(+) create mode 100644 pkg/asset/machines/aws/clusterapi_machinesets_test.go diff --git a/pkg/asset/machines/aws/clusterapi_machinesets_test.go b/pkg/asset/machines/aws/clusterapi_machinesets_test.go new file mode 100644 index 00000000000..893d6febf76 --- /dev/null +++ b/pkg/asset/machines/aws/clusterapi_machinesets_test.go @@ -0,0 +1,533 @@ +package aws + +import ( + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" + capa "sigs.k8s.io/cluster-api-provider-aws/v2/api/v1beta2" + capi "sigs.k8s.io/cluster-api/api/core/v1beta2" + + icaws "github.com/openshift/installer/pkg/asset/installconfig/aws" + "github.com/openshift/installer/pkg/types" + awstypes "github.com/openshift/installer/pkg/types/aws" +) + +func TestClusterAPIMachineSets(t *testing.T) { + tests := []struct { + name string + input *MachineSetInput + wantErr string + validate func(t *testing.T, templates []capa.AWSMachineTemplate, machineSets []capi.MachineSet) + }{ + { + name: "non-AWS pool returns error", + input: func() *MachineSetInput { + in := defaultMachineSetInput() + in.Pool.Platform.AWS = nil + return in + }(), + wantErr: `non-AWS machine-pool: ""`, + }, + { + name: "missing subnet for zone in BYO VPC returns error", + input: func() *MachineSetInput { + in := defaultMachineSetInput() + in.Pool.Platform.AWS.Zones = []string{"us-east-1a"} + in.Subnets = icaws.SubnetsByZone{ + "us-east-1b": {ID: "subnet-b"}, + } + return in + }(), + wantErr: `no subnet for zone us-east-1a`, + }, + { + name: "user tags conflict with reserved key returns error", + input: func() *MachineSetInput { + in := defaultMachineSetInput() + in.Pool.Platform.AWS.Zones = []string{"us-east-1a"} + in.InstallConfigPlatformAWS.UserTags = map[string]string{ + "kubernetes.io/cluster/test-cluster": "not-allowed", + } + return in + }(), + wantErr: "failed to create CAPA tags from user tags", + }, + { + name: "single zone managed VPC", + input: func() *MachineSetInput { + in := defaultMachineSetInput() + in.Pool.Platform.AWS.Zones = []string{"us-east-1a"} + in.Pool.Replicas = ptr.To(int64(2)) + return in + }(), + validate: validateSingleZoneManagedVPC, + }, + { + name: "replicas distributed evenly across three zones", + input: defaultMachineSetInput(), + validate: func(t *testing.T, _ []capa.AWSMachineTemplate, machineSets []capi.MachineSet) { + t.Helper() + if len(machineSets) != 3 { + t.Fatalf("expected 3 machinesets, got %d", len(machineSets)) + } + for i, ms := range machineSets { + if *ms.Spec.Replicas != 1 { + t.Errorf("zone %d: replicas = %d, want 1", i, *ms.Spec.Replicas) + } + } + }, + }, + { + name: "1 replica across 3 zones assigns to first zone only", + input: func() *MachineSetInput { + in := defaultMachineSetInput() + in.Pool.Replicas = ptr.To(int64(1)) + return in + }(), + validate: func(t *testing.T, _ []capa.AWSMachineTemplate, machineSets []capi.MachineSet) { + t.Helper() + wantReplicas := []int32{1, 0, 0} + for i, ms := range machineSets { + if *ms.Spec.Replicas != wantReplicas[i] { + t.Errorf("zone %d: replicas = %d, want %d", i, *ms.Spec.Replicas, wantReplicas[i]) + } + } + }, + }, + { + name: "0 replicas", + input: func() *MachineSetInput { + in := defaultMachineSetInput() + in.Pool.Replicas = ptr.To(int64(0)) + in.Pool.Platform.AWS.Zones = []string{"us-east-1a", "us-east-1b"} + return in + }(), + validate: func(t *testing.T, _ []capa.AWSMachineTemplate, machineSets []capi.MachineSet) { + t.Helper() + for i, ms := range machineSets { + if *ms.Spec.Replicas != 0 { + t.Errorf("zone %d: replicas = %d, want 0", i, *ms.Spec.Replicas) + } + } + }, + }, + { + name: "BYO VPC with explicit subnets", + input: func() *MachineSetInput { + in := defaultMachineSetInput() + in.Pool.Platform.AWS.Zones = []string{"us-east-1a", "us-east-1b"} + in.Pool.Replicas = ptr.To(int64(2)) + in.Subnets = icaws.SubnetsByZone{ + "us-east-1a": {ID: "subnet-aaa", Public: false}, + "us-east-1b": {ID: "subnet-bbb", Public: true}, + } + return in + }(), + validate: validateBYOVPCSubnets, + }, + { + name: "public subnet managed VPC uses public subnet filter", + input: func() *MachineSetInput { + in := defaultMachineSetInput() + in.PublicSubnet = true + in.Pool.Platform.AWS.Zones = []string{"us-east-1a", "us-east-1b"} + in.Pool.Replicas = ptr.To(int64(2)) + return in + }(), + validate: validatePublicSubnetFilter, + }, + { + name: "IMDS defaults to optional", + input: func() *MachineSetInput { + in := defaultMachineSetInput() + in.Pool.Platform.AWS.Zones = []string{"us-east-1a"} + in.Pool.Replicas = ptr.To(int64(1)) + return in + }(), + validate: func(t *testing.T, templates []capa.AWSMachineTemplate, _ []capi.MachineSet) { + t.Helper() + if len(templates) != 1 { + t.Fatalf("expected 1 template, got %d", len(templates)) + } + got := templates[0].Spec.Template.Spec.InstanceMetadataOptions.HTTPTokens + if got != capa.HTTPTokensStateOptional { + t.Errorf("IMDS HTTPTokens = %q, want %q", got, capa.HTTPTokensStateOptional) + } + }, + }, + { + name: "IMDS required when authentication is Required", + input: func() *MachineSetInput { + in := defaultMachineSetInput() + in.Pool.Platform.AWS.Zones = []string{"us-east-1a"} + in.Pool.Replicas = ptr.To(int64(1)) + in.Pool.Platform.AWS.EC2Metadata = awstypes.EC2Metadata{ + Authentication: "Required", + } + return in + }(), + validate: func(t *testing.T, templates []capa.AWSMachineTemplate, _ []capi.MachineSet) { + t.Helper() + if len(templates) != 1 { + t.Fatalf("expected 1 template, got %d", len(templates)) + } + got := templates[0].Spec.Template.Spec.InstanceMetadataOptions.HTTPTokens + if got != capa.HTTPTokensStateRequired { + t.Errorf("IMDS HTTPTokens = %q, want %q", got, capa.HTTPTokensStateRequired) + } + }, + }, + { + name: "default IAM profile uses cluster ID", + input: func() *MachineSetInput { + in := defaultMachineSetInput() + in.Pool.Platform.AWS.Zones = []string{"us-east-1a"} + in.Pool.Replicas = ptr.To(int64(1)) + return in + }(), + validate: func(t *testing.T, templates []capa.AWSMachineTemplate, _ []capi.MachineSet) { + t.Helper() + if len(templates) != 1 { + t.Fatalf("expected 1 template, got %d", len(templates)) + } + got := templates[0].Spec.Template.Spec.IAMInstanceProfile + if got != "test-cluster-worker-profile" { + t.Errorf("IAM profile = %q, want %q", got, "test-cluster-worker-profile") + } + }, + }, + { + name: "custom IAM profile", + input: func() *MachineSetInput { + in := defaultMachineSetInput() + in.Pool.Platform.AWS.Zones = []string{"us-east-1a"} + in.Pool.Replicas = ptr.To(int64(1)) + in.Pool.Platform.AWS.IAMProfile = "my-custom-profile" + return in + }(), + validate: func(t *testing.T, templates []capa.AWSMachineTemplate, _ []capi.MachineSet) { + t.Helper() + if len(templates) != 1 { + t.Fatalf("expected 1 template, got %d", len(templates)) + } + got := templates[0].Spec.Template.Spec.IAMInstanceProfile + if got != "my-custom-profile" { + t.Errorf("IAM profile = %q, want %q", got, "my-custom-profile") + } + }, + }, + { + name: "user tags propagated to templates", + input: func() *MachineSetInput { + in := defaultMachineSetInput() + in.Pool.Platform.AWS.Zones = []string{"us-east-1a"} + in.Pool.Replicas = ptr.To(int64(1)) + in.InstallConfigPlatformAWS.UserTags = map[string]string{ + "env": "prod", + "owner": "team-a", + } + return in + }(), + validate: func(t *testing.T, templates []capa.AWSMachineTemplate, _ []capi.MachineSet) { + t.Helper() + if len(templates) != 1 { + t.Fatalf("expected 1 template, got %d", len(templates)) + } + got := templates[0].Spec.Template.Spec.AdditionalTags + want := capa.Tags{ + "kubernetes.io/cluster/test-cluster": "owned", + "env": "prod", + "owner": "team-a", + } + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("tags mismatch (-want +got):\n%s", diff) + } + }, + }, + { + name: "additional security groups appended", + input: func() *MachineSetInput { + in := defaultMachineSetInput() + in.Pool.Platform.AWS.Zones = []string{"us-east-1a"} + in.Pool.Replicas = ptr.To(int64(1)) + in.Pool.Platform.AWS.AdditionalSecurityGroupIDs = []string{"sg-extra-1", "sg-extra-2"} + return in + }(), + validate: validateAdditionalSecurityGroups, + }, + { + name: "edge pool adds labels taints and preferred instance type", + input: func() *MachineSetInput { + in := defaultMachineSetInput() + in.Pool.Name = types.MachinePoolEdgeRoleName + in.Pool.Replicas = ptr.To(int64(1)) + in.Pool.Platform.AWS.Zones = []string{"us-east-1-bos-1a"} + in.Zones = icaws.Zones{ + "us-east-1-bos-1a": &icaws.Zone{ + Name: "us-east-1-bos-1a", + Type: "local-zone", + GroupName: "us-east-1-bos-1", + ParentZoneName: "us-east-1c", + PreferredInstanceType: "m5.xlarge", + }, + } + return in + }(), + validate: validateEdgePool, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + templates, machineSets, err := ClusterAPIMachineSets(tt.input) + if tt.wantErr != "" { + if err == nil { + t.Fatalf("expected error containing %q, got nil", tt.wantErr) + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("error = %q, want substring %q", err.Error(), tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tt.validate != nil { + tt.validate(t, templates, machineSets) + } + }) + } +} + +func defaultMachineSetInput() *MachineSetInput { + return &MachineSetInput{ + ClusterID: "test-cluster", + InstallConfigPlatformAWS: &awstypes.Platform{ + Region: "us-east-1", + }, + Pool: &types.MachinePool{ + Name: types.MachinePoolComputeRoleName, + Replicas: ptr.To(int64(3)), + Platform: types.MachinePoolPlatform{ + AWS: &awstypes.MachinePool{ + InstanceType: "m5.xlarge", + Zones: []string{"us-east-1a", "us-east-1b", "us-east-1c"}, + EC2RootVolume: awstypes.EC2RootVolume{ + Size: 120, + Type: "gp3", + }, + }, + }, + }, + UserDataSecret: "worker-user-data", + } +} + +func validateSingleZoneManagedVPC(t *testing.T, templates []capa.AWSMachineTemplate, machineSets []capi.MachineSet) { + t.Helper() + clusterID := "test-cluster" + baseTags := capa.Tags{"kubernetes.io/cluster/test-cluster": "owned"} + + wantTemplates := []capa.AWSMachineTemplate{ + { + TypeMeta: metav1.TypeMeta{ + APIVersion: "infrastructure.cluster.x-k8s.io/v1beta2", + Kind: "AWSMachineTemplate", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cluster-worker-us-east-1a", + Namespace: "openshift-cluster-api", + Labels: map[string]string{"cluster.x-k8s.io/cluster-name": clusterID}, + }, + Spec: capa.AWSMachineTemplateSpec{ + Template: capa.AWSMachineTemplateResource{ + Spec: GenerateCAPIMachineSpec(&CAPIMachineSpecInput{ + InstanceType: "m5.xlarge", + IAMInstanceProfile: "test-cluster-worker-profile", + Subnet: &capa.AWSResourceReference{ + Filters: []capa.Filter{{ + Name: "tag:Name", + Values: []string{"test-cluster-subnet-private-us-east-1a"}, + }}, + }, + Tags: baseTags, + EC2RootVolume: awstypes.EC2RootVolume{ + Size: 120, + Type: "gp3", + }, + IMDS: capa.HTTPTokensStateOptional, + SecurityGroups: []capa.AWSResourceReference{ + {Filters: []capa.Filter{{Name: "tag:Name", Values: []string{"test-cluster-node"}}}}, + {Filters: []capa.Filter{{Name: "tag:Name", Values: []string{"test-cluster-lb"}}}}, + }, + Ignition: &capa.Ignition{ + Version: "3.2", + StorageType: capa.IgnitionStorageTypeOptionUnencryptedUserData, + }, + }), + }, + }, + }, + } + if diff := cmp.Diff(wantTemplates, templates); diff != "" { + t.Errorf("AWSMachineTemplates mismatch (-want +got):\n%s", diff) + } + wantSets := []capi.MachineSet{ + { + TypeMeta: metav1.TypeMeta{ + APIVersion: capi.GroupVersion.String(), + Kind: "MachineSet", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cluster-worker-us-east-1a", + Namespace: "openshift-cluster-api", + Labels: map[string]string{"cluster.x-k8s.io/cluster-name": clusterID}, + }, + Spec: capi.MachineSetSpec{ + ClusterName: clusterID, + Replicas: ptr.To(int32(2)), + Selector: metav1.LabelSelector{ + MatchLabels: map[string]string{ + "cluster.x-k8s.io/cluster-name": clusterID, + "cluster.x-k8s.io/set-name": "test-cluster-worker-us-east-1a", + }, + }, + Template: capi.MachineTemplateSpec{ + ObjectMeta: capi.ObjectMeta{ + Labels: map[string]string{ + "cluster.x-k8s.io/cluster-name": clusterID, + "cluster.x-k8s.io/set-name": "test-cluster-worker-us-east-1a", + "node-role.kubernetes.io/worker": "", + }, + }, + Spec: capi.MachineSpec{ + ClusterName: clusterID, + Bootstrap: capi.Bootstrap{ + DataSecretName: ptr.To("worker-user-data"), + }, + InfrastructureRef: capi.ContractVersionedObjectReference{ + APIGroup: "infrastructure.cluster.x-k8s.io", + Kind: "AWSMachineTemplate", + Name: "test-cluster-worker-us-east-1a", + }, + FailureDomain: "us-east-1a", + Taints: []capi.MachineTaint{}, + }, + }, + }, + }, + } + if diff := cmp.Diff(wantSets, machineSets); diff != "" { + t.Errorf("MachineSets mismatch (-want +got):\n%s", diff) + } +} + +func validateBYOVPCSubnets(t *testing.T, templates []capa.AWSMachineTemplate, machineSets []capi.MachineSet) { + t.Helper() + wantSubnets := []struct { + id string + publicIP bool + zone string + }{ + {id: "subnet-aaa", publicIP: false, zone: "us-east-1a"}, + {id: "subnet-bbb", publicIP: true, zone: "us-east-1b"}, + } + if len(templates) != len(wantSubnets) { + t.Fatalf("expected %d templates, got %d", len(wantSubnets), len(templates)) + } + for i, want := range wantSubnets { + spec := templates[i].Spec.Template.Spec + if spec.Subnet == nil || ptr.Deref(spec.Subnet.ID, "") != want.id { + t.Errorf("zone %d: subnet ID = %+v, want %s", i, spec.Subnet, want.id) + } + if ptr.Deref(spec.PublicIP, false) != want.publicIP { + t.Errorf("zone %d: PublicIP = %v, want %v", i, ptr.Deref(spec.PublicIP, false), want.publicIP) + } + if machineSets[i].Spec.Template.Spec.FailureDomain != want.zone { + t.Errorf("zone %d: failure domain = %s, want %s", i, machineSets[i].Spec.Template.Spec.FailureDomain, want.zone) + } + } +} + +func validatePublicSubnetFilter(t *testing.T, templates []capa.AWSMachineTemplate, _ []capi.MachineSet) { + t.Helper() + zones := []string{"us-east-1a", "us-east-1b"} + if len(templates) != len(zones) { + t.Fatalf("expected %d templates, got %d", len(zones), len(templates)) + } + for i, az := range zones { + spec := templates[i].Spec.Template.Spec + if !ptr.Deref(spec.PublicIP, false) { + t.Errorf("zone %s: PublicIP = false, want true", az) + } + if len(spec.Subnet.Filters) == 0 { + t.Fatalf("zone %s: expected subnet filters", az) + } + got := spec.Subnet.Filters[0].Values[0] + want := "test-cluster-subnet-public-" + az + if got != want { + t.Errorf("zone %s: subnet filter = %q, want %q", az, got, want) + } + } +} + +func validateAdditionalSecurityGroups(t *testing.T, templates []capa.AWSMachineTemplate, _ []capi.MachineSet) { + t.Helper() + if len(templates) != 1 { + t.Fatalf("expected 1 template, got %d", len(templates)) + } + sgs := templates[0].Spec.Template.Spec.AdditionalSecurityGroups + if len(sgs) != 4 { + t.Fatalf("expected 4 security groups (2 default + 2 additional), got %d", len(sgs)) + } + if sgs[0].Filters[0].Values[0] != "test-cluster-node" { + t.Errorf("first SG filter = %v, want test-cluster-node", sgs[0].Filters[0].Values) + } + if sgs[1].Filters[0].Values[0] != "test-cluster-lb" { + t.Errorf("second SG filter = %v, want test-cluster-lb", sgs[1].Filters[0].Values) + } + if ptr.Deref(sgs[2].ID, "") != "sg-extra-1" { + t.Errorf("third SG ID = %q, want sg-extra-1", ptr.Deref(sgs[2].ID, "")) + } + if ptr.Deref(sgs[3].ID, "") != "sg-extra-2" { + t.Errorf("fourth SG ID = %q, want sg-extra-2", ptr.Deref(sgs[3].ID, "")) + } +} + +func validateEdgePool(t *testing.T, templates []capa.AWSMachineTemplate, machineSets []capi.MachineSet) { + t.Helper() + if len(templates) != 1 || len(machineSets) != 1 { + t.Fatalf("expected 1 template and 1 machineset, got %d and %d", len(templates), len(machineSets)) + } + if templates[0].Spec.Template.Spec.InstanceType != "m5.xlarge" { + t.Errorf("instance type = %q, want m5.xlarge", templates[0].Spec.Template.Spec.InstanceType) + } + msLabels := machineSets[0].Spec.Template.ObjectMeta.Labels + wantLabels := map[string]string{ + "node-role.kubernetes.io/edge": "", + "node-role.kubernetes.io/worker": "", + "machine.openshift.io/zone-type": "local-zone", + "machine.openshift.io/zone-group": "us-east-1-bos-1", + "machine.openshift.io/parent-zone-name": "us-east-1c", + } + for k, v := range wantLabels { + if got, ok := msLabels[k]; !ok || got != v { + t.Errorf("label %s = %q (present=%v), want %q", k, got, ok, v) + } + } + taints := machineSets[0].Spec.Template.Spec.Taints + if len(taints) != 1 { + t.Fatalf("expected 1 taint, got %d", len(taints)) + } + if taints[0].Key != "node-role.kubernetes.io/edge" || taints[0].Effect != "NoSchedule" { + t.Errorf("taint = %+v, want key=node-role.kubernetes.io/edge effect=NoSchedule", taints[0]) + } + if taints[0].Propagation != capi.MachineTaintPropagationAlways { + t.Errorf("taint propagation = %q, want Always", taints[0].Propagation) + } + if machineSets[0].Name != "test-cluster-edge-us-east-1-bos-1a" { + t.Errorf("machineset name = %q, want test-cluster-edge-us-east-1-bos-1a", machineSets[0].Name) + } +}