From 6df11aa5090d9ffc9b73e9b480824355681db1de Mon Sep 17 00:00:00 2001 From: Maysa Macedo Date: Tue, 27 Dec 2022 21:23:04 -0300 Subject: [PATCH] OpenStack: Dual stack support with BYON This commit adds dual stack support with bring your own network for OpenStack platform. The new ControlPlanePort field accepts IPv4/IPv6 subnets and the network in the install config, while the machinesSubnet only supports IPv4 Subnets and is deprecated. --- .../install.openshift.io_installconfigs.yaml | 54 ++++- .../openstack/validation/cloudinfo.go | 94 +++++--- .../openstack/validation/machinepool.go | 4 +- .../openstack/validation/platform.go | 95 ++++++-- .../openstack/validation/platform_test.go | 208 +++++++++++++++--- pkg/asset/machines/openstack/machines.go | 21 +- pkg/asset/quota/openstack/openstack.go | 2 +- pkg/tfvars/openstack/openstack.go | 30 +-- pkg/tfvars/openstack/ports.go | 85 +++++++ pkg/types/conversion/installconfig.go | 13 ++ pkg/types/openstack/machinepool.go | 21 +- pkg/types/openstack/platform.go | 12 +- pkg/types/openstack/validation/platform.go | 24 ++ .../openstack/validation/platform_test.go | 15 ++ pkg/types/openstack/validation/techpreview.go | 4 + .../openstack/validation/techpreview_test.go | 41 ++++ 16 files changed, 601 insertions(+), 122 deletions(-) create mode 100644 pkg/types/openstack/validation/techpreview_test.go diff --git a/data/data/install.openshift.io_installconfigs.yaml b/data/data/install.openshift.io_installconfigs.yaml index d4383291cc0..353668db98d 100644 --- a/data/data/install.openshift.io_installconfigs.yaml +++ b/data/data/install.openshift.io_installconfigs.yaml @@ -3057,6 +3057,49 @@ spec: use for instances in this cluster. Deprecated: use FlavorName in DefaultMachinePlatform to define default flavor.' type: string + controlPlanePort: + description: ControlPlanePort contains details of the network + attached to the control plane port, with the network either + containing one openstack subnet for IPv4 or two openstack subnets + for dualstack clusters. Providing this configuration will prevent + OpenShift from managing or updating this network and its subnets. + The network and its subnets will be used during creation of + all nodes. This is a TechPreview feature and requires setting + featureSet to TechPreviewNoUpgrade. + properties: + fixedIPs: + description: Specify subnets of the network where control + plane port will be discovered. + items: + description: FixedIP identifies a subnet defined by a subnet + filter. + properties: + subnet: + description: SubnetFilter defines a subnet by ID and/or + name. + properties: + id: + type: string + name: + type: string + type: object + required: + - subnet + type: object + type: array + network: + description: Network is a query for an openstack network that + the port will be discovered on. This will fail if the query + returns more than one network. + properties: + id: + type: string + name: + type: string + type: object + required: + - fixedIPs + type: object defaultMachinePlatform: description: DefaultMachinePlatform is the default configuration used when installing on OpenStack for machine pools which do @@ -3190,11 +3233,12 @@ spec: type: string type: object machinesSubnet: - description: MachinesSubnet is the UUIDv4 of an openstack subnet. - This subnet will be used by all nodes created by the installer. - By setting this, the installer will no longer create a network - and subnet. The subnet and network specified in MachinesSubnet - will not be deleted or modified by the installer. + description: 'DeprecatedMachinesSubnet is a string of the UUIDv4 + of an openstack subnet. This subnet will be used by all nodes + created by the installer. By setting this, the installer will + no longer create a network and subnet. The subnet and network + specified in MachinesSubnet will not be deleted or modified + by the installer. Deprecated: Use ControlPlanePort' type: string octaviaSupport: description: 'OctaviaSupport holds a `0` or `1` value that indicates diff --git a/pkg/asset/installconfig/openstack/validation/cloudinfo.go b/pkg/asset/installconfig/openstack/validation/cloudinfo.go index a216f5c27c1..4d88b5c25fe 100644 --- a/pkg/asset/installconfig/openstack/validation/cloudinfo.go +++ b/pkg/asset/installconfig/openstack/validation/cloudinfo.go @@ -27,23 +27,25 @@ import ( "github.com/openshift/installer/pkg/quota" "github.com/openshift/installer/pkg/types" + "github.com/openshift/installer/pkg/types/openstack" openstackdefaults "github.com/openshift/installer/pkg/types/openstack/defaults" "github.com/openshift/installer/pkg/types/openstack/validation/networkextensions" ) // CloudInfo caches data fetched from the user's openstack cloud type CloudInfo struct { - APIFIP *floatingips.FloatingIP - ExternalNetwork *networks.Network - Flavors map[string]Flavor - IngressFIP *floatingips.FloatingIP - MachinesSubnet *subnets.Subnet - OSImage *images.Image - ComputeZones []string - VolumeZones []string - VolumeTypes []string - NetworkExtensions []extensions.Extension - Quotas []quota.Quota + APIFIP *floatingips.FloatingIP + ExternalNetwork *networks.Network + Flavors map[string]Flavor + IngressFIP *floatingips.FloatingIP + ControlPlanePortSubnets []*subnets.Subnet + ControlPlanePortNetwork *networks.Network + OSImage *images.Image + ComputeZones []string + VolumeZones []string + VolumeTypes []string + NetworkExtensions []extensions.Extension + Quotas []quota.Quota clients *clients } @@ -117,7 +119,7 @@ func GetCloudInfo(ic *types.InstallConfig) (*CloudInfo, error) { func (ci *CloudInfo) collectInfo(ic *types.InstallConfig, opts *clientconfig.ClientOpts) error { var err error - ci.ExternalNetwork, err = ci.getNetwork(ic.OpenStack.ExternalNetwork) + ci.ExternalNetwork, err = ci.getNetworkByName(ic.OpenStack.ExternalNetwork) if err != nil { return fmt.Errorf("failed to fetch external network info: %w", err) } @@ -177,10 +179,16 @@ func (ci *CloudInfo) collectInfo(ic *types.InstallConfig, opts *clientconfig.Cli } } } - - ci.MachinesSubnet, err = ci.getSubnet(ic.OpenStack.MachinesSubnet) - if err != nil { - return fmt.Errorf("failed to fetch machine subnet info: %w", err) + if ic.OpenStack.ControlPlanePort != nil { + controlPlanePort := ic.OpenStack.ControlPlanePort + ci.ControlPlanePortSubnets, err = ci.getSubnets(controlPlanePort) + if err != nil { + return err + } + ci.ControlPlanePortNetwork, err = ci.getNetwork(controlPlanePort.Network.Name, controlPlanePort.Network.ID) + if err != nil { + return err + } } ci.APIFIP, err = ci.getFloatingIP(ic.OpenStack.APIFloatingIP) @@ -226,20 +234,24 @@ func (ci *CloudInfo) collectInfo(ic *types.InstallConfig, opts *clientconfig.Cli return nil } - -func (ci *CloudInfo) getSubnet(subnetID string) (*subnets.Subnet, error) { - if subnetID == "" { - return nil, nil - } - subnet, err := subnets.Get(ci.clients.networkClient, subnetID).Extract() - if err != nil { - if isNotFoundError(err) { - return nil, nil +func (ci *CloudInfo) getSubnets(controlPlanePort *openstack.PortTarget) ([]*subnets.Subnet, error) { + controlPlaneSubnets := make([]*subnets.Subnet, 0, len(controlPlanePort.FixedIPs)) + for _, fixedIP := range controlPlanePort.FixedIPs { + page, err := subnets.List(ci.clients.networkClient, subnets.ListOpts{ID: fixedIP.Subnet.ID, Name: fixedIP.Subnet.Name}).AllPages() + if err != nil { + return controlPlaneSubnets, err + } + subnetList, err := subnets.ExtractSubnets(page) + if err != nil { + return controlPlaneSubnets, err + } + if len(subnetList) == 1 { + controlPlaneSubnets = append(controlPlaneSubnets, &subnetList[0]) + } else if len(subnetList) > 1 { + return controlPlaneSubnets, fmt.Errorf("found multiple subnets") } - return nil, err } - - return subnet, nil + return controlPlaneSubnets, nil } func isNotFoundError(err error) bool { @@ -285,7 +297,7 @@ func (ci *CloudInfo) getFlavor(flavorName string) (Flavor, error) { }, nil } -func (ci *CloudInfo) getNetwork(networkName string) (*networks.Network, error) { +func (ci *CloudInfo) getNetworkByName(networkName string) (*networks.Network, error) { if networkName == "" { return nil, nil } @@ -305,6 +317,30 @@ func (ci *CloudInfo) getNetwork(networkName string) (*networks.Network, error) { return network, nil } +func (ci *CloudInfo) getNetwork(networkName, networkID string) (*networks.Network, error) { + opts := networks.ListOpts{ + ID: networkID, + Name: networkName, + } + allPages, err := networks.List(ci.clients.networkClient, opts).AllPages() + if err != nil { + return nil, err + } + + allNetworks, err := networks.ExtractNetworks(allPages) + if err != nil { + return nil, err + } + + if len(allNetworks) == 0 { + return nil, nil + } else if len(allNetworks) > 1 { + return nil, fmt.Errorf("found multiple networks") + } + + return &allNetworks[0], nil +} + func (ci *CloudInfo) getFloatingIP(fip string) (*floatingips.FloatingIP, error) { if fip != "" { opts := floatingips.ListOpts{ diff --git a/pkg/asset/installconfig/openstack/validation/machinepool.go b/pkg/asset/installconfig/openstack/validation/machinepool.go index f088b5a6713..f7b72fba224 100644 --- a/pkg/asset/installconfig/openstack/validation/machinepool.go +++ b/pkg/asset/installconfig/openstack/validation/machinepool.go @@ -106,7 +106,7 @@ func validateVolumeTypes(input string, available []string, fldPath *field.Path) func validateUUIDV4s(input []string, fldPath *field.Path) field.ErrorList { allErrs := field.ErrorList{} for idx, uuid := range input { - if !validUUIDv4(uuid) { + if !ValidUUIDv4(uuid) { allErrs = append(allErrs, field.Invalid(fldPath.Index(idx), uuid, "valid UUID v4 must be specified")) } } @@ -116,7 +116,7 @@ func validateUUIDV4s(input []string, fldPath *field.Path) field.ErrorList { // validUUIDv4 checks if string is in UUID v4 format // For more information: https://en.wikipedia.org/wiki/Universally_unique_identifier#Version_4_(random) -func validUUIDv4(s string) bool { +func ValidUUIDv4(s string) bool { uuid, err := guuid.Parse(s) if err != nil { return false diff --git a/pkg/asset/installconfig/openstack/validation/platform.go b/pkg/asset/installconfig/openstack/validation/platform.go index 1707f44faa9..2417a82ce1d 100644 --- a/pkg/asset/installconfig/openstack/validation/platform.go +++ b/pkg/asset/installconfig/openstack/validation/platform.go @@ -2,13 +2,14 @@ package validation import ( "bytes" - "errors" "fmt" "net" "net/url" "github.com/gophercloud/gophercloud/openstack/imageservice/v2/images" + "github.com/gophercloud/gophercloud/openstack/networking/v2/subnets" "k8s.io/apimachinery/pkg/util/validation/field" + utilsslice "k8s.io/utils/strings/slices" "github.com/openshift/installer/pkg/types" "github.com/openshift/installer/pkg/types/openstack" @@ -19,8 +20,10 @@ func ValidatePlatform(p *openstack.Platform, n *types.Networking, ci *CloudInfo) var allErrs field.ErrorList fldPath := field.NewPath("platform", "openstack") - // validate BYO machinesSubnet usage - allErrs = append(allErrs, validateMachinesSubnet(p, n, ci, fldPath)...) + // validate BYO controlPlanePort usage + if p.ControlPlanePort != nil { + allErrs = append(allErrs, validateControlPlanePort(p, n, ci, fldPath)...) + } // validate the externalNetwork allErrs = append(allErrs, validateExternalNetwork(p, ci, fldPath)...) @@ -37,26 +40,79 @@ func ValidatePlatform(p *openstack.Platform, n *types.Networking, ci *CloudInfo) return allErrs } -// validateMachinesSubnet validates the machines subnet and enforces proper byo subnet usage and returns a list of all validation errors -func validateMachinesSubnet(p *openstack.Platform, n *types.Networking, ci *CloudInfo, fldPath *field.Path) (allErrs field.ErrorList) { - if p.MachinesSubnet != "" { - if len(p.ExternalDNS) > 0 { - allErrs = append(allErrs, field.Invalid(fldPath.Child("externalDNS"), p.ExternalDNS, "externalDNS is set, externalDNS is not supported when machinesSubnet is set")) - } - if ci.MachinesSubnet == nil { - allErrs = append(allErrs, field.NotFound(fldPath.Child("machinesSubnet"), p.MachinesSubnet)) - } else if !validUUIDv4(p.MachinesSubnet) { - allErrs = append(allErrs, field.InternalError(fldPath.Child("machinesSubnet"), errors.New("invalid subnet ID"))) +// validateControlPlanePort validates the machines subnets and network, while enforcing proper byo subnets usage and returns a list of all validation errors. +func validateControlPlanePort(p *openstack.Platform, n *types.Networking, ci *CloudInfo, fldPath *field.Path) (allErrs field.ErrorList) { + networkID := "" + hasIPv4Subnet := false + hasIPv6Subnet := false + if len(p.ExternalDNS) > 0 { + allErrs = append(allErrs, field.Invalid(fldPath.Child("externalDNS"), p.ExternalDNS, "externalDNS is set, externalDNS is not supported when ControlPlanePort is set")) + return allErrs + } + networkCIDRs := networksCIDRs(n.MachineNetwork) + for _, fixedIP := range p.ControlPlanePort.FixedIPs { + subnet := getSubnet(ci.ControlPlanePortSubnets, fixedIP.Subnet.ID, fixedIP.Subnet.Name) + if subnet == nil { + subnetDetail := fixedIP.Subnet.ID + if subnetDetail == "" { + subnetDetail = fixedIP.Subnet.Name + } + allErrs = append(allErrs, field.NotFound(fldPath.Child("controlPlanePort").Child("fixedIPs"), subnetDetail)) } else { - if n.MachineNetwork[0].CIDR.String() != ci.MachinesSubnet.CIDR { - allErrs = append(allErrs, field.InternalError(fldPath.Child("machinesSubnet"), fmt.Errorf("the first CIDR in machineNetwork, %s, doesn't match the CIDR of the machineSubnet, %s", n.MachineNetwork[0].CIDR.String(), ci.MachinesSubnet.CIDR))) + if subnet.IPVersion == 6 { + hasIPv6Subnet = true + } else { + hasIPv4Subnet = true } + if !utilsslice.Contains(networkCIDRs, subnet.CIDR) { + allErrs = append(allErrs, field.Invalid(fldPath.Child("controlPlanePort").Child("fixedIPs"), subnet.CIDR, "controlPlanePort CIDR does not match machineNetwork")) + } + if networkID != "" && networkID != subnet.NetworkID { + allErrs = append(allErrs, field.Invalid(fldPath.Child("controlPlanePort").Child("fixedIPs"), subnet.NetworkID, "fixedIPs subnets must be on the same Network")) + } + networkID = subnet.NetworkID + } + } + if !hasIPv4Subnet && hasIPv6Subnet { + allErrs = append(allErrs, field.InternalError(fldPath.Child("controlPlanePort").Child("fixedIPs"), fmt.Errorf("one IPv4 subnet must be specified"))) + } else if hasIPv4Subnet && !hasIPv6Subnet && len(p.ControlPlanePort.FixedIPs) == 2 { + allErrs = append(allErrs, field.InternalError(fldPath.Child("controlPlanePort").Child("fixedIPs"), fmt.Errorf("multiple IPv4 subnets is not supported"))) + } + controlPlaneNetwork := p.ControlPlanePort.Network + if controlPlaneNetwork.ID != "" || controlPlaneNetwork.Name != "" { + networkDetail := controlPlaneNetwork.ID + if networkDetail == "" { + networkDetail = controlPlaneNetwork.Name + } + // check if the networks does not exist. If it does, verifies if the network contains the subnets + if ci.ControlPlanePortNetwork == nil { + allErrs = append(allErrs, field.NotFound(fldPath.Child("controlPlanePort").Child("network"), networkDetail)) + } else if ci.ControlPlanePortNetwork.ID != networkID { + allErrs = append(allErrs, field.Invalid(fldPath.Child("controlPlanePort").Child("network"), networkDetail, "network must contain subnets")) } } - return allErrs } +func networksCIDRs(machineNetwork []types.MachineNetworkEntry) []string { + networks := make([]string, 0, len(machineNetwork)) + for _, network := range machineNetwork { + networks = append(networks, network.CIDR.String()) + } + return networks +} + +func getSubnet(controlPlaneSubnets []*subnets.Subnet, subnetID, subnetName string) *subnets.Subnet { + for _, subnet := range controlPlaneSubnets { + if subnet.ID == subnetID { + return subnet + } else if subnet.Name != "" && subnet.Name == subnetName { + return subnet + } + } + return nil +} + // validateExternalNetwork validates the user's input for the externalNetwork and returns a list of all validation errors func validateExternalNetwork(p *openstack.Platform, ci *CloudInfo, fldPath *field.Path) (allErrs field.ErrorList) { // Return an error if external network was specified in the install config, but hasn't been found @@ -96,9 +152,10 @@ func validateFloatingIPs(p *openstack.Platform, ci *CloudInfo, fldPath *field.Pa // platform VIP validation is done in pkg/types/validation/installconfig.go, // validateAPIAndIngressVIPs(). func validateVIPs(p *openstack.Platform, ci *CloudInfo, fldPath *field.Path) (allErrs field.ErrorList) { - // If the subnet is not found in the CloudInfo object, abandon validation - if ci.MachinesSubnet != nil { - for _, allocationPool := range ci.MachinesSubnet.AllocationPools { + // If the subnet is not found in the CloudInfo object, abandon validation. + // For dual-stack the user needs to pre-create the Port for API and Ingress, so no need for validation. + if len(ci.ControlPlanePortSubnets) == 1 { + for _, allocationPool := range ci.ControlPlanePortSubnets[0].AllocationPools { start := net.ParseIP(allocationPool.Start) end := net.ParseIP(allocationPool.End) diff --git a/pkg/asset/installconfig/openstack/validation/platform_test.go b/pkg/asset/installconfig/openstack/validation/platform_test.go index 0f5f8d99924..7fe0584e582 100644 --- a/pkg/asset/installconfig/openstack/validation/platform_test.go +++ b/pkg/asset/installconfig/openstack/validation/platform_test.go @@ -19,6 +19,7 @@ var ( validExternalNetwork = "valid-external-network" validFIP1 = "128.35.27.8" validFIP2 = "128.35.27.13" + validSubnetID = "031a5b9d-5a89-4465-8d54-3517ec2bad48" ) // Returns a default install @@ -31,18 +32,30 @@ func validPlatform() *openstack.Platform { } } +func validControlPlanePort() *openstack.PortTarget { + fixedIP := openstack.FixedIP{ + Subnet: openstack.SubnetFilter{ID: validSubnetID, Name: "valid-subnet"}, + } + controlPlanePort := &openstack.PortTarget{ + FixedIPs: []openstack.FixedIP{fixedIP}, + } + return controlPlanePort +} + func validNetworking() *types.Networking { return &types.Networking{} } -func withMachinesSubnet(subnetCIDR, allocationPoolStart, allocationPoolEnd string) func(*CloudInfo) { +func withControlPlanePortSubnets(subnetCIDR, allocationPoolStart, allocationPoolEnd string) func(*CloudInfo) { return func(ci *CloudInfo) { - ci.MachinesSubnet = &subnets.Subnet{ + subnet := subnets.Subnet{ CIDR: subnetCIDR, AllocationPools: []subnets.AllocationPool{ {Start: allocationPoolStart, End: allocationPoolEnd}, }, } + Allsubnets := []*subnets.Subnet{&subnet} + ci.ControlPlanePortSubnets = Allsubnets } } func validPlatformCloudInfo(options ...func(*CloudInfo)) *CloudInfo { @@ -219,7 +232,7 @@ func TestOpenStackPlatformValidation(t *testing.T) { p.APIVIPs = []string{"10.0.128.10"} return p }(), - cloudInfo: validPlatformCloudInfo(withMachinesSubnet( + cloudInfo: validPlatformCloudInfo(withControlPlanePortSubnets( "10.0.128.0/24", "10.0.128.8", "10.0.128.255", @@ -235,7 +248,7 @@ func TestOpenStackPlatformValidation(t *testing.T) { p.IngressVIPs = []string{"10.0.128.42"} return p }(), - cloudInfo: validPlatformCloudInfo(withMachinesSubnet( + cloudInfo: validPlatformCloudInfo(withControlPlanePortSubnets( "10.0.128.0/24", "10.0.128.8", "10.0.128.255", @@ -379,59 +392,78 @@ func TestMachineSubnet(t *testing.T) { name: "external dns is not supported", platform: func() *openstack.Platform { p := validPlatform() - p.MachinesSubnet = "031a5b9d-5a89-4465-8d54-3517ec2bad48" p.ExternalDNS = append(p.ExternalDNS, "1.2.3.4") + p.ControlPlanePort = validControlPlanePort() return p }(), cloudInfo: validPlatformCloudInfo(), networking: validNetworking(), - expectedErrMsg: `platform.openstack.externalDNS: Invalid value: \[\]string{"1.2.3.4"}: externalDNS is set, externalDNS is not supported when machinesSubnet is set`, + expectedErrMsg: `platform.openstack.externalDNS: Invalid value: \[\]string{"1.2.3.4"}: externalDNS is set, externalDNS is not supported when ControlPlanePort is set`, }, { - name: "machine subnet not found", + name: "control plane port subnet not found", platform: func() *openstack.Platform { p := validPlatform() - p.MachinesSubnet = "031a5b9d-5a89-4465-8d54-3517ec2bad48" + p.ControlPlanePort = validControlPlanePort() return p }(), cloudInfo: func() *CloudInfo { ci := validPlatformCloudInfo() - ci.MachinesSubnet = nil + subnet := subnets.Subnet{ + ID: "00000000-5a89-4465-8d54-3517ec2bad48", + } + Allsubnets := []*subnets.Subnet{&subnet} + ci.ControlPlanePortSubnets = Allsubnets return ci }(), networking: validNetworking(), - expectedErrMsg: `platform.openstack.machinesSubnet: Not found: "031a5b9d-5a89-4465-8d54-3517ec2bad48"`, + expectedErrMsg: `platform.openstack.controlPlanePort.fixedIPs: Not found: "031a5b9d-5a89-4465-8d54-3517ec2bad48"`, }, { - name: "invalid subnet ID", + name: "network does not contain subnets", platform: func() *openstack.Platform { p := validPlatform() - p.MachinesSubnet = "fake" + p.ControlPlanePort = validControlPlanePort() + p.ControlPlanePort.Network.ID = "00000000-2a22-4465-8d54-3517ec2bad48" return p }(), cloudInfo: func() *CloudInfo { ci := validPlatformCloudInfo() - ci.MachinesSubnet = &subnets.Subnet{ - ID: "031a5b9d-5a89-4465-8d54-3517ec2bad48", + subnet := subnets.Subnet{ + ID: "031a5b9d-5a89-4465-8d54-3517ec2bad48", + NetworkID: "00000000-1a11-4465-8d54-3517ec2bad48", + CIDR: "172.0.0.1/24", } + Allsubnets := []*subnets.Subnet{&subnet} + ci.ControlPlanePortSubnets = Allsubnets + ci.ControlPlanePortNetwork = &networks.Network{ID: "00000000-2a22-4465-8d54-3517ec2bad48"} return ci }(), - networking: validNetworking(), - expectedErrMsg: `platform.openstack.machinesSubnet: Internal error: invalid subnet ID`, + networking: func() *types.Networking { + n := validNetworking() + machineNetworkEntry := &types.MachineNetworkEntry{ + CIDR: *ipnet.MustParseCIDR("172.0.0.1/24"), + } + n.MachineNetwork = []types.MachineNetworkEntry{*machineNetworkEntry} + return n + }(), + expectedErrMsg: `platform.openstack.controlPlanePort.network: Invalid value: "00000000-2a22-4465-8d54-3517ec2bad48": network must contain subnets`, }, { name: "doesn't match the CIDR", platform: func() *openstack.Platform { p := validPlatform() - p.MachinesSubnet = "031a5b9d-5a89-4465-8d54-3517ec2bad48" + p.ControlPlanePort = validControlPlanePort() return p }(), cloudInfo: func() *CloudInfo { ci := validPlatformCloudInfo() - ci.MachinesSubnet = &subnets.Subnet{ - ID: "031a5b9d-5a89-4465-8d54-3517ec2bad48", + subnet := subnets.Subnet{ + ID: validSubnetID, + CIDR: "172.0.0.1/16", } - ci.MachinesSubnet.CIDR = "172.0.0.1/16" + Allsubnets := []*subnets.Subnet{&subnet} + ci.ControlPlanePortSubnets = Allsubnets return ci }(), networking: func() *types.Networking { @@ -442,33 +474,155 @@ func TestMachineSubnet(t *testing.T) { n.MachineNetwork = []types.MachineNetworkEntry{*machineNetworkEntry} return n }(), - expectedErrMsg: `platform.openstack.machinesSubnet: Internal error: the first CIDR in machineNetwork, 172.0.0.1/24, doesn't match the CIDR of the machineSubnet, 172.0.0.1/16`, + expectedErrMsg: `platform.openstack.controlPlanePort.fixedIPs: Invalid value: "172.0.0.1/16": controlPlanePort CIDR does not match machineNetwork`, }, { - name: "valid machine subnet", + name: "control plane port subnets on different network", platform: func() *openstack.Platform { p := validPlatform() - p.MachinesSubnet = "031a5b9d-5a89-4465-8d54-3517ec2bad48" + fixedIP := openstack.FixedIP{ + Subnet: openstack.SubnetFilter{ID: "00000000-5a89-4465-8d54-3517ec2bad48"}, + } + fixedIPv6 := openstack.FixedIP{ + Subnet: openstack.SubnetFilter{ID: "00000000-1111-4465-8d54-3517ec2bad48"}, + } + p.ControlPlanePort = &openstack.PortTarget{ + FixedIPs: []openstack.FixedIP{fixedIP, fixedIPv6}, + } return p }(), cloudInfo: func() *CloudInfo { ci := validPlatformCloudInfo() - ci.MachinesSubnet = &subnets.Subnet{ - ID: "031a5b9d-5a89-4465-8d54-3517ec2bad48", + subnet := subnets.Subnet{ + ID: "00000000-5a89-4465-8d54-3517ec2bad48", + NetworkID: "00000000-2222-4465-8d54-3517ec2bad48", + CIDR: "172.0.0.1/16", + IPVersion: 4, + } + subnetv6 := subnets.Subnet{ + ID: "00000000-1111-4465-8d54-3517ec2bad48", + NetworkID: "00000000-3333-4465-8d54-3517ec2bad48", + CIDR: "2001:db8::/64", + IPVersion: 6, } - ci.MachinesSubnet.CIDR = "172.0.0.1/24" + Allsubnets := []*subnets.Subnet{&subnet, &subnetv6} + ci.ControlPlanePortSubnets = Allsubnets return ci }(), networking: func() *types.Networking { n := validNetworking() machineNetworkEntry := &types.MachineNetworkEntry{ - CIDR: *ipnet.MustParseCIDR("172.0.0.1/24"), + CIDR: *ipnet.MustParseCIDR("172.0.0.1/16"), + } + machineNetworkEntryv6 := &types.MachineNetworkEntry{ + CIDR: *ipnet.MustParseCIDR("2001:db8::/64"), + } + n.MachineNetwork = []types.MachineNetworkEntry{*machineNetworkEntry, *machineNetworkEntryv6} + return n + }(), + expectedErrMsg: `platform.openstack.controlPlanePort.fixedIPs: Invalid value: "00000000-3333-4465-8d54-3517ec2bad48": fixedIPs subnets must be on the same Network`, + }, + { + name: "valid control plane port", + platform: func() *openstack.Platform { + p := validPlatform() + p.ControlPlanePort = validControlPlanePort() + return p + }(), + cloudInfo: func() *CloudInfo { + ci := validPlatformCloudInfo() + subnet := subnets.Subnet{ + ID: validSubnetID, + CIDR: "172.0.0.1/16", + } + Allsubnets := []*subnets.Subnet{&subnet} + ci.ControlPlanePortSubnets = Allsubnets + return ci + }(), + networking: func() *types.Networking { + n := validNetworking() + machineNetworkEntry := &types.MachineNetworkEntry{ + CIDR: *ipnet.MustParseCIDR("172.0.0.1/16"), } n.MachineNetwork = []types.MachineNetworkEntry{*machineNetworkEntry} return n }(), expectedErrMsg: "", }, + { + name: "control plane port multiple ipv4 subnets", + platform: func() *openstack.Platform { + p := validPlatform() + fixedIP := openstack.FixedIP{ + Subnet: openstack.SubnetFilter{ID: "00000000-5a89-4465-8d54-3517ec2bad48"}, + } + fixedIPv6 := openstack.FixedIP{ + Subnet: openstack.SubnetFilter{ID: "00000000-1111-4465-8d54-3517ec2bad48"}, + } + p.ControlPlanePort = &openstack.PortTarget{ + FixedIPs: []openstack.FixedIP{fixedIP, fixedIPv6}, + } + return p + }(), + cloudInfo: func() *CloudInfo { + ci := validPlatformCloudInfo() + subnet := subnets.Subnet{ + ID: "00000000-5a89-4465-8d54-3517ec2bad48", + CIDR: "172.0.0.1/16", + IPVersion: 4, + } + subnetv6 := subnets.Subnet{ + ID: "00000000-1111-4465-8d54-3517ec2bad48", + CIDR: "10.0.0.0/16", + IPVersion: 4, + } + Allsubnets := []*subnets.Subnet{&subnet, &subnetv6} + ci.ControlPlanePortSubnets = Allsubnets + return ci + }(), + networking: func() *types.Networking { + n := validNetworking() + machineNetworkEntry := &types.MachineNetworkEntry{ + CIDR: *ipnet.MustParseCIDR("172.0.0.1/16"), + } + n.MachineNetwork = []types.MachineNetworkEntry{*machineNetworkEntry} + return n + }(), + expectedErrMsg: `[platform.openstack.controlPlanePort.fixedIPs: Internal error: controlPlanePort CIDRs does not match machineNetwork, platform.openstack.controlPlanePort.fixedIPs: Internal error: multiple IPv4 subnets is not supported]`, + }, + { + name: "control plane port no ipv4 subnets", + platform: func() *openstack.Platform { + p := validPlatform() + fixedIPv6 := openstack.FixedIP{ + Subnet: openstack.SubnetFilter{ID: "00000000-1111-4465-8d54-3517ec2bad48"}, + } + p.ControlPlanePort = &openstack.PortTarget{ + FixedIPs: []openstack.FixedIP{fixedIPv6}, + } + return p + }(), + cloudInfo: func() *CloudInfo { + ci := validPlatformCloudInfo() + subnetv6 := subnets.Subnet{ + ID: "00000000-1111-4465-8d54-3517ec2bad48", + CIDR: "2001:db8::/64", + IPVersion: 6, + } + Allsubnets := []*subnets.Subnet{&subnetv6} + ci.ControlPlanePortSubnets = Allsubnets + return ci + }(), + networking: func() *types.Networking { + n := validNetworking() + machineNetworkEntry := &types.MachineNetworkEntry{ + CIDR: *ipnet.MustParseCIDR("2001:db8::/64"), + } + n.MachineNetwork = []types.MachineNetworkEntry{*machineNetworkEntry} + return n + }(), + expectedErrMsg: `platform.openstack.controlPlanePort.fixedIPs: Internal error: one IPv4 subnet must be specified`, + }, } for _, tc := range cases { diff --git a/pkg/asset/machines/openstack/machines.go b/pkg/asset/machines/openstack/machines.go index 48bdd9a883a..cd2cff88391 100644 --- a/pkg/asset/machines/openstack/machines.go +++ b/pkg/asset/machines/openstack/machines.go @@ -111,14 +111,25 @@ func Machines(clusterID string, config *types.InstallConfig, pool *types.Machine func generateProvider(clusterID string, platform *openstack.Platform, mpool *openstack.MachinePool, osImage string, role, userDataSecret string, trunkSupport bool, failureDomain machinev1.OpenStackFailureDomain) (*machinev1alpha1.OpenstackProviderSpec, error) { var controlPlaneNetwork machinev1alpha1.NetworkParam additionalNetworks := make([]machinev1alpha1.NetworkParam, 0, len(mpool.AdditionalNetworkIDs)) - primarySubnet := platform.MachinesSubnet + primarySubnet := "" - if platform.MachinesSubnet != "" { + if platform.ControlPlanePort != nil { + var subnets []machinev1alpha1.SubnetParam + controlPlanePort := platform.ControlPlanePort + + for _, fixedIP := range controlPlanePort.FixedIPs { + subnets = append(subnets, machinev1alpha1.SubnetParam{ + Filter: machinev1alpha1.SubnetFilter{ID: fixedIP.Subnet.ID, Name: fixedIP.Subnet.Name}, + }) + } controlPlaneNetwork = machinev1alpha1.NetworkParam{ - Subnets: []machinev1alpha1.SubnetParam{{ - UUID: platform.MachinesSubnet, - }}, + Subnets: subnets, + Filter: machinev1alpha1.Filter{ + Name: controlPlanePort.Network.Name, + ID: controlPlanePort.Network.ID, + }, } + primarySubnet = controlPlanePort.FixedIPs[0].Subnet.ID } else { controlPlaneNetwork = machinev1alpha1.NetworkParam{ Subnets: []machinev1alpha1.SubnetParam{ diff --git a/pkg/asset/quota/openstack/openstack.go b/pkg/asset/quota/openstack/openstack.go index 1fb2fa70905..979cbc32b69 100644 --- a/pkg/asset/quota/openstack/openstack.go +++ b/pkg/asset/quota/openstack/openstack.go @@ -56,7 +56,7 @@ func Constraints(ci *validation.CloudInfo, controlPlanes []machineapi.Machine, c // If the cluster is using pre-provisioned networks, then the quota constraints should be // null because the installer doesn't need to create any resources. - if ci.MachinesSubnet == nil { + if len(ci.ControlPlanePortSubnets) == 0 { constraints = append(constraints, networkConstraint(1), routerConstraint(1), subnetConstraint(1)) } diff --git a/pkg/tfvars/openstack/openstack.go b/pkg/tfvars/openstack/openstack.go index 26e1edf8ce0..ee5af5f22b4 100644 --- a/pkg/tfvars/openstack/openstack.go +++ b/pkg/tfvars/openstack/openstack.go @@ -11,7 +11,6 @@ import ( "github.com/gophercloud/gophercloud/openstack/identity/v3/tokens" "github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/attributestags" "github.com/gophercloud/gophercloud/openstack/networking/v2/ports" - "github.com/gophercloud/gophercloud/openstack/networking/v2/subnets" "github.com/gophercloud/utils/openstack/clientconfig" configv1 "github.com/openshift/api/config/v1" @@ -178,23 +177,18 @@ func TFVars( additionalNetworkIDs = mastermpool.AdditionalNetworkIDs } - // defaultMachinesPort carries the machinesSubnet (and its resolved - // network) if provided. + // defaultMachinesPort carries the machine subnets and the network. var defaultMachinesPort *terraformPort - if machinesSubnet := installConfig.Config.Platform.OpenStack.MachinesSubnet; machinesSubnet != "" { - networkID, err := getNetworkFromSubnet(networkClient, machinesSubnet) + if controlPlanePort := installConfig.Config.Platform.OpenStack.ControlPlanePort; controlPlanePort != nil { + port, err := portTargetToTerraformPort(networkClient, *controlPlanePort) if err != nil { - return nil, fmt.Errorf("failed to resolve the given machineSubnet: %w", err) + return nil, fmt.Errorf("failed to resolve portTarget :%w", err) } - defaultMachinesPort = &terraformPort{ - NetworkID: networkID, - FixedIP: []terraformFixedIP{{SubnetID: machinesSubnet}}, - } - + defaultMachinesPort = &port // tagging the API port if pre-created by the user. if apiVIPS := installConfig.Config.OpenStack.APIVIPs; len(apiVIPS) > 0 { // Assuming the API VIPs addresses are on the same Port - err = tagVIPsPort(cloud, clusterID.InfraID, apiVIPS[0], networkID) + err = tagVIPsPort(cloud, clusterID.InfraID, apiVIPS[0], port.NetworkID) if err != nil { return nil, err } @@ -202,7 +196,7 @@ func TFVars( // tagging the Ingress port if pre-created by the user. if ingressVIPS := installConfig.Config.OpenStack.IngressVIPs; len(ingressVIPS) > 0 { // Assuming the Ingress VIPs addresses are on the same Port - err = tagVIPsPort(cloud, clusterID.InfraID, ingressVIPS[0], networkID) + err = tagVIPsPort(cloud, clusterID.InfraID, ingressVIPS[0], port.NetworkID) if err != nil { return nil, err } @@ -347,16 +341,6 @@ func getServiceCatalog(cloud string) (*tokens.ServiceCatalog, error) { return serviceCatalog, nil } -// getNetworkFromSubnet looks up a subnet in openstack and returns the ID of the network it's a part of -func getNetworkFromSubnet(networkClient *gophercloud.ServiceClient, subnetID string) (string, error) { - subnet, err := subnets.Get(networkClient, subnetID).Extract() - if err != nil { - return "", err - } - - return subnet.NetworkID, nil -} - func isOctaviaSupported(serviceCatalog *tokens.ServiceCatalog) (bool, error) { _, err := openstack.V3EndpointURL(serviceCatalog, gophercloud.EndpointOpts{ Type: "load-balancer", diff --git a/pkg/tfvars/openstack/ports.go b/pkg/tfvars/openstack/ports.go index 93c86746ea4..5fe27936a2b 100644 --- a/pkg/tfvars/openstack/ports.go +++ b/pkg/tfvars/openstack/ports.go @@ -1,5 +1,17 @@ package openstack +import ( + "fmt" + + "github.com/gophercloud/gophercloud" + "github.com/gophercloud/gophercloud/openstack/networking/v2/subnets" + "github.com/gophercloud/gophercloud/pagination" + network_utils "github.com/gophercloud/utils/openstack/networking/v2/networks" + + machinev1alpha1 "github.com/openshift/api/machine/v1alpha1" + types_openstack "github.com/openshift/installer/pkg/types/openstack" +) + type terraformFixedIP struct { SubnetID string `json:"subnet_id"` IPAddress string `json:"ip_address"` @@ -9,3 +21,76 @@ type terraformPort struct { NetworkID string `json:"network_id"` FixedIP []terraformFixedIP `json:"fixed_ips"` } + +func portTargetToTerraformPort(networkClient *gophercloud.ServiceClient, portTarget types_openstack.PortTarget) (terraformPort, error) { + networkID := portTarget.Network.ID + if networkID == "" && portTarget.Network.Name != "" { + var err error + networkID, err = network_utils.IDFromName(networkClient, portTarget.Network.Name) + if err != nil { + return terraformPort{}, fmt.Errorf("failed to resolve network ID for network name %q: %w", portTarget.Network.Name, err) + } + } + + terraformFixedIPs := make([]terraformFixedIP, 0, len(portTarget.FixedIPs)) + for _, fixedIP := range portTarget.FixedIPs { + subnetFilter := machinev1alpha1.SubnetFilter{ + ID: fixedIP.Subnet.ID, + Name: fixedIP.Subnet.Name, + } + resolvedSubnetID, resolvedNetworkID, err := resolveSubnetFilter(networkClient, networkID, subnetFilter) + if err != nil { + return terraformPort{}, fmt.Errorf("failed to resolve the subnet filter: %w", err) + } + + if networkID == "" { + networkID = resolvedNetworkID + } + + if networkID != resolvedNetworkID { + return terraformPort{}, fmt.Errorf("control plane port has ports on multiple networks") + } + + terraformFixedIPs = append(terraformFixedIPs, terraformFixedIP{ + SubnetID: resolvedSubnetID, + }) + } + + return terraformPort{ + NetworkID: networkID, + FixedIP: terraformFixedIPs, + }, nil +} + +func resolveSubnetFilter(networkClient *gophercloud.ServiceClient, networkID string, subnetFilter machinev1alpha1.SubnetFilter) (resolvedSubnetID, resolvedNetworkID string, err error) { + if subnetFilter.ProjectID != "" { + subnetFilter.TenantID = "" + } + if err = subnets.List(networkClient, subnets.ListOpts{ + NetworkID: networkID, + Name: subnetFilter.Name, + ID: subnetFilter.ID, + }).EachPage(func(page pagination.Page) (bool, error) { + returnedSubnets, err := subnets.ExtractSubnets(page) + if err != nil { + return false, err + } + for _, subnet := range returnedSubnets { + if resolvedSubnetID == "" { + resolvedSubnetID = subnet.ID + resolvedNetworkID = subnet.NetworkID + } else { + return false, fmt.Errorf("more than one subnet found") + } + } + return true, nil + }); err != nil { + return "", "", fmt.Errorf("failed to list subnets: %w", err) + } + + if resolvedSubnetID == "" { + return "", "", fmt.Errorf("no subnet found") + } + + return resolvedSubnetID, resolvedNetworkID, err +} diff --git a/pkg/types/conversion/installconfig.go b/pkg/types/conversion/installconfig.go index 34ec0e54473..ab4895f4383 100644 --- a/pkg/types/conversion/installconfig.go +++ b/pkg/types/conversion/installconfig.go @@ -176,6 +176,19 @@ func convertOpenStack(config *types.InstallConfig) error { return err } + // machinesSubnet has been deprecated in favor of ControlPlanePort + controlPlanePort := config.Platform.OpenStack.ControlPlanePort + deprecatedMachinesSubnet := config.Platform.OpenStack.DeprecatedMachinesSubnet + if deprecatedMachinesSubnet != "" && controlPlanePort == nil { + fixedIPs := []openstack.FixedIP{{Subnet: openstack.SubnetFilter{ID: deprecatedMachinesSubnet}}} + config.Platform.OpenStack.ControlPlanePort = &openstack.PortTarget{FixedIPs: fixedIPs} + } else if deprecatedMachinesSubnet != "" && + controlPlanePort != nil { + if !(len(controlPlanePort.FixedIPs) == 1 && controlPlanePort.FixedIPs[0].Subnet.ID == deprecatedMachinesSubnet) { + return field.Invalid(field.NewPath("platform").Child("openstack").Child("machinesSubnet"), deprecatedMachinesSubnet, fmt.Sprintf("%s is deprecated; only %s needs to be specified", "machinesSubnet", "controlPlanePort")) + } + } + return nil } diff --git a/pkg/types/openstack/machinepool.go b/pkg/types/openstack/machinepool.go index 944fe9a6f58..85ef9937d57 100644 --- a/pkg/types/openstack/machinepool.go +++ b/pkg/types/openstack/machinepool.go @@ -1,7 +1,5 @@ package openstack -import machinev1alpha1 "github.com/openshift/api/machine/v1alpha1" - // MachinePool stores the configuration for a machine pool installed // on OpenStack. type MachinePool struct { @@ -90,21 +88,26 @@ type RootVolume struct { // PortTarget defines, directly or indirectly, one or more subnets where to attach a port. type PortTarget struct { - // Network is a query for an openstack network that the port will be created or discovered on. + // Network is a query for an openstack network that the port will be discovered on. // This will fail if the query returns more than one network. Network NetworkFilter `json:"network,omitempty"` - // Specify pairs of subnet and/or IP address. These should be subnets of the network with the given NetworkID. - FixedIPs []FixedIP `json:"fixedIPs,omitempty"` + // Specify subnets of the network where control plane port will be discovered. + FixedIPs []FixedIP `json:"fixedIPs"` } -// NetworkFilter defines a network either by name or by ID. +// NetworkFilter defines a network by name and/or ID. type NetworkFilter struct { Name string `json:"name,omitempty"` ID string `json:"id,omitempty"` } -// FixedIP defines a subnet. +// FixedIP identifies a subnet defined by a subnet filter. type FixedIP struct { - // subnetID specifies the ID of the subnet where the fixed IP will be allocated. - Subnet machinev1alpha1.SubnetFilter `json:"subnet"` + Subnet SubnetFilter `json:"subnet"` +} + +// SubnetFilter defines a subnet by ID and/or name. +type SubnetFilter struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` } diff --git a/pkg/types/openstack/platform.go b/pkg/types/openstack/platform.go index b70b3e48363..471a545cf31 100644 --- a/pkg/types/openstack/platform.go +++ b/pkg/types/openstack/platform.go @@ -113,11 +113,19 @@ type Platform struct { // +optional IngressVIPs []string `json:"ingressVIPs,omitempty"` - // MachinesSubnet is the UUIDv4 of an openstack subnet. This subnet will be used by all nodes created by the installer. + // DeprecatedMachinesSubnet is a string of the UUIDv4 of an openstack subnet. This subnet will be used by all nodes created by the installer. // By setting this, the installer will no longer create a network and subnet. // The subnet and network specified in MachinesSubnet will not be deleted or modified by the installer. + // Deprecated: Use ControlPlanePort // +optional - MachinesSubnet string `json:"machinesSubnet,omitempty"` + DeprecatedMachinesSubnet string `json:"machinesSubnet,omitempty"` + + // ControlPlanePort contains details of the network attached to the control plane port, with the network either containing one openstack + // subnet for IPv4 or two openstack subnets for dualstack clusters. Providing this configuration will prevent OpenShift from managing + // or updating this network and its subnets. The network and its subnets will be used during creation of all nodes. + // This is a TechPreview feature and requires setting featureSet to TechPreviewNoUpgrade. + // +optional + ControlPlanePort *PortTarget `json:"controlPlanePort,omitempty"` // LoadBalancer defines how the load balancer used by the cluster is configured. // +optional diff --git a/pkg/types/openstack/validation/platform.go b/pkg/types/openstack/validation/platform.go index 4b187bf346b..59d0f0bfa0c 100644 --- a/pkg/types/openstack/validation/platform.go +++ b/pkg/types/openstack/validation/platform.go @@ -4,6 +4,7 @@ import ( "k8s.io/apimachinery/pkg/util/validation/field" configv1 "github.com/openshift/api/config/v1" + "github.com/openshift/installer/pkg/asset/installconfig/openstack/validation" "github.com/openshift/installer/pkg/types" "github.com/openshift/installer/pkg/types/openstack" "github.com/openshift/installer/pkg/validate" @@ -27,6 +28,10 @@ func ValidatePlatform(p *openstack.Platform, n *types.Networking, fldPath *field } } + if c.OpenStack.ControlPlanePort != nil { + allErrs = append(allErrs, validateControlPlanePort(c, fldPath)...) + } + return allErrs } @@ -39,3 +44,22 @@ func validateLoadBalancer(lbType configv1.PlatformLoadBalancerType) bool { return false } } + +// validateControlPlanePort returns all the errors found when the control plane port is not valid. +func validateControlPlanePort(c *types.InstallConfig, fldPath *field.Path) field.ErrorList { + controlPlanePort := c.OpenStack.ControlPlanePort + var allErrs field.ErrorList + if len(controlPlanePort.FixedIPs) <= 2 { + for _, fixedIP := range controlPlanePort.FixedIPs { + if fixedIP.Subnet.ID != "" && !validation.ValidUUIDv4(fixedIP.Subnet.ID) { + allErrs = append(allErrs, field.Invalid(fldPath.Child("controlPlanePort").Child("fixedIPs"), fixedIP.Subnet.ID, "invalid subnet ID")) + } + } + if controlPlanePort.Network.ID != "" && !validation.ValidUUIDv4(controlPlanePort.Network.ID) { + allErrs = append(allErrs, field.Invalid(fldPath.Child("controlPlanePort").Child("network"), controlPlanePort.Network.ID, "invalid network ID")) + } + } else { + allErrs = append(allErrs, field.TooMany(fldPath.Child("fixedIPs"), len(controlPlanePort.FixedIPs), 2)) + } + return allErrs +} diff --git a/pkg/types/openstack/validation/platform_test.go b/pkg/types/openstack/validation/platform_test.go index fe9a6561e4b..1d0d7806fea 100644 --- a/pkg/types/openstack/validation/platform_test.go +++ b/pkg/types/openstack/validation/platform_test.go @@ -128,6 +128,21 @@ func TestValidatePlatform(t *testing.T) { networking: validNetworking(), valid: true, }, + { + name: "invalid subnet ID", + platform: func() *openstack.Platform { + p := validPlatform() + fixedIP := openstack.FixedIP{ + Subnet: openstack.SubnetFilter{ID: "fake"}, + } + p.ControlPlanePort = &openstack.PortTarget{ + FixedIPs: []openstack.FixedIP{fixedIP}, + } + return p + }(), + networking: validNetworking(), + expectedError: `^test-path\.controlPlanePort.fixedIPs: Invalid value: "fake": invalid subnet ID`, + }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { diff --git a/pkg/types/openstack/validation/techpreview.go b/pkg/types/openstack/validation/techpreview.go index 688289a0a31..15d98ea2f94 100644 --- a/pkg/types/openstack/validation/techpreview.go +++ b/pkg/types/openstack/validation/techpreview.go @@ -14,5 +14,9 @@ func FilledInTechPreviewFields(installConfig *types.InstallConfig) (fields []*fi return nil } + if installConfig.OpenStack.ControlPlanePort != nil && installConfig.OpenStack.DeprecatedMachinesSubnet == "" { + fields = append(fields, field.NewPath("platform", "openstack", "controlPlanePort")) + } + return fields } diff --git a/pkg/types/openstack/validation/techpreview_test.go b/pkg/types/openstack/validation/techpreview_test.go new file mode 100644 index 00000000000..f08ffc90a88 --- /dev/null +++ b/pkg/types/openstack/validation/techpreview_test.go @@ -0,0 +1,41 @@ +package validation + +import ( + "testing" + + "k8s.io/apimachinery/pkg/util/validation/field" + + "github.com/openshift/installer/pkg/types" + "github.com/openshift/installer/pkg/types/openstack" +) + +func TestFilledInControlPlanePortField(t *testing.T) { + t.Run("control_plane_port_field", func(t *testing.T) { + fixedIP := openstack.FixedIP{ + Subnet: openstack.SubnetFilter{ID: "031a5b9d-5a89-4465-8d54-3517ec2bad48"}, + } + installConfig := types.InstallConfig{ + Platform: types.Platform{ + OpenStack: &openstack.Platform{ + ControlPlanePort: &openstack.PortTarget{ + FixedIPs: []openstack.FixedIP{fixedIP}, + }, + }, + }, + } + + expectedField := field.NewPath("platform", "openstack", "controlPlanePort") + + var found bool + + for _, f := range FilledInTechPreviewFields(&installConfig) { + if f.String() == expectedField.String() { + found = true + break + } + } + if !found { + t.Errorf("expected field %q to be detected", expectedField) + } + }) +}