From 4cb91a2d62d952158d18a5a30ea07a1cb9c04580 Mon Sep 17 00:00:00 2001 From: Chin2691 Date: Sat, 23 May 2026 19:55:15 +0530 Subject: [PATCH] OCPBUGS-86418: sync CPMS template from master machine providerSpecs When users edit master machine manifests after `create manifests` (e.g. changing instanceType) but do not also edit the CPMS manifest, the ControlPlaneMachineSet template retains defaults. Post-install the CPMS operator detects drift between its template and the actual Machine providerSpecs, triggering an unintended rolling update that silently reverts the user's customization. Add a MasterCPMSSync asset that runs after Master asset generation. It compares provisioning-relevant AWS fields (instanceType, AMI, rootVolume, IAM profile, metadata auth, publicIP) between the first MAPI master machine and the CPMS template, syncing any drift and emitting a warning. Zone-specific fields (Subnet, Placement AZ) are intentionally excluded since they are managed by CPMS FailureDomains. Bug: https://issues.redhat.com/browse/OCPBUGS-86418 Co-authored-by: Cursor --- pkg/asset/cluster/cluster.go | 1 + pkg/asset/ignition/bootstrap/common.go | 1 + pkg/asset/machines/mastercpmssync.go | 271 ++++++++++++++++++++++ pkg/asset/machines/mastercpmssync_test.go | 257 ++++++++++++++++++++ 4 files changed, 530 insertions(+) create mode 100644 pkg/asset/machines/mastercpmssync.go create mode 100644 pkg/asset/machines/mastercpmssync_test.go diff --git a/pkg/asset/cluster/cluster.go b/pkg/asset/cluster/cluster.go index 02c13b5974f..11893342686 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.MasterCPMSSync{}, new(rhcos.Image), &manifests.Manifests{}, &tls.RootCA{}, diff --git a/pkg/asset/ignition/bootstrap/common.go b/pkg/asset/ignition/bootstrap/common.go index 1e2d64df663..84827ec4495 100644 --- a/pkg/asset/ignition/bootstrap/common.go +++ b/pkg/asset/ignition/bootstrap/common.go @@ -131,6 +131,7 @@ func (a *Common) Dependencies() []asset.Asset { &mcign.MasterIgnitionCustomizations{}, &mcign.WorkerIgnitionCustomizations{}, &machines.Master{}, + &machines.MasterCPMSSync{}, &machines.Arbiter{}, &machines.Worker{}, &manifests.Manifests{}, diff --git a/pkg/asset/machines/mastercpmssync.go b/pkg/asset/machines/mastercpmssync.go new file mode 100644 index 00000000000..26aec0e1814 --- /dev/null +++ b/pkg/asset/machines/mastercpmssync.go @@ -0,0 +1,271 @@ +package machines + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/sirupsen/logrus" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/yaml" + + machinev1 "github.com/openshift/api/machine/v1" + 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" +) + +// MasterCPMSSync detects drift between master machine providerSpecs and the +// CPMS template providerSpec, syncing provisioning-relevant fields so the CPMS +// does not trigger an unintended rolling update post-install. +type MasterCPMSSync struct{} + +var _ asset.Asset = (*MasterCPMSSync)(nil) + +// Name returns the human-friendly name of the asset. +func (a *MasterCPMSSync) Name() string { + return "Master CPMS Template Sync" +} + +// Dependencies returns the assets upon which this asset directly depends. +func (a *MasterCPMSSync) Dependencies() []asset.Asset { + return []asset.Asset{ + &installconfig.InstallConfig{}, + &Master{}, + } +} + +// Generate compares MAPI master machine providerSpecs to the CPMS template +// providerSpec and syncs provisioning-relevant fields when drift is detected. +func (a *MasterCPMSSync) Generate(ctx context.Context, dependencies asset.Parents) error { + ic := &installconfig.InstallConfig{} + mastersAsset := &Master{} + dependencies.Get(ic, mastersAsset) + + if !capiutils.IsEnabled(ic) { + return nil + } + + platform := ic.Config.Platform.Name() + if platform != awstypes.Name { + return nil + } + + if mastersAsset.ControlPlaneMachineSet == nil { + return nil + } + + masters, err := mastersAsset.Machines() + if err != nil { + logrus.Debugf("MasterCPMSSync: skipping, could not parse MAPI machines: %v", err) + return nil + } + if len(masters) == 0 { + return nil + } + + cpms := &machinev1.ControlPlaneMachineSet{} + if err := yaml.Unmarshal(mastersAsset.ControlPlaneMachineSet.Data, cpms); err != nil { + logrus.Debugf("MasterCPMSSync: skipping, could not parse CPMS: %v", err) + return nil + } + + tmpl := cpms.Spec.Template.OpenShiftMachineV1Beta1Machine + if tmpl == nil { + return nil + } + + cpmsProviderSpec, err := decodeCPMSProviderSpec(tmpl.Spec.ProviderSpec.Value) + if cpmsProviderSpec == nil || err != nil { + logrus.Debugf("MasterCPMSSync: skipping, could not decode CPMS providerSpec: %v", err) + return nil + } + + firstMaster := masters[0] + mapiConfig, ok := firstMaster.Spec.ProviderSpec.Value.Object.(*machinev1beta1.AWSMachineProviderConfig) + if !ok || mapiConfig == nil { + return nil + } + + drifts := syncCPMSAWSFields(mapiConfig, cpmsProviderSpec) + + if len(drifts) == 0 { + return nil + } + + logrus.Warnf("Detected drift between MAPI master machine providerSpec (openshift/) and CPMS template.\n%s\n"+ + " Syncing master machine values to CPMS template to prevent unintended rolling update.\n"+ + " To avoid this warning, also edit 99_openshift-machine-api_master-control-plane-machine-set.yaml when customizing masters.", + strings.Join(drifts, "\n")) + + rawPS, err := json.Marshal(cpmsProviderSpec) + if err != nil { + logrus.Debugf("MasterCPMSSync: failed to marshal synced providerSpec: %v", err) + return nil + } + tmpl.Spec.ProviderSpec.Value = &runtime.RawExtension{Raw: rawPS} + cpmsData, err := yaml.Marshal(cpms) + if err != nil { + logrus.Debugf("MasterCPMSSync: failed to re-serialize CPMS: %v", err) + return nil + } + + mastersAsset.ControlPlaneMachineSet.Data = cpmsData + + return nil +} + +// syncCPMSAWSFields compares provisioning-relevant fields (excluding zone-specific +// fields like Placement.AvailabilityZone and Subnet which are handled by CPMS +// FailureDomains) from MAPI machine to CPMS template. Returns drift descriptions. +func syncCPMSAWSFields(mapi *machinev1beta1.AWSMachineProviderConfig, cpms *machinev1beta1.AWSMachineProviderConfig) []string { + var drifts []string + + if mapi.InstanceType != "" && mapi.InstanceType != cpms.InstanceType { + drifts = append(drifts, fmt.Sprintf(" instanceType: machine has %q, CPMS template has %q → syncing", mapi.InstanceType, cpms.InstanceType)) + cpms.InstanceType = mapi.InstanceType + } + + if mapi.AMI.ID != nil && *mapi.AMI.ID != "" { + cpmsAMI := "" + if cpms.AMI.ID != nil { + cpmsAMI = *cpms.AMI.ID + } + if *mapi.AMI.ID != cpmsAMI { + drifts = append(drifts, fmt.Sprintf(" ami.id: machine has %q, CPMS template has %q → syncing", *mapi.AMI.ID, cpmsAMI)) + cpms.AMI.ID = mapi.AMI.ID + } + } + + if len(mapi.BlockDevices) > 0 && mapi.BlockDevices[0].EBS != nil { + ebs := mapi.BlockDevices[0].EBS + if len(cpms.BlockDevices) == 0 { + cpms.BlockDevices = []machinev1beta1.BlockDeviceMappingSpec{{EBS: &machinev1beta1.EBSBlockDeviceSpec{}}} + } + if cpms.BlockDevices[0].EBS == nil { + cpms.BlockDevices[0].EBS = &machinev1beta1.EBSBlockDeviceSpec{} + } + cpmsEBS := cpms.BlockDevices[0].EBS + + if ebs.VolumeSize != nil { + cpmsSize := int64(0) + if cpmsEBS.VolumeSize != nil { + cpmsSize = *cpmsEBS.VolumeSize + } + if *ebs.VolumeSize != cpmsSize { + drifts = append(drifts, fmt.Sprintf(" rootVolume.size: machine has %d, CPMS template has %d → syncing", *ebs.VolumeSize, cpmsSize)) + cpmsEBS.VolumeSize = ebs.VolumeSize + } + } + + if ebs.VolumeType != nil && *ebs.VolumeType != "" { + cpmsType := "" + if cpmsEBS.VolumeType != nil { + cpmsType = *cpmsEBS.VolumeType + } + if *ebs.VolumeType != cpmsType { + drifts = append(drifts, fmt.Sprintf(" rootVolume.type: machine has %q, CPMS template has %q → syncing", *ebs.VolumeType, cpmsType)) + cpmsEBS.VolumeType = ebs.VolumeType + } + } + + if ebs.Iops != nil { + cpmsIOPS := int64(0) + if cpmsEBS.Iops != nil { + cpmsIOPS = *cpmsEBS.Iops + } + if *ebs.Iops != cpmsIOPS { + drifts = append(drifts, fmt.Sprintf(" rootVolume.iops: machine has %d, CPMS template has %d → syncing", *ebs.Iops, cpmsIOPS)) + cpmsEBS.Iops = ebs.Iops + } + } + + if ebs.Encrypted != nil { + cpmsEncrypted := false + if cpmsEBS.Encrypted != nil { + cpmsEncrypted = *cpmsEBS.Encrypted + } + if *ebs.Encrypted != cpmsEncrypted { + drifts = append(drifts, fmt.Sprintf(" rootVolume.encrypted: machine has %v, CPMS template has %v → syncing", *ebs.Encrypted, cpmsEncrypted)) + cpmsEBS.Encrypted = ebs.Encrypted + } + } + + mapiKMS := "" + if ebs.KMSKey.ARN != nil { + mapiKMS = *ebs.KMSKey.ARN + } + if mapiKMS == "" && ebs.KMSKey.ID != nil { + mapiKMS = *ebs.KMSKey.ID + } + cpmsKMS := "" + if cpmsEBS.KMSKey.ARN != nil { + cpmsKMS = *cpmsEBS.KMSKey.ARN + } + if cpmsKMS == "" && cpmsEBS.KMSKey.ID != nil { + cpmsKMS = *cpmsEBS.KMSKey.ID + } + if mapiKMS != "" && mapiKMS != cpmsKMS { + drifts = append(drifts, fmt.Sprintf(" rootVolume.kmsKey: machine has %q, CPMS template has %q → syncing", mapiKMS, cpmsKMS)) + cpmsEBS.KMSKey = ebs.KMSKey + } + } + + if mapi.IAMInstanceProfile != nil && mapi.IAMInstanceProfile.ID != nil && *mapi.IAMInstanceProfile.ID != "" { + cpmsProfile := "" + if cpms.IAMInstanceProfile != nil && cpms.IAMInstanceProfile.ID != nil { + cpmsProfile = *cpms.IAMInstanceProfile.ID + } + if *mapi.IAMInstanceProfile.ID != cpmsProfile { + drifts = append(drifts, fmt.Sprintf(" iamInstanceProfile: machine has %q, CPMS template has %q → syncing", *mapi.IAMInstanceProfile.ID, cpmsProfile)) + cpms.IAMInstanceProfile = mapi.IAMInstanceProfile + } + } + + if mapi.MetadataServiceOptions.Authentication != "" { + mapiAuth := string(mapi.MetadataServiceOptions.Authentication) + cpmsAuth := string(cpms.MetadataServiceOptions.Authentication) + if mapiAuth != cpmsAuth { + drifts = append(drifts, fmt.Sprintf(" metadataServiceOptions.authentication: machine has %q, CPMS template has %q → syncing", mapiAuth, cpmsAuth)) + cpms.MetadataServiceOptions.Authentication = mapi.MetadataServiceOptions.Authentication + } + } + + if mapi.PublicIP != nil { + cpmsPublicIP := false + if cpms.PublicIP != nil { + cpmsPublicIP = *cpms.PublicIP + } + if *mapi.PublicIP != cpmsPublicIP { + drifts = append(drifts, fmt.Sprintf(" publicIP: machine has %v, CPMS template has %v → syncing", *mapi.PublicIP, cpmsPublicIP)) + cpms.PublicIP = mapi.PublicIP + } + } + + return drifts +} + +func decodeCPMSProviderSpec(raw *runtime.RawExtension) (*machinev1beta1.AWSMachineProviderConfig, error) { + if raw == nil { + return nil, nil + } + + if raw.Object != nil { + if cfg, ok := raw.Object.(*machinev1beta1.AWSMachineProviderConfig); ok { + return cfg, nil + } + } + + if raw.Raw == nil { + return nil, nil + } + + cfg := &machinev1beta1.AWSMachineProviderConfig{} + if err := json.Unmarshal(raw.Raw, cfg); err != nil { + return nil, fmt.Errorf("decode CPMS providerSpec: %w", err) + } + return cfg, nil +} diff --git a/pkg/asset/machines/mastercpmssync_test.go b/pkg/asset/machines/mastercpmssync_test.go new file mode 100644 index 00000000000..37481657222 --- /dev/null +++ b/pkg/asset/machines/mastercpmssync_test.go @@ -0,0 +1,257 @@ +package machines + +import ( + "testing" + + "github.com/stretchr/testify/assert" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/utils/ptr" + + machinev1beta1 "github.com/openshift/api/machine/v1beta1" +) + +func makeMAPIProviderConfig(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 makeTestCPMSProviderConfig(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", + }, + } +} + +func TestSyncCPMSAWSFields_NoDrift(t *testing.T) { + mapi := makeMAPIProviderConfig("m6i.xlarge", 120, "gp3") + cpms := makeTestCPMSProviderConfig("m6i.xlarge", 120, "gp3") + + drifts := syncCPMSAWSFields(mapi, cpms) + + assert.Empty(t, drifts, "expected no drift when specs match") + assert.Equal(t, "m6i.xlarge", cpms.InstanceType) +} + +func TestSyncCPMSAWSFields_InstanceTypeDrift(t *testing.T) { + mapi := makeMAPIProviderConfig("m6i.4xlarge", 120, "gp3") + cpms := makeTestCPMSProviderConfig("m6i.xlarge", 120, "gp3") + + drifts := syncCPMSAWSFields(mapi, cpms) + + assert.Len(t, drifts, 1) + assert.Contains(t, drifts[0], "instanceType") + assert.Equal(t, "m6i.4xlarge", cpms.InstanceType) +} + +func TestSyncCPMSAWSFields_RootVolumeDrift(t *testing.T) { + mapi := makeMAPIProviderConfig("m6i.xlarge", 200, "io2") + mapi.BlockDevices[0].EBS.Iops = ptr.To(int64(5000)) + + cpms := makeTestCPMSProviderConfig("m6i.xlarge", 120, "gp3") + + drifts := syncCPMSAWSFields(mapi, cpms) + + assert.Len(t, drifts, 3) // size, type, iops + assert.Equal(t, ptr.To(int64(200)), cpms.BlockDevices[0].EBS.VolumeSize) + assert.Equal(t, ptr.To("io2"), cpms.BlockDevices[0].EBS.VolumeType) + assert.Equal(t, ptr.To(int64(5000)), cpms.BlockDevices[0].EBS.Iops) +} + +func TestSyncCPMSAWSFields_AMIDrift(t *testing.T) { + mapi := makeMAPIProviderConfig("m6i.xlarge", 120, "gp3") + mapi.AMI.ID = ptr.To("ami-custom-image") + + cpms := makeTestCPMSProviderConfig("m6i.xlarge", 120, "gp3") + + drifts := syncCPMSAWSFields(mapi, cpms) + + assert.Len(t, drifts, 1) + assert.Contains(t, drifts[0], "ami.id") + assert.Equal(t, ptr.To("ami-custom-image"), cpms.AMI.ID) +} + +func TestSyncCPMSAWSFields_IAMProfileDrift(t *testing.T) { + mapi := makeMAPIProviderConfig("m6i.xlarge", 120, "gp3") + mapi.IAMInstanceProfile = &machinev1beta1.AWSResourceReference{ + ID: ptr.To("custom-iam-profile"), + } + + cpms := makeTestCPMSProviderConfig("m6i.xlarge", 120, "gp3") + + drifts := syncCPMSAWSFields(mapi, cpms) + + assert.Len(t, drifts, 1) + assert.Contains(t, drifts[0], "iamInstanceProfile") + assert.Equal(t, ptr.To("custom-iam-profile"), cpms.IAMInstanceProfile.ID) +} + +func TestSyncCPMSAWSFields_MetadataAuthDrift(t *testing.T) { + mapi := makeMAPIProviderConfig("m6i.xlarge", 120, "gp3") + mapi.MetadataServiceOptions.Authentication = "Required" + + cpms := makeTestCPMSProviderConfig("m6i.xlarge", 120, "gp3") + + drifts := syncCPMSAWSFields(mapi, cpms) + + assert.Len(t, drifts, 1) + assert.Contains(t, drifts[0], "metadataServiceOptions.authentication") + assert.Equal(t, machinev1beta1.MetadataServiceAuthentication("Required"), cpms.MetadataServiceOptions.Authentication) +} + +func TestSyncCPMSAWSFields_MultiFieldDrift(t *testing.T) { + mapi := makeMAPIProviderConfig("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) + + cpms := makeTestCPMSProviderConfig("m6i.xlarge", 120, "gp3") + + drifts := syncCPMSAWSFields(mapi, cpms) + + assert.Greater(t, len(drifts), 4) + assert.Equal(t, "m6i.4xlarge", cpms.InstanceType) + assert.Equal(t, ptr.To("ami-custom"), cpms.AMI.ID) + assert.Equal(t, ptr.To(int64(200)), cpms.BlockDevices[0].EBS.VolumeSize) + assert.Equal(t, ptr.To("io2"), cpms.BlockDevices[0].EBS.VolumeType) + assert.Equal(t, ptr.To("custom-profile"), cpms.IAMInstanceProfile.ID) + assert.Equal(t, machinev1beta1.MetadataServiceAuthentication("Required"), cpms.MetadataServiceOptions.Authentication) + assert.Equal(t, ptr.To(true), cpms.PublicIP) +} + +func TestSyncCPMSAWSFields_KMSKeyDrift(t *testing.T) { + mapi := makeMAPIProviderConfig("m6i.xlarge", 120, "gp3") + mapi.BlockDevices[0].EBS.KMSKey = machinev1beta1.AWSResourceReference{ + ARN: ptr.To("arn:aws:kms:us-east-1:123456789:key/my-key"), + } + + cpms := makeTestCPMSProviderConfig("m6i.xlarge", 120, "gp3") + + drifts := syncCPMSAWSFields(mapi, cpms) + + assert.Len(t, drifts, 1) + assert.Contains(t, drifts[0], "rootVolume.kmsKey") + assert.Equal(t, ptr.To("arn:aws:kms:us-east-1:123456789:key/my-key"), cpms.BlockDevices[0].EBS.KMSKey.ARN) +} + +func TestSyncCPMSAWSFields_PublicIPDrift(t *testing.T) { + mapi := makeMAPIProviderConfig("m6i.xlarge", 120, "gp3") + mapi.PublicIP = ptr.To(true) + + cpms := makeTestCPMSProviderConfig("m6i.xlarge", 120, "gp3") + + drifts := syncCPMSAWSFields(mapi, cpms) + + assert.Len(t, drifts, 1) + assert.Contains(t, drifts[0], "publicIP") + assert.Equal(t, ptr.To(true), cpms.PublicIP) +} + +func TestSyncCPMSAWSFields_SubnetNotSynced(t *testing.T) { + mapi := makeMAPIProviderConfig("m6i.xlarge", 120, "gp3") + mapi.Subnet = machinev1beta1.AWSResourceReference{ + ID: ptr.To("subnet-abc123"), + } + + cpms := makeTestCPMSProviderConfig("m6i.xlarge", 120, "gp3") + + drifts := syncCPMSAWSFields(mapi, cpms) + + assert.Empty(t, drifts, "subnet should not be synced to CPMS (handled by FailureDomains)") +} + +func TestSyncCPMSAWSFields_PlacementNotSynced(t *testing.T) { + mapi := makeMAPIProviderConfig("m6i.xlarge", 120, "gp3") + mapi.Placement.AvailabilityZone = "us-east-1a" + + cpms := makeTestCPMSProviderConfig("m6i.xlarge", 120, "gp3") + + drifts := syncCPMSAWSFields(mapi, cpms) + + assert.Empty(t, drifts, "placement AZ should not be synced to CPMS (handled by FailureDomains)") +} + +func TestDecodeCPMSProviderSpec_NilInput(t *testing.T) { + result, err := decodeCPMSProviderSpec(nil) + assert.Nil(t, result) + assert.NoError(t, err) +} + +func TestDecodeCPMSProviderSpec_FromRawBytes(t *testing.T) { + raw := []byte(`{ + "apiVersion": "machine.openshift.io/v1beta1", + "kind": "AWSMachineProviderConfig", + "instanceType": "m6i.xlarge", + "blockDevices": [{"ebs": {"volumeSize": 120, "volumeType": "gp3"}}] + }`) + ext := &runtime.RawExtension{Raw: raw} + + result, err := decodeCPMSProviderSpec(ext) + + assert.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, "m6i.xlarge", result.InstanceType) + assert.Len(t, result.BlockDevices, 1) + assert.Equal(t, ptr.To(int64(120)), result.BlockDevices[0].EBS.VolumeSize) +}