diff --git a/data/data/install.openshift.io_installconfigs.yaml b/data/data/install.openshift.io_installconfigs.yaml index 08d7ff8c573..1d45b918e0c 100644 --- a/data/data/install.openshift.io_installconfigs.yaml +++ b/data/data/install.openshift.io_installconfigs.yaml @@ -6138,6 +6138,77 @@ spec: type: string maxItems: 2 type: array + bgpVIPConfig: + description: |- + BGPVIPConfig configures BGP-based advertisement of the API and + Ingress VIPs. When set, kube-vip (Routing Table Mode) and frr-k8s + are deployed as static pods on control plane nodes to advertise + VIPs via BGP, replacing the default keepalived/VRRP mechanism. + properties: + communities: + description: |- + Communities is an optional list of BGP communities to attach to + advertised VIP routes, in the format "ASN:value" (e.g., "64512:100"). + items: + type: string + type: array + localASN: + description: LocalASN is the Autonomous System Number for + this cluster's BGP speaker. + format: int64 + type: integer + peers: + description: Peers is the list of BGP peers to advertise VIPs + to. + items: + description: BGPPeerConfig defines the configuration for + a BGP peer. + properties: + bfdEnabled: + description: |- + BFDEnabled configures Bi-directional Forwarding Detection. + Valid values are "true" and "false". + type: string + ebgpMultiHop: + description: EBGPMultiHop enables multi-hop eBGP when + the peer is not directly connected. + type: string + holdTime: + description: HoldTime is the BGP hold time for this + peer (e.g., "90s"). + type: string + keepaliveTime: + description: KeepaliveTime is the BGP keepalive interval + for this peer (e.g., "30s"). + type: string + password: + description: |- + Password is the optional TCP MD5 signature password for the BGP + session (RFC 2385), consumed by FRR. + type: string + peerASN: + description: PeerASN is the Autonomous System Number + of the BGP peer. + format: int64 + type: integer + peerAddress: + description: PeerAddress is the IP address of the BGP + peer (e.g., ToR switch). + type: string + port: + description: Port is the TCP port for the BGP session. + Defaults to 179. + format: int32 + type: integer + required: + - peerASN + - peerAddress + type: object + type: array + required: + - localASN + - peers + type: object bmcVerifyCA: type: string bootstrapExternalStaticDNS: @@ -6227,6 +6298,56 @@ spec: description: Host stores all the configuration data for a baremetal host. properties: + bgpPeers: + description: |- + BGPPeers overrides the global bgpVIPConfig.peers for this specific + host. When set, this host will peer with the listed BGP peers instead + of the global peer list. + items: + description: BGPPeerConfig defines the configuration for + a BGP peer. + properties: + bfdEnabled: + description: |- + BFDEnabled configures Bi-directional Forwarding Detection. + Valid values are "true" and "false". + type: string + ebgpMultiHop: + description: EBGPMultiHop enables multi-hop eBGP when + the peer is not directly connected. + type: string + holdTime: + description: HoldTime is the BGP hold time for this + peer (e.g., "90s"). + type: string + keepaliveTime: + description: KeepaliveTime is the BGP keepalive interval + for this peer (e.g., "30s"). + type: string + password: + description: |- + Password is the optional TCP MD5 signature password for the BGP + session (RFC 2385), consumed by FRR. + type: string + peerASN: + description: PeerASN is the Autonomous System Number + of the BGP peer. + format: int64 + type: integer + peerAddress: + description: PeerAddress is the IP address of the + BGP peer (e.g., ToR switch). + type: string + port: + description: Port is the TCP port for the BGP session. + Defaults to 179. + format: int32 + type: integer + required: + - peerASN + - peerAddress + type: object + type: array bmc: description: BMC stores the information about a baremetal host's management controller. diff --git a/pkg/asset/manifests/bgpvipconfig.go b/pkg/asset/manifests/bgpvipconfig.go new file mode 100644 index 00000000000..aefd087f736 --- /dev/null +++ b/pkg/asset/manifests/bgpvipconfig.go @@ -0,0 +1,137 @@ +package manifests + +import ( + "context" + "encoding/json" + "path" + + "github.com/pkg/errors" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/yaml" + + "github.com/openshift/installer/pkg/asset" + "github.com/openshift/installer/pkg/asset/installconfig" + "github.com/openshift/installer/pkg/types/baremetal" +) + +var ( + bgpVIPConfigMapFileName = path.Join(manifestDir, "bgp-vip-config.yaml") +) + +const ( + bgpVIPConfigMapName = "bgp-vip-config" + bgpVIPConfigMapNamespace = "openshift-network-operator" + bgpVIPConfigMapDataKey = "config.json" +) + +// bgpVIPConfigJSON is the JSON structure stored in the ConfigMap. Its field +// names match baremetal-runtimecfg's FRRPeerMapping so the same JSON can be +// written verbatim to /etc/kubernetes/static-pod-resources/frr-k8s/frr-peers.json. +type bgpVIPConfigJSON struct { + LocalASN int64 `json:"localASN"` + DefaultPeers []baremetal.BGPPeerConfig `json:"defaultPeers"` + Communities []string `json:"communities,omitempty"` + APIVIPs []string `json:"apiVIPs"` + IngressVIPs []string `json:"ingressVIPs"` + HostOverrides map[string][]baremetal.BGPPeerConfig `json:"hostOverrides,omitempty"` +} + +// BGPVIPConfigMap generates the bgp-vip-config ConfigMap for CNO. +type BGPVIPConfigMap struct { + ConfigMap *corev1.ConfigMap + File *asset.File +} + +var _ asset.WritableAsset = (*BGPVIPConfigMap)(nil) + +// Name returns a human friendly name for the asset. +func (*BGPVIPConfigMap) Name() string { + return "BGP VIP Config ConfigMap" +} + +// Dependencies returns all of the dependencies directly needed to generate +// the asset. +func (*BGPVIPConfigMap) Dependencies() []asset.Asset { + return []asset.Asset{ + &installconfig.InstallConfig{}, + } +} + +// Generate generates the BGP VIP Config ConfigMap. +func (b *BGPVIPConfigMap) Generate(_ context.Context, dependencies asset.Parents) error { + installConfig := &installconfig.InstallConfig{} + dependencies.Get(installConfig) + + // Only generate for baremetal platform with BGPVIPConfig set. + if installConfig.Config.Platform.Name() != baremetal.Name || + installConfig.Config.Platform.BareMetal == nil || + installConfig.Config.Platform.BareMetal.BGPVIPConfig == nil { + return nil + } + + bm := installConfig.Config.Platform.BareMetal + bgpConfig := bm.BGPVIPConfig + + configData := bgpVIPConfigJSON{ + LocalASN: bgpConfig.LocalASN, + DefaultPeers: bgpConfig.Peers, + Communities: bgpConfig.Communities, + APIVIPs: bm.APIVIPs, + IngressVIPs: bm.IngressVIPs, + } + + // Collect per-host BGP peer overrides. + hostOverrides := make(map[string][]baremetal.BGPPeerConfig) + for _, host := range bm.Hosts { + if host != nil && len(host.BGPPeers) > 0 { + hostOverrides[host.Name] = host.BGPPeers + } + } + if len(hostOverrides) > 0 { + configData.HostOverrides = hostOverrides + } + + jsonBytes, err := json.Marshal(configData) + if err != nil { + return errors.Wrap(err, "failed to marshal BGP VIP config JSON") + } + + cm := &corev1.ConfigMap{ + TypeMeta: metav1.TypeMeta{ + APIVersion: corev1.SchemeGroupVersion.String(), + Kind: "ConfigMap", + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: bgpVIPConfigMapNamespace, + Name: bgpVIPConfigMapName, + }, + Data: map[string]string{ + bgpVIPConfigMapDataKey: string(jsonBytes), + }, + } + + cmData, err := yaml.Marshal(cm) + if err != nil { + return errors.Wrapf(err, "failed to create %s manifest", b.Name()) + } + b.ConfigMap = cm + b.File = &asset.File{ + Filename: bgpVIPConfigMapFileName, + Data: cmData, + } + return nil +} + +// Files returns the files generated by the asset. +func (b *BGPVIPConfigMap) Files() []*asset.File { + if b.File != nil { + return []*asset.File{b.File} + } + return []*asset.File{} +} + +// Load loads the already-rendered files back from disk. +func (b *BGPVIPConfigMap) Load(f asset.FileFetcher) (bool, error) { + return false, nil +} diff --git a/pkg/asset/manifests/bgpvipconfig_test.go b/pkg/asset/manifests/bgpvipconfig_test.go new file mode 100644 index 00000000000..f65dad99bd9 --- /dev/null +++ b/pkg/asset/manifests/bgpvipconfig_test.go @@ -0,0 +1,72 @@ +package manifests + +import ( + "context" + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/openshift/installer/pkg/asset" + "github.com/openshift/installer/pkg/asset/installconfig" + "github.com/openshift/installer/pkg/types" + "github.com/openshift/installer/pkg/types/baremetal" +) + +func bgpInstallConfig() *installconfig.InstallConfig { + return installconfig.MakeAsset(&types.InstallConfig{ + Platform: types.Platform{ + BareMetal: &baremetal.Platform{ + APIVIPs: []string{"192.168.111.5"}, + IngressVIPs: []string{"192.168.111.4"}, + BGPVIPConfig: &baremetal.BGPVIPConfig{ + LocalASN: 64512, + Peers: []baremetal.BGPPeerConfig{ + {PeerAddress: "192.168.111.1", PeerASN: 64513}, + }, + }, + Hosts: []*baremetal.Host{ + { + Name: "master-0", + BGPPeers: []baremetal.BGPPeerConfig{ + {PeerAddress: "192.168.1.1", PeerASN: 64513}, + }, + }, + }, + }, + }, + }) +} + +// The config.json schema must match baremetal-runtimecfg's FRRPeerMapping: +// top-level "defaultPeers" (not "peers") and hostOverrides as a flat +// map[hostname][]peer (not map[hostname]{"peers":[...]}). +func TestBGPVIPConfigMapSchemaMatchesRuntimecfg(t *testing.T) { + parents := asset.Parents{} + parents.Add(bgpInstallConfig()) + + cmAsset := &BGPVIPConfigMap{} + if !assert.NoError(t, cmAsset.Generate(context.Background(), parents)) { + return + } + if !assert.NotNil(t, cmAsset.ConfigMap) { + return + } + + var got map[string]json.RawMessage + if !assert.NoError(t, json.Unmarshal([]byte(cmAsset.ConfigMap.Data["config.json"]), &got)) { + return + } + + assert.Contains(t, got, "defaultPeers") + assert.NotContains(t, got, "peers") + + var overrides map[string][]baremetal.BGPPeerConfig + if !assert.NoError(t, json.Unmarshal(got["hostOverrides"], &overrides)) { + return + } + if !assert.Len(t, overrides["master-0"], 1) { + return + } + assert.Equal(t, "192.168.1.1", overrides["master-0"][0].PeerAddress) +} diff --git a/pkg/asset/manifests/infrastructure.go b/pkg/asset/manifests/infrastructure.go index bc1ac019e31..ed104bcfd62 100644 --- a/pkg/asset/manifests/infrastructure.go +++ b/pkg/asset/manifests/infrastructure.go @@ -192,6 +192,9 @@ func (i *Infrastructure) Generate(ctx context.Context, dependencies asset.Parent config.Spec.PlatformSpec.BareMetal.IngressIPs = types.StringsToIPs(installConfig.Config.Platform.BareMetal.IngressVIPs) config.Spec.PlatformSpec.BareMetal.MachineNetworks = types.MachineNetworksToCIDRs(installConfig.Config.MachineNetwork) config.Status.PlatformStatus.BareMetal.MachineNetworks = types.MachineNetworksToCIDRs(installConfig.Config.MachineNetwork) + if installConfig.Config.Platform.BareMetal.BGPVIPConfig != nil { + config.Status.PlatformStatus.BareMetal.VIPManagement = configv1.VIPManagementTypeBGP + } case gcp.Name: config.Spec.PlatformSpec.Type = configv1.GCPPlatformType config.Status.PlatformStatus.GCP = &configv1.GCPPlatformStatus{ diff --git a/pkg/asset/manifests/network.go b/pkg/asset/manifests/network.go index b79e7bb4896..e81ebe29db3 100644 --- a/pkg/asset/manifests/network.go +++ b/pkg/asset/manifests/network.go @@ -15,6 +15,7 @@ import ( "github.com/openshift/installer/pkg/asset/installconfig" "github.com/openshift/installer/pkg/types" "github.com/openshift/installer/pkg/types/aws" + "github.com/openshift/installer/pkg/types/baremetal" "github.com/openshift/installer/pkg/types/powervs" ) @@ -145,6 +146,16 @@ func clusterNetworkOperatorConfig(ic *installconfig.InstallConfig, cns []configv cnoCfg = ovnNetworkOperatorConfig(cns, sn) cnoCfg.Spec.DefaultNetwork.OVNKubernetesConfig.GatewayConfig = &operatorv1.GatewayConfig{RoutingViaHost: true} } + case baremetal.Name: + // BGP-based VIP management needs frr-k8s CRDs/namespace deployed by + // CNO; enable the FRR routing capability provider. Non-OVN network + // types are rejected by install-config validation. + if ic.Config.Platform.BareMetal.BGPVIPConfig != nil && ic.Config.NetworkType == string(operatorv1.NetworkTypeOVNKubernetes) { + cnoCfg = ovnNetworkOperatorConfig(cns, sn) + cnoCfg.Spec.AdditionalRoutingCapabilities = &operatorv1.AdditionalRoutingCapabilities{ + Providers: []operatorv1.RoutingCapabilitiesProvider{operatorv1.RoutingCapabilitiesProviderFRR}, + } + } } if ovnCfg := ic.Config.OVNKubernetesConfig; ovnCfg != nil && ovnCfg.IPv4 != nil && ovnCfg.IPv4.InternalJoinSubnet != nil { diff --git a/pkg/asset/manifests/network_test.go b/pkg/asset/manifests/network_test.go index 18a9a1ce3dd..41e026f7561 100644 --- a/pkg/asset/manifests/network_test.go +++ b/pkg/asset/manifests/network_test.go @@ -13,6 +13,7 @@ import ( "github.com/openshift/installer/pkg/ipnet" "github.com/openshift/installer/pkg/types" "github.com/openshift/installer/pkg/types/aws" + "github.com/openshift/installer/pkg/types/baremetal" "github.com/openshift/installer/pkg/types/powervs" ) @@ -179,3 +180,43 @@ func TestNetworking_GenerateCustomNetworkConfigMTU(t *testing.T) { }) } } + +func TestCNOConfigEnablesFRRForBGPVIP(t *testing.T) { + ic := validInstallConfigWithMTU(&types.InstallConfig{ + Networking: &types.Networking{NetworkType: "OVNKubernetes"}, + Platform: types.Platform{ + BareMetal: &baremetal.Platform{ + BGPVIPConfig: &baremetal.BGPVIPConfig{ + LocalASN: 64512, + Peers: []baremetal.BGPPeerConfig{{PeerAddress: "192.168.111.1", PeerASN: 64513}}, + }, + }, + }, + }) + cfg, err := clusterNetworkOperatorConfig(ic, nil, nil) + if !assert.NoError(t, err) { + return + } + if !assert.NotNil(t, cfg) { + return + } + if !assert.NotNil(t, cfg.Spec.AdditionalRoutingCapabilities) { + return + } + assert.Contains(t, cfg.Spec.AdditionalRoutingCapabilities.Providers, + operatorv1.RoutingCapabilitiesProviderFRR) +} + +func TestCNOConfigNilForBareMetalWithoutBGPVIP(t *testing.T) { + ic := validInstallConfigWithMTU(&types.InstallConfig{ + Networking: &types.Networking{NetworkType: "OVNKubernetes"}, + Platform: types.Platform{ + BareMetal: &baremetal.Platform{}, + }, + }) + cfg, err := clusterNetworkOperatorConfig(ic, nil, nil) + if !assert.NoError(t, err) { + return + } + assert.Nil(t, cfg) +} diff --git a/pkg/asset/manifests/operators.go b/pkg/asset/manifests/operators.go index d83da09de31..435a57eaabd 100644 --- a/pkg/asset/manifests/operators.go +++ b/pkg/asset/manifests/operators.go @@ -93,6 +93,7 @@ func (m *Manifests) Dependencies() []asset.Asset { &bootkube.InternalReleaseImageRegistryAuthSecret{}, &BMCVerifyCAConfigMap{}, &PKIConfiguration{}, + &BGPVIPConfigMap{}, } } @@ -111,8 +112,9 @@ func (m *Manifests) Generate(_ context.Context, dependencies asset.Parents) erro mcoCfgTemplate := &manifests.MCO{} bmcVerifyCAConfigMap := &BMCVerifyCAConfigMap{} pkiConfig := &PKIConfiguration{} + bgpVIPConfigMap := &BGPVIPConfigMap{} - dependencies.Get(installConfig, ingress, dns, network, infra, proxy, scheduler, imageContentSourcePolicy, imageDigestMirrorSet, clusterCSIDriverConfig, mcoCfgTemplate, bmcVerifyCAConfigMap, pkiConfig) + dependencies.Get(installConfig, ingress, dns, network, infra, proxy, scheduler, imageContentSourcePolicy, imageDigestMirrorSet, clusterCSIDriverConfig, mcoCfgTemplate, bmcVerifyCAConfigMap, pkiConfig, bgpVIPConfigMap) redactedConfig, err := redactedInstallConfig(*installConfig.Config) if err != nil { @@ -152,6 +154,7 @@ func (m *Manifests) Generate(_ context.Context, dependencies asset.Parents) erro m.FileList = append(m.FileList, imageDigestMirrorSet.Files()...) m.FileList = append(m.FileList, bmcVerifyCAConfigMap.Files()...) m.FileList = append(m.FileList, pkiConfig.Files()...) + m.FileList = append(m.FileList, bgpVIPConfigMap.Files()...) asset.SortFiles(m.FileList) diff --git a/pkg/types/baremetal/platform.go b/pkg/types/baremetal/platform.go index 01ccce900b7..f06657fbef6 100644 --- a/pkg/types/baremetal/platform.go +++ b/pkg/types/baremetal/platform.go @@ -34,6 +34,48 @@ const ( workerRole string = "worker" ) +// BGPPeerConfig defines the configuration for a BGP peer. +type BGPPeerConfig struct { + // PeerAddress is the IP address of the BGP peer (e.g., ToR switch). + PeerAddress string `json:"peerAddress"` + + // PeerASN is the Autonomous System Number of the BGP peer. + PeerASN int64 `json:"peerASN"` + + // Password is the optional TCP MD5 signature password for the BGP + // session (RFC 2385), consumed by FRR. + Password string `json:"password,omitempty"` //nolint:gosec // BGP protocol session password field, not a hardcoded credential + + // Port is the TCP port for the BGP session. Defaults to 179. + Port int32 `json:"port,omitempty"` + + // BFDEnabled configures Bi-directional Forwarding Detection. + // Valid values are "true" and "false". + BFDEnabled string `json:"bfdEnabled,omitempty"` + + // EBGPMultiHop enables multi-hop eBGP when the peer is not directly connected. + EBGPMultiHop string `json:"ebgpMultiHop,omitempty"` + + // HoldTime is the BGP hold time for this peer (e.g., "90s"). + HoldTime string `json:"holdTime,omitempty"` + + // KeepaliveTime is the BGP keepalive interval for this peer (e.g., "30s"). + KeepaliveTime string `json:"keepaliveTime,omitempty"` +} + +// BGPVIPConfig configures BGP-based VIP advertisement for API and Ingress VIPs. +type BGPVIPConfig struct { + // LocalASN is the Autonomous System Number for this cluster's BGP speaker. + LocalASN int64 `json:"localASN"` + + // Peers is the list of BGP peers to advertise VIPs to. + Peers []BGPPeerConfig `json:"peers"` + + // Communities is an optional list of BGP communities to attach to + // advertised VIP routes, in the format "ASN:value" (e.g., "64512:100"). + Communities []string `json:"communities,omitempty"` +} + // Host stores all the configuration data for a baremetal host. type Host struct { Name string `json:"name,omitempty" validate:"required,uniqueField"` @@ -44,6 +86,11 @@ type Host struct { RootDeviceHints *RootDeviceHints `json:"rootDeviceHints,omitempty"` BootMode BootMode `json:"bootMode,omitempty"` NetworkConfig *apiextv1.JSON `json:"networkConfig,omitempty"` + + // BGPPeers overrides the global bgpVIPConfig.peers for this specific + // host. When set, this host will peer with the listed BGP peers instead + // of the global peer list. + BGPPeers []BGPPeerConfig `json:"bgpPeers,omitempty"` } // IsMaster checks if the current host is a master @@ -267,6 +314,13 @@ type Platform struct { // +optional DNSRecordsType configv1.DNSRecordsType `json:"dnsRecordsType,omitempty"` + // BGPVIPConfig configures BGP-based advertisement of the API and + // Ingress VIPs. When set, kube-vip (Routing Table Mode) and frr-k8s + // are deployed as static pods on control plane nodes to advertise + // VIPs via BGP, replacing the default keepalived/VRRP mechanism. + // +optional + BGPVIPConfig *BGPVIPConfig `json:"bgpVIPConfig,omitempty"` + // BootstrapExternalStaticDNS is the static network DNS of the bootstrap node. // This can be useful in environments without a DHCP server. // +kubebuilder:validation:Format=ip diff --git a/pkg/types/baremetal/validation/featuregates.go b/pkg/types/baremetal/validation/featuregates.go index a7047c5617d..6025b5f6fa1 100644 --- a/pkg/types/baremetal/validation/featuregates.go +++ b/pkg/types/baremetal/validation/featuregates.go @@ -18,5 +18,10 @@ func GatedFeatures(c *types.InstallConfig) []featuregates.GatedInstallConfigFeat Condition: c.BareMetal.DNSRecordsType == configv1.DNSRecordsTypeExternal, Field: field.NewPath("platform", "baremetal", "dnsRecordsType"), }, + { + FeatureGateName: features.FeatureGateBGPBasedVIPManagement, + Condition: c.BareMetal.BGPVIPConfig != nil, + Field: field.NewPath("platform", "baremetal", "bgpVIPConfig"), + }, } } diff --git a/pkg/types/baremetal/validation/platform.go b/pkg/types/baremetal/validation/platform.go index 71941fe9c6c..b34bb802878 100644 --- a/pkg/types/baremetal/validation/platform.go +++ b/pkg/types/baremetal/validation/platform.go @@ -6,8 +6,10 @@ import ( "net" "net/http" "net/url" + "regexp" "strconv" "strings" + "time" "github.com/apparentlymart/go-cidr/cidr" "github.com/go-playground/validator/v10" @@ -20,6 +22,7 @@ import ( "sigs.k8s.io/yaml" configv1 "github.com/openshift/api/config/v1" + operv1 "github.com/openshift/api/operator/v1" "github.com/openshift/installer/pkg/ipnet" "github.com/openshift/installer/pkg/types" "github.com/openshift/installer/pkg/types/baremetal" @@ -523,6 +526,34 @@ func ValidatePlatform(p *baremetal.Platform, agentBasedInstallation bool, n *typ allErrs = append(allErrs, field.Invalid(fldPath.Child("dnsRecordsType"), c.BareMetal.DNSRecordsType, "external DNS records can only be configured with user-managed loadbalancers")) } + if p.BGPVIPConfig != nil { + allErrs = append(allErrs, validateBGPVIPConfig(p.BGPVIPConfig, fldPath.Child("bgpVIPConfig"))...) + // The BGP VIP rendering path configures the cluster network + // operator's OVN-Kubernetes configuration. + if n != nil && n.NetworkType != string(operv1.NetworkTypeOVNKubernetes) { + allErrs = append(allErrs, field.Invalid(fldPath.Child("bgpVIPConfig"), n.NetworkType, + "BGP-based VIP management requires the OVNKubernetes network type")) + } + } + + for i, host := range p.Hosts { + if host == nil { + continue + } + hostsPath := fldPath.Child("hosts").Index(i).Child("bgpPeers") + if len(host.BGPPeers) > 0 && p.BGPVIPConfig == nil { + allErrs = append(allErrs, field.Forbidden(hostsPath, + "host-level BGP peer overrides require platform.baremetal.bgpVIPConfig")) + continue + } + if len(host.BGPPeers) > 16 { + allErrs = append(allErrs, field.TooMany(hostsPath, len(host.BGPPeers), 16)) + } + for j, peer := range host.BGPPeers { + allErrs = append(allErrs, validateBGPPeer(peer, hostsPath.Index(j))...) + } + } + return allErrs } @@ -553,6 +584,87 @@ func validateLoadBalancer(lbType configv1.PlatformLoadBalancerType) bool { } } +func validateBGPVIPConfig(bgpConfig *baremetal.BGPVIPConfig, fldPath *field.Path) field.ErrorList { + allErrs := field.ErrorList{} + if bgpConfig == nil { + return allErrs + } + + // Validate LocalASN + if bgpConfig.LocalASN < 1 || bgpConfig.LocalASN > 4294967295 { + allErrs = append(allErrs, field.Invalid(fldPath.Child("localASN"), bgpConfig.LocalASN, + "must be between 1 and 4294967295")) + } + + // Validate Peers + if len(bgpConfig.Peers) == 0 { + allErrs = append(allErrs, field.Required(fldPath.Child("peers"), "at least one BGP peer is required")) + } + if len(bgpConfig.Peers) > 16 { + allErrs = append(allErrs, field.TooMany(fldPath.Child("peers"), len(bgpConfig.Peers), 16)) + } + + for i, peer := range bgpConfig.Peers { + peerPath := fldPath.Child("peers").Index(i) + allErrs = append(allErrs, validateBGPPeer(peer, peerPath)...) + } + + for i, community := range bgpConfig.Communities { + if !bgpCommunityRe.MatchString(community) { + allErrs = append(allErrs, field.Invalid(fldPath.Child("communities").Index(i), community, + "must be a standard (AA:NN) or large (AA:BB:CC) BGP community")) + } + } + + return allErrs +} + +// bgpCommunityRe matches standard (AA:NN) and large (AA:BB:CC) BGP community +// values. +var bgpCommunityRe = regexp.MustCompile(`^\d+:\d+(:\d+)?$`) + +func validateBGPPeer(peer baremetal.BGPPeerConfig, fldPath *field.Path) field.ErrorList { + allErrs := field.ErrorList{} + + if peer.PeerAddress == "" { + allErrs = append(allErrs, field.Required(fldPath.Child("peerAddress"), "peer address is required")) + } else if ip := net.ParseIP(peer.PeerAddress); ip == nil { + allErrs = append(allErrs, field.Invalid(fldPath.Child("peerAddress"), peer.PeerAddress, "must be a valid IP address")) + } + + if peer.PeerASN < 1 || peer.PeerASN > 4294967295 { + allErrs = append(allErrs, field.Invalid(fldPath.Child("peerASN"), peer.PeerASN, + "must be between 1 and 4294967295")) + } + + if peer.BFDEnabled != "" && peer.BFDEnabled != "true" && peer.BFDEnabled != "false" { + allErrs = append(allErrs, field.Invalid(fldPath.Child("bfdEnabled"), peer.BFDEnabled, + `must be "true", "false", or empty`)) + } + + if peer.EBGPMultiHop != "" && peer.EBGPMultiHop != "true" && peer.EBGPMultiHop != "false" { + allErrs = append(allErrs, field.Invalid(fldPath.Child("ebgpMultiHop"), peer.EBGPMultiHop, + `must be "true", "false", or empty`)) + } + + if peer.Port != 0 && (peer.Port < 1 || peer.Port > 65535) { + allErrs = append(allErrs, field.Invalid(fldPath.Child("port"), peer.Port, + "must be between 1 and 65535, or omitted for the default 179")) + } + + for name, value := range map[string]string{"holdTime": peer.HoldTime, "keepaliveTime": peer.KeepaliveTime} { + if value == "" { + continue + } + if _, err := time.ParseDuration(value); err != nil { + allErrs = append(allErrs, field.Invalid(fldPath.Child(name), value, + "must be a valid duration (e.g. 90s)")) + } + } + + return allErrs +} + // ValidateProvisioning checks that provisioning network requirements specified is valid. func ValidateProvisioning(p *baremetal.Platform, n *types.Networking, fldPath *field.Path) field.ErrorList { allErrs := field.ErrorList{} diff --git a/pkg/types/baremetal/validation/platform_test.go b/pkg/types/baremetal/validation/platform_test.go index 4de4ac234ad..6c31b024d34 100644 --- a/pkg/types/baremetal/validation/platform_test.go +++ b/pkg/types/baremetal/validation/platform_test.go @@ -1166,3 +1166,116 @@ func (nb *networkingBuilder) Network(cidr string) *networkingBuilder { func (nb *networkingBuilder) build() *types.Networking { return &nb.Networking } + +func TestValidateBGPVIP(t *testing.T) { + ovnNetwork := func() *types.Networking { + n := network() + n.NetworkType = "OVNKubernetes" + return n + } + bgpPlatform := func(mutate func(*baremetal.Platform)) *baremetal.Platform { + p := platform().build() + p.BGPVIPConfig = &baremetal.BGPVIPConfig{ + LocalASN: 64512, + Peers: []baremetal.BGPPeerConfig{{PeerAddress: "192.168.111.1", PeerASN: 64513}}, + } + if mutate != nil { + mutate(p) + } + return p + } + + cases := []struct { + name string + platform *baremetal.Platform + network *types.Networking + expected string + }{ + { + name: "valid bgp config", + platform: bgpPlatform(nil), + network: ovnNetwork(), + }, + { + name: "invalid peer port", + platform: bgpPlatform(func(p *baremetal.Platform) { + p.BGPVIPConfig.Peers[0].Port = 65536 + }), + network: ovnNetwork(), + expected: `must be between 1 and 65535`, + }, + { + name: "negative peer port", + platform: bgpPlatform(func(p *baremetal.Platform) { + p.BGPVIPConfig.Peers[0].Port = -1 + }), + network: ovnNetwork(), + expected: `must be between 1 and 65535`, + }, + { + name: "bgp requires OVNKubernetes", + platform: bgpPlatform(nil), + network: network(), + expected: `BGP-based VIP management requires the OVNKubernetes network type`, + }, + { + name: "invalid holdTime", + platform: bgpPlatform(func(p *baremetal.Platform) { + p.BGPVIPConfig.Peers[0].HoldTime = "ninety" + }), + network: ovnNetwork(), + expected: `must be a valid duration`, + }, + { + name: "invalid community", + platform: bgpPlatform(func(p *baremetal.Platform) { + p.BGPVIPConfig.Communities = []string{"no-export"} + }), + network: ovnNetwork(), + expected: `must be a standard (AA:NN) or large (AA:BB:CC) BGP community`, + }, + { + name: "too many host override peers", + platform: bgpPlatform(func(p *baremetal.Platform) { + peers := make([]baremetal.BGPPeerConfig, 17) + for i := range peers { + peers[i] = baremetal.BGPPeerConfig{PeerAddress: "192.168.111.2", PeerASN: 64513} + } + p.Hosts = append(p.Hosts, &baremetal.Host{ + Name: "host-0", + BootMACAddress: "CA:FE:CA:FE:00:00", + BMC: baremetal.BMC{Address: "ipmi://192.168.111.10", Username: "u", Password: "p"}, + BGPPeers: peers, + }) + }), + network: ovnNetwork(), + expected: `Too many`, + }, + { + name: "host bgpPeers without bgpVIPConfig", + platform: bgpPlatform(func(p *baremetal.Platform) { + p.BGPVIPConfig = nil + p.Hosts = append(p.Hosts, &baremetal.Host{ + Name: "host-0", + BootMACAddress: "CA:FE:CA:FE:00:00", + BMC: baremetal.BMC{Address: "ipmi://192.168.111.10", Username: "u", Password: "p"}, + BGPPeers: []baremetal.BGPPeerConfig{{PeerAddress: "192.168.111.2", PeerASN: 64513}}, + }) + }), + network: ovnNetwork(), + expected: `host-level BGP peer overrides require platform.baremetal.bgpVIPConfig`, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cfg := installConfig().build() + cfg.BareMetal = tc.platform + err := ValidatePlatform(tc.platform, false, tc.network, field.NewPath("baremetal"), cfg).ToAggregate() + if tc.expected == "" { + assert.NoError(t, err) + } else { + assert.ErrorContains(t, err, tc.expected) + } + }) + } +} diff --git a/pkg/types/baremetal/zz_generated.deepcopy.go b/pkg/types/baremetal/zz_generated.deepcopy.go index 742d51cc4d0..e9f15040c33 100644 --- a/pkg/types/baremetal/zz_generated.deepcopy.go +++ b/pkg/types/baremetal/zz_generated.deepcopy.go @@ -10,6 +10,48 @@ import ( v1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" ) +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BGPPeerConfig) DeepCopyInto(out *BGPPeerConfig) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BGPPeerConfig. +func (in *BGPPeerConfig) DeepCopy() *BGPPeerConfig { + if in == nil { + return nil + } + out := new(BGPPeerConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BGPVIPConfig) DeepCopyInto(out *BGPVIPConfig) { + *out = *in + if in.Peers != nil { + in, out := &in.Peers, &out.Peers + *out = make([]BGPPeerConfig, len(*in)) + copy(*out, *in) + } + if in.Communities != nil { + in, out := &in.Communities, &out.Communities + *out = make([]string, len(*in)) + copy(*out, *in) + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BGPVIPConfig. +func (in *BGPVIPConfig) DeepCopy() *BGPVIPConfig { + if in == nil { + return nil + } + out := new(BGPVIPConfig) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *BMC) DeepCopyInto(out *BMC) { *out = *in @@ -40,6 +82,11 @@ func (in *Host) DeepCopyInto(out *Host) { *out = new(v1.JSON) (*in).DeepCopyInto(*out) } + if in.BGPPeers != nil { + in, out := &in.BGPPeers, &out.BGPPeers + *out = make([]BGPPeerConfig, len(*in)) + copy(*out, *in) + } return } @@ -123,6 +170,11 @@ func (in *Platform) DeepCopyInto(out *Platform) { *out = new(configv1.BareMetalPlatformLoadBalancer) **out = **in } + if in.BGPVIPConfig != nil { + in, out := &in.BGPVIPConfig, &out.BGPVIPConfig + *out = new(BGPVIPConfig) + (*in).DeepCopyInto(*out) + } if in.AdditionalNTPServers != nil { in, out := &in.AdditionalNTPServers, &out.AdditionalNTPServers *out = make([]string, len(*in))