From fea40a33e671c9f3fea17c9c636a28f056ad9dbf Mon Sep 17 00:00:00 2001 From: Oliver Frommel Date: Thu, 17 Sep 2026 14:33:41 +0200 Subject: [PATCH] Support unnumbered interface-based BGP peering Allow a BGPPeer to name an Interface instead of a peer address, so an eBGP session can run over the peers' IPv6 link-local addresses and the transit link needs no addressing of its own. The referenced Interface must set spec.ipv6.useLinkLocalOnly. spec.address becomes optional and is mutually exclusive with the new spec.interfaceRef; spec.localAddress is meaningless for such a peer. Both rules are enforced by CEL and by the webhook. A peer's identity, its address, interfaceRef or bgpRef, is immutable, as the finalizer only knows the current identity and would leave the previous peer behind on the device. spec.asNumber accepts the sentinel "external" for dynamic AS discovery, which only applies to interface-based peers. On NX-OS the peer maps to a PeerIf object under peerif-items, keyed by the interface name. The device reports an empty asn with asnType external, which the omitempty payload matches, so reconciliation stays idempotent. The device-level interface name is recorded in status.peerInterface, so the finalizer can still remove the peer after its Interface was deleted. The openconfig and iosxr providers reject interfaceRef as unsupported and skip the deletion of such peers as they were never configured on the device. The BGPPeer controller now watches Interfaces through a field index covering both interface references, so a peer converges as soon as its Interface appears instead of waiting for the periodic requeue. The NX-OS interface address items are replaced instead of merged, so addresses removed from the spec, such as global addresses when switching to link-local only, are removed from the device. As a gNMI Set applies replace before update operations, they are sent in a separate Set after the interface itself has been created. Signed-off-by: Oliver Frommel --- Tiltfile | 1 + api/core/v1alpha1/bgp_peer_types.go | 42 +++- api/core/v1alpha1/zz_generated.deepcopy.go | 5 + ...gppeers.networking.metal.ironcore.dev.yaml | 56 ++++- ...etworking.metal.ironcore.dev_bgppeers.yaml | 56 ++++- config/samples/v1alpha1_bgppeer.yaml | 22 ++ docs/api-reference/index.md | 8 +- hack/provider/main.go | 36 ++- .../controller/core/bgp_peer_controller.go | 149 ++++++++++-- .../core/bgp_peer_controller_test.go | 159 +++++++++++++ internal/controller/core/suite_test.go | 5 +- internal/provider/cisco/iosxr/provider.go | 13 ++ .../provider/cisco/iosxr/provider_test.go | 17 ++ internal/provider/cisco/nxos/bgp.go | 68 +++++- internal/provider/cisco/nxos/bgp_test.go | 26 +++ internal/provider/cisco/nxos/provider.go | 220 ++++++++++++------ .../cisco/nxos/testdata/bgp_peer_if.json | 33 +++ .../cisco/nxos/testdata/bgp_peer_if.json.txt | 5 + .../cisco/nxos/testdata/bgp_peer_if_asn.json | 23 ++ .../nxos/testdata/bgp_peer_if_asn.json.txt | 3 + internal/provider/openconfig/bgp_test.go | 21 ++ internal/provider/openconfig/bgppeer.go | 12 + internal/provider/provider.go | 9 + .../webhook/core/v1alpha1/bgppeer_webhook.go | 18 +- .../core/v1alpha1/bgppeer_webhook_test.go | 55 +++++ .../bgp_peer_unnumbered.txtar | 206 ++++++++++++++++ 26 files changed, 1154 insertions(+), 114 deletions(-) create mode 100644 internal/provider/cisco/nxos/testdata/bgp_peer_if.json create mode 100644 internal/provider/cisco/nxos/testdata/bgp_peer_if.json.txt create mode 100644 internal/provider/cisco/nxos/testdata/bgp_peer_if_asn.json create mode 100644 internal/provider/cisco/nxos/testdata/bgp_peer_if_asn.json.txt create mode 100644 test/gnmi/testdata/nx.cisco.networking.metal.ironcore.dev/bgp_peer_unnumbered.txtar diff --git a/Tiltfile b/Tiltfile index f3662dbc2..7ded83347 100644 --- a/Tiltfile +++ b/Tiltfile @@ -136,6 +136,7 @@ k8s_yaml('./config/samples/v1alpha1_bgppeer.yaml') k8s_resource(new_name='peer-spine1', objects=['leaf1-spine1:bgppeer'], resource_deps=['bgp', 'lo0'], trigger_mode=TRIGGER_MODE_MANUAL, auto_init=False, labels=['samples']) k8s_resource(new_name='peer-spine2', objects=['leaf1-spine2:bgppeer'], resource_deps=['bgp', 'lo0'], trigger_mode=TRIGGER_MODE_MANUAL, auto_init=False, labels=['samples']) k8s_resource(new_name='peer-spine1-filtered', objects=['leaf1-spine1-filtered:bgppeer'], resource_deps=['bgp', 'lo0', 'bgp-import-policy'], trigger_mode=TRIGGER_MODE_MANUAL, auto_init=False, labels=['samples']) +k8s_resource(new_name='peer-spine1-unnumbered', objects=['leaf1-spine1-unnumbered:bgppeer'], resource_deps=['bgp', 'eth1-4'], trigger_mode=TRIGGER_MODE_MANUAL, auto_init=False, labels=['samples']) k8s_yaml('./config/samples/v1alpha1_ospf.yaml') k8s_resource(new_name='ospf-underlay', objects=['underlay:ospf'], resource_deps=['lo0', 'lo1', 'eth1-1', 'eth1-2'], trigger_mode=TRIGGER_MODE_MANUAL, auto_init=False, labels=['samples']) diff --git a/api/core/v1alpha1/bgp_peer_types.go b/api/core/v1alpha1/bgp_peer_types.go index ba54aa75e..e2498368a 100644 --- a/api/core/v1alpha1/bgp_peer_types.go +++ b/api/core/v1alpha1/bgp_peer_types.go @@ -13,6 +13,11 @@ import ( ) // BGPPeerSpec defines the desired state of BGPPeer +// +kubebuilder:validation:XValidation:rule="has(self.address) != has(self.interfaceRef)", message="exactly one of address or interfaceRef must be specified" +// +kubebuilder:validation:XValidation:rule="!has(self.interfaceRef) || !has(self.localAddress)", message="localAddress must not be specified for interface-based peers" +// +kubebuilder:validation:XValidation:rule="type(self.asNumber) != string || self.asNumber != 'external' || has(self.interfaceRef)", message="asNumber external requires interfaceRef" +// +kubebuilder:validation:XValidation:rule="(!has(self.address) && !has(oldSelf.address)) || (has(self.address) && has(oldSelf.address) && self.address == oldSelf.address)",message="Address is immutable" +// +kubebuilder:validation:XValidation:rule="(!has(self.interfaceRef) && !has(oldSelf.interfaceRef)) || (has(self.interfaceRef) && has(oldSelf.interfaceRef) && self.interfaceRef == oldSelf.interfaceRef)",message="InterfaceRef is immutable" type BGPPeerSpec struct { // DeviceName is the name of the Device this object belongs to. The Device object must exist in the same namespace. // Immutable. @@ -27,7 +32,9 @@ type BGPPeerSpec struct { // BgpRef is a reference to the BGP instance this peer belongs to. // The BGP object must exist in the same namespace. + // Immutable. // +required + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="BgpRef is immutable" BgpRef LocalObjectReference `json:"bgpRef"` // AdminState indicates whether this BGP peer is administratively up or down. @@ -37,12 +44,26 @@ type BGPPeerSpec struct { AdminState AdminState `json:"adminState,omitempty"` // Address is the IPv4 address of the BGP peer. - // +required + // Mutually exclusive with InterfaceRef: exactly one of both must be specified. + // Immutable. + // +optional // +kubebuilder:validation:Format=ipv4 - Address string `json:"address"` + Address string `json:"address,omitempty"` + + // InterfaceRef is a reference to an Interface resource over which an unnumbered + // (interface-based) BGP session is established. The peers discover each other over + // their IPv6 link-local addresses, so the link needs no addressing of its own. + // The referenced Interface must belong to the same Device, exist in the same namespace, + // and be configured for link-local operation (spec.ipv6.useLinkLocalOnly). + // Mutually exclusive with Address: exactly one of both must be specified. + // Immutable. + // +optional + InterfaceRef *LocalObjectReference `json:"interfaceRef,omitempty"` // ASNumber is the autonomous system number (ASN) of the BGP peer. // Supports both plain format (1-4294967295) and dotted notation (0-65535.0-65535) as per RFC 5396. + // The special value "external" configures a dynamic AS number, accepting any AS number + // that differs from the local one. It is only valid together with InterfaceRef. // +required ASNumber intstr.IntOrString `json:"asNumber"` @@ -66,6 +87,16 @@ type BGPPeerSpec struct { LocalAS *LocalAS `json:"localAS,omitempty"` } +// BGPPeerASNumberExternal is the value of BGPPeerSpec.ASNumber that requests a dynamic +// AS number for the peer. The session is established with any AS number that differs from +// the local one, which is the common setup for unnumbered eBGP peerings. +const BGPPeerASNumberExternal = "external" + +// IsExternalASNumber reports whether the peer is configured with a dynamic AS number. +func (s *BGPPeerSpec) IsExternalASNumber() bool { + return s.ASNumber.Type == intstr.String && s.ASNumber.StrVal == BGPPeerASNumberExternal +} + // LocalAS defines the local AS configuration and how it factors in BGP announcements. type LocalAS struct { // ASNumber specifies a local AS number to present in BGP sessions with this peer. @@ -178,6 +209,12 @@ type BGPPeerStatus struct { // +patchMergeKey=afiSafi AddressFamilies []AddressFamilyStatus `json:"addressFamilies,omitempty"` + // PeerInterface is the device-level name of the interface an unnumbered peer is + // configured over. It is recorded so that the peer can still be removed from the + // device after the referenced Interface has been deleted. + // +optional + PeerInterface string `json:"peerInterface,omitempty"` + // ObservedGeneration reflects the .metadata.generation that was last processed by the controller. // +optional ObservedGeneration int64 `json:"observedGeneration,omitempty"` @@ -254,6 +291,7 @@ const ( // +kubebuilder:resource:singular=bgppeer // +kubebuilder:resource:shortName=peer;bgpneighbor // +kubebuilder:printcolumn:name="Peer Address",type=string,JSONPath=`.spec.address` +// +kubebuilder:printcolumn:name="Peer Interface",type=string,JSONPath=`.spec.interfaceRef.name` // +kubebuilder:printcolumn:name="Device",type=string,JSONPath=`.spec.deviceRef.name` // +kubebuilder:printcolumn:name="Admin State",type=string,JSONPath=`.spec.adminState` // +kubebuilder:printcolumn:name="AS Number",type=string,JSONPath=`.spec.asNumber` diff --git a/api/core/v1alpha1/zz_generated.deepcopy.go b/api/core/v1alpha1/zz_generated.deepcopy.go index 7ceabafe5..446abc4af 100644 --- a/api/core/v1alpha1/zz_generated.deepcopy.go +++ b/api/core/v1alpha1/zz_generated.deepcopy.go @@ -833,6 +833,11 @@ func (in *BGPPeerSpec) DeepCopyInto(out *BGPPeerSpec) { **out = **in } out.BgpRef = in.BgpRef + if in.InterfaceRef != nil { + in, out := &in.InterfaceRef, &out.InterfaceRef + *out = new(LocalObjectReference) + **out = **in + } out.ASNumber = in.ASNumber if in.LocalAddress != nil { in, out := &in.LocalAddress, &out.LocalAddress diff --git a/charts/network-operator/templates/crd/bgppeers.networking.metal.ironcore.dev.yaml b/charts/network-operator/templates/crd/bgppeers.networking.metal.ironcore.dev.yaml index 6515b97bd..cbfd5d5ec 100644 --- a/charts/network-operator/templates/crd/bgppeers.networking.metal.ironcore.dev.yaml +++ b/charts/network-operator/templates/crd/bgppeers.networking.metal.ironcore.dev.yaml @@ -24,6 +24,9 @@ spec: - jsonPath: .spec.address name: Peer Address type: string + - jsonPath: .spec.interfaceRef.name + name: Peer Interface + type: string - jsonPath: .spec.deviceRef.name name: Device type: string @@ -91,7 +94,10 @@ spec: More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status properties: address: - description: Address is the IPv4 address of the BGP peer. + description: |- + Address is the IPv4 address of the BGP peer. + Mutually exclusive with InterfaceRef: exactly one of both must be specified. + Immutable. format: ipv4 type: string addressFamilies: @@ -290,11 +296,14 @@ spec: description: |- ASNumber is the autonomous system number (ASN) of the BGP peer. Supports both plain format (1-4294967295) and dotted notation (0-65535.0-65535) as per RFC 5396. + The special value "external" configures a dynamic AS number, accepting any AS number + that differs from the local one. It is only valid together with InterfaceRef. x-kubernetes-int-or-string: true bgpRef: description: |- BgpRef is a reference to the BGP instance this peer belongs to. The BGP object must exist in the same namespace. + Immutable. properties: name: description: |- @@ -307,6 +316,9 @@ spec: - name type: object x-kubernetes-map-type: atomic + x-kubernetes-validations: + - message: BgpRef is immutable + rule: self == oldSelf description: description: |- Description is an optional human-readable description for this BGP peer. @@ -331,6 +343,27 @@ spec: x-kubernetes-validations: - message: DeviceRef is immutable rule: self == oldSelf + interfaceRef: + description: |- + InterfaceRef is a reference to an Interface resource over which an unnumbered + (interface-based) BGP session is established. The peers discover each other over + their IPv6 link-local addresses, so the link needs no addressing of its own. + The referenced Interface must belong to the same Device, exist in the same namespace, + and be configured for link-local operation (spec.ipv6.useLinkLocalOnly). + Mutually exclusive with Address: exactly one of both must be specified. + Immutable. + properties: + name: + description: |- + Name of the referent. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + maxLength: 63 + minLength: 1 + type: string + required: + - name + type: object + x-kubernetes-map-type: atomic localAS: description: LocalAS configures the local AS number and how it factors into BGP announcements for this peer. @@ -415,11 +448,24 @@ spec: type: object x-kubernetes-map-type: atomic required: - - address - asNumber - bgpRef - deviceRef type: object + x-kubernetes-validations: + - message: exactly one of address or interfaceRef must be specified + rule: has(self.address) != has(self.interfaceRef) + - message: localAddress must not be specified for interface-based peers + rule: '!has(self.interfaceRef) || !has(self.localAddress)' + - message: asNumber external requires interfaceRef + rule: type(self.asNumber) != string || self.asNumber != 'external' || + has(self.interfaceRef) + - message: Address is immutable + rule: (!has(self.address) && !has(oldSelf.address)) || (has(self.address) + && has(oldSelf.address) && self.address == oldSelf.address) + - message: InterfaceRef is immutable + rule: (!has(self.interfaceRef) && !has(oldSelf.interfaceRef)) || (has(self.interfaceRef) + && has(oldSelf.interfaceRef) && self.interfaceRef == oldSelf.interfaceRef) status: description: |- Status of the resource. This is set and updated automatically. @@ -541,6 +587,12 @@ spec: that was last processed by the controller. format: int64 type: integer + peerInterface: + description: |- + PeerInterface is the device-level name of the interface an unnumbered peer is + configured over. It is recorded so that the peer can still be removed from the + device after the referenced Interface has been deleted. + type: string sessionState: description: SessionState is the current operational state of the BGP session. diff --git a/config/crd/bases/networking.metal.ironcore.dev_bgppeers.yaml b/config/crd/bases/networking.metal.ironcore.dev_bgppeers.yaml index d5d54d272..3f0d24658 100644 --- a/config/crd/bases/networking.metal.ironcore.dev_bgppeers.yaml +++ b/config/crd/bases/networking.metal.ironcore.dev_bgppeers.yaml @@ -21,6 +21,9 @@ spec: - jsonPath: .spec.address name: Peer Address type: string + - jsonPath: .spec.interfaceRef.name + name: Peer Interface + type: string - jsonPath: .spec.deviceRef.name name: Device type: string @@ -88,7 +91,10 @@ spec: More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status properties: address: - description: Address is the IPv4 address of the BGP peer. + description: |- + Address is the IPv4 address of the BGP peer. + Mutually exclusive with InterfaceRef: exactly one of both must be specified. + Immutable. format: ipv4 type: string addressFamilies: @@ -287,11 +293,14 @@ spec: description: |- ASNumber is the autonomous system number (ASN) of the BGP peer. Supports both plain format (1-4294967295) and dotted notation (0-65535.0-65535) as per RFC 5396. + The special value "external" configures a dynamic AS number, accepting any AS number + that differs from the local one. It is only valid together with InterfaceRef. x-kubernetes-int-or-string: true bgpRef: description: |- BgpRef is a reference to the BGP instance this peer belongs to. The BGP object must exist in the same namespace. + Immutable. properties: name: description: |- @@ -304,6 +313,9 @@ spec: - name type: object x-kubernetes-map-type: atomic + x-kubernetes-validations: + - message: BgpRef is immutable + rule: self == oldSelf description: description: |- Description is an optional human-readable description for this BGP peer. @@ -328,6 +340,27 @@ spec: x-kubernetes-validations: - message: DeviceRef is immutable rule: self == oldSelf + interfaceRef: + description: |- + InterfaceRef is a reference to an Interface resource over which an unnumbered + (interface-based) BGP session is established. The peers discover each other over + their IPv6 link-local addresses, so the link needs no addressing of its own. + The referenced Interface must belong to the same Device, exist in the same namespace, + and be configured for link-local operation (spec.ipv6.useLinkLocalOnly). + Mutually exclusive with Address: exactly one of both must be specified. + Immutable. + properties: + name: + description: |- + Name of the referent. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + maxLength: 63 + minLength: 1 + type: string + required: + - name + type: object + x-kubernetes-map-type: atomic localAS: description: LocalAS configures the local AS number and how it factors into BGP announcements for this peer. @@ -412,11 +445,24 @@ spec: type: object x-kubernetes-map-type: atomic required: - - address - asNumber - bgpRef - deviceRef type: object + x-kubernetes-validations: + - message: exactly one of address or interfaceRef must be specified + rule: has(self.address) != has(self.interfaceRef) + - message: localAddress must not be specified for interface-based peers + rule: '!has(self.interfaceRef) || !has(self.localAddress)' + - message: asNumber external requires interfaceRef + rule: type(self.asNumber) != string || self.asNumber != 'external' || + has(self.interfaceRef) + - message: Address is immutable + rule: (!has(self.address) && !has(oldSelf.address)) || (has(self.address) + && has(oldSelf.address) && self.address == oldSelf.address) + - message: InterfaceRef is immutable + rule: (!has(self.interfaceRef) && !has(oldSelf.interfaceRef)) || (has(self.interfaceRef) + && has(oldSelf.interfaceRef) && self.interfaceRef == oldSelf.interfaceRef) status: description: |- Status of the resource. This is set and updated automatically. @@ -538,6 +584,12 @@ spec: that was last processed by the controller. format: int64 type: integer + peerInterface: + description: |- + PeerInterface is the device-level name of the interface an unnumbered peer is + configured over. It is recorded so that the peer can still be removed from the + device after the referenced Interface has been deleted. + type: string sessionState: description: SessionState is the current operational state of the BGP session. diff --git a/config/samples/v1alpha1_bgppeer.yaml b/config/samples/v1alpha1_bgppeer.yaml index 98c4625e2..14c04891b 100644 --- a/config/samples/v1alpha1_bgppeer.yaml +++ b/config/samples/v1alpha1_bgppeer.yaml @@ -66,3 +66,25 @@ spec: name: bgp-import-policy outboundRoutingPolicyRef: name: bgp-import-policy +--- +apiVersion: networking.metal.ironcore.dev/v1alpha1 +kind: BGPPeer +metadata: + labels: + app.kubernetes.io/name: network-operator + app.kubernetes.io/managed-by: kustomize + name: leaf1-spine1-unnumbered +spec: + deviceRef: + name: leaf1 + bgpRef: + name: bgp + # Unnumbered peering: the session runs over the interface's IPv6 link-local + # address, and "external" accepts any AS number that differs from the local one. + interfaceRef: + name: eth1-4 + asNumber: external + description: Unnumbered eBGP to spine1 + addressFamilies: + ipv4Unicast: + enabled: true diff --git a/docs/api-reference/index.md b/docs/api-reference/index.md index f89990a8f..04d6622a5 100644 --- a/docs/api-reference/index.md +++ b/docs/api-reference/index.md @@ -1053,10 +1053,11 @@ _Appears in:_ | --- | --- | --- | --- | | `deviceRef` _[LocalObjectReference](#localobjectreference)_ | DeviceName is the name of the Device this object belongs to. The Device object must exist in the same namespace.
Immutable. | | Required: \{\}
| | `providerConfigRef` _[TypedLocalObjectReference](#typedlocalobjectreference)_ | ProviderConfigRef is a reference to a resource holding the provider-specific configuration of this interface.
This reference is used to link the BGPPeer to its provider-specific configuration. | | Optional: \{\}
| -| `bgpRef` _[LocalObjectReference](#localobjectreference)_ | BgpRef is a reference to the BGP instance this peer belongs to.
The BGP object must exist in the same namespace. | | Required: \{\}
| +| `bgpRef` _[LocalObjectReference](#localobjectreference)_ | BgpRef is a reference to the BGP instance this peer belongs to.
The BGP object must exist in the same namespace.
Immutable. | | Required: \{\}
| | `adminState` _[AdminState](#adminstate)_ | AdminState indicates whether this BGP peer is administratively up or down.
When Down, the BGP session with this peer is administratively shut down. | Up | Enum: [Up Down]
Optional: \{\}
| -| `address` _string_ | Address is the IPv4 address of the BGP peer. | | Format: ipv4
Required: \{\}
| -| `asNumber` _[IntOrString](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.35/#intorstring-intstr-util)_ | ASNumber is the autonomous system number (ASN) of the BGP peer.
Supports both plain format (1-4294967295) and dotted notation (0-65535.0-65535) as per RFC 5396. | | Required: \{\}
| +| `address` _string_ | Address is the IPv4 address of the BGP peer.
Mutually exclusive with InterfaceRef: exactly one of both must be specified.
Immutable. | | Format: ipv4
Optional: \{\}
| +| `interfaceRef` _[LocalObjectReference](#localobjectreference)_ | InterfaceRef is a reference to an Interface resource over which an unnumbered
(interface-based) BGP session is established. The peers discover each other over
their IPv6 link-local addresses, so the link needs no addressing of its own.
The referenced Interface must belong to the same Device, exist in the same namespace,
and be configured for link-local operation (spec.ipv6.useLinkLocalOnly).
Mutually exclusive with Address: exactly one of both must be specified.
Immutable. | | Optional: \{\}
| +| `asNumber` _[IntOrString](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.35/#intorstring-intstr-util)_ | ASNumber is the autonomous system number (ASN) of the BGP peer.
Supports both plain format (1-4294967295) and dotted notation (0-65535.0-65535) as per RFC 5396.
The special value "external" configures a dynamic AS number, accepting any AS number
that differs from the local one. It is only valid together with InterfaceRef. | | Required: \{\}
| | `description` _string_ | Description is an optional human-readable description for this BGP peer.
This field is used for documentation purposes and may be displayed in management interfaces. | | Optional: \{\}
| | `localAddress` _[BGPPeerLocalAddress](#bgppeerlocaladdress)_ | LocalAddress specifies the local address configuration for the BGP session with this peer.
This determines the source address/interface for BGP packets sent to this peer. | | Optional: \{\}
| | `addressFamilies` _[BGPPeerAddressFamilies](#bgppeeraddressfamilies)_ | AddressFamilies configures address family specific settings for this BGP peer.
Controls which address families are enabled and their specific configuration. | | Optional: \{\}
| @@ -1080,6 +1081,7 @@ _Appears in:_ | `lastEstablishedTime` _[Time](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.35/#time-v1-meta)_ | LastEstablishedTime is the timestamp when the BGP session last transitioned to the ESTABLISHED state.
A frequently changing timestamp indicates session instability (flapping). | | Optional: \{\}
| | `advertisedPrefixesSummary` _string_ | AdvertisedPrefixesSummary provides a human-readable summary of advertised prefixes
across all address families (e.g., "10 (IPv4Unicast), 5 (IPv6Unicast)").
This field is computed by the controller from the AddressFamilies field. | | Optional: \{\}
| | `addressFamilies` _[AddressFamilyStatus](#addressfamilystatus) array_ | AddressFamilies contains per-address-family statistics for this peer.
Only address families that are enabled and negotiated with the peer are included. | | Optional: \{\}
| +| `peerInterface` _string_ | PeerInterface is the device-level name of the interface an unnumbered peer is
configured over. It is recorded so that the peer can still be removed from the
device after the referenced Interface has been deleted. | | Optional: \{\}
| | `observedGeneration` _integer_ | ObservedGeneration reflects the .metadata.generation that was last processed by the controller. | | Optional: \{\}
| | `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.35/#condition-v1-meta) array_ | The conditions are a list of status objects that describe the state of the BGP. | | Optional: \{\}
| diff --git a/hack/provider/main.go b/hack/provider/main.go index 097e48c4e..ce554666c 100644 --- a/hack/provider/main.go +++ b/hack/provider/main.go @@ -503,6 +503,22 @@ func performCreate(ctx context.Context, prov provider.Provider, obj client.Objec sourceInterface = iface.Spec.Name } + peerInterface := "" + if res.Spec.InterfaceRef != nil && res.Spec.InterfaceRef.Name != "" { + if len(refStore) == 0 { + return errors.New("bgppeer resource references peer interface but no reference files provided (use --ref-files)") + } + obj := refStore.Get(res.Spec.InterfaceRef.Name, res.Namespace) + if obj == nil { + return fmt.Errorf("referenced peer interface %s not found in reference files", res.Spec.InterfaceRef.Name) + } + iface, ok := obj.(*v1alpha1.Interface) + if !ok { + return fmt.Errorf("referenced resource %s is not an Interface", res.Spec.InterfaceRef.Name) + } + peerInterface = iface.Spec.Name + } + var cfg *provider.ProviderConfig if res.Spec.ProviderConfigRef != nil { var err error @@ -515,6 +531,7 @@ func performCreate(ctx context.Context, prov provider.Provider, obj client.Objec return bpp.EnsureBGPPeer(ctx, &provider.EnsureBGPPeerRequest{ BGPPeer: res, SourceInterface: sourceInterface, + PeerInterface: peerInterface, ProviderConfig: cfg, }) @@ -1109,8 +1126,25 @@ func performDelete(ctx context.Context, prov provider.Provider, obj client.Objec if !ok { return errors.New("provider does not implement BGPPeerProvider") } + + // An unnumbered peer is identified by its interface, so it has to be + // resolved for the deletion as well. + peerInterface := "" + if resource.Spec.InterfaceRef != nil && resource.Spec.InterfaceRef.Name != "" { + obj := refStore.Get(resource.Spec.InterfaceRef.Name, resource.Namespace) + if obj == nil { + return fmt.Errorf("referenced peer interface %s not found in reference files", resource.Spec.InterfaceRef.Name) + } + iface, ok := obj.(*v1alpha1.Interface) + if !ok { + return fmt.Errorf("referenced resource %s is not an Interface", resource.Spec.InterfaceRef.Name) + } + peerInterface = iface.Spec.Name + } + return bpp.DeleteBGPPeer(ctx, &provider.DeleteBGPPeerRequest{ - BGPPeer: resource, + BGPPeer: resource, + PeerInterface: peerInterface, }) case *v1alpha1.Certificate: diff --git a/internal/controller/core/bgp_peer_controller.go b/internal/controller/core/bgp_peer_controller.go index ff7014c56..a786fa5e7 100644 --- a/internal/controller/core/bgp_peer_controller.go +++ b/internal/controller/core/bgp_peer_controller.go @@ -47,6 +47,10 @@ const bgpPeerBGPRefIndexKey = ".spec.bgpRef.name" // referenced by BGPPeer address families. const bgpPeerRoutingPolicyRefIndexKey = ".spec.addressFamilies.routingPolicyRefs" +// bgpPeerInterfaceRefIndexKey is the field index key for all Interface names referenced by +// a BGPPeer, both as unnumbered peer interface and as local (source) address. +const bgpPeerInterfaceRefIndexKey = ".spec.interfaceRefs" + // BGPPeerReconciler reconciles a BGPPeer object type BGPPeerReconciler struct { client.Client @@ -276,6 +280,20 @@ func (r *BGPPeerReconciler) SetupWithManager(ctx context.Context, mgr ctrl.Manag return err } + if err := mgr.GetFieldIndexer().IndexField(ctx, &v1alpha1.BGPPeer{}, bgpPeerInterfaceRefIndexKey, func(obj client.Object) []string { + o := obj.(*v1alpha1.BGPPeer) + var names []string + if o.Spec.InterfaceRef != nil { + names = append(names, o.Spec.InterfaceRef.Name) + } + if o.Spec.LocalAddress != nil { + names = append(names, o.Spec.LocalAddress.InterfaceRef.Name) + } + return names + }); err != nil { + return err + } + bldr := ctrl.NewControllerManagedBy(mgr). For(&v1alpha1.BGPPeer{}). Named("bgppeer"). @@ -353,6 +371,20 @@ func (r *BGPPeerReconciler) SetupWithManager(ctx context.Context, mgr ctrl.Manag }, }), ). + // Watches enqueues BGPPeers when a referenced Interface is created or deleted. + // Only triggers on create and delete events since interface names are immutable. + Watches( + &v1alpha1.Interface{}, + handler.EnqueueRequestsFromMapFunc(r.interfaceToBGPPeers), + builder.WithPredicates(predicate.Funcs{ + UpdateFunc: func(e event.UpdateEvent) bool { + return false + }, + GenericFunc: func(e event.GenericEvent) bool { + return false + }, + }), + ). Complete(r) } @@ -418,30 +450,23 @@ func (r *BGPPeerReconciler) reconcile(ctx context.Context, s *bgpPeerScope) (ret var sourceInterface string if addr := s.BGPPeer.Spec.LocalAddress; addr != nil { - intf := new(v1alpha1.Interface) - if err := r.Get(ctx, client.ObjectKey{Name: addr.InterfaceRef.Name, Namespace: s.BGPPeer.Namespace}, intf); err != nil { - if apierrors.IsNotFound(err) { - conditions.Set(s.BGPPeer, metav1.Condition{ - Type: v1alpha1.ConfiguredCondition, - Status: metav1.ConditionFalse, - Reason: v1alpha1.InterfaceNotFoundReason, - Message: fmt.Sprintf("source interface %q not found", addr.InterfaceRef.Name), - }) - return reconcile.TerminalError(fmt.Errorf("source interface %q not found", addr.InterfaceRef.Name)) - } - return fmt.Errorf("failed to get source interface %q: %w", addr.InterfaceRef.Name, err) + intf, err := r.reconcileInterfaceRef(ctx, s, addr.InterfaceRef.Name, "source interface") + if err != nil { + return err } + sourceInterface = intf.Spec.Name + } - if intf.Spec.DeviceRef.Name != s.Device.Name { - conditions.Set(s.BGPPeer, metav1.Condition{ - Type: v1alpha1.ConfiguredCondition, - Status: metav1.ConditionFalse, - Reason: v1alpha1.CrossDeviceReferenceReason, - Message: fmt.Sprintf("source interface %q does not belong to device %q", intf.Name, s.Device.Name), - }) - return reconcile.TerminalError(fmt.Errorf("source interface %q does not belong to device %q", intf.Name, s.Device.Name)) + var peerInterface string + if ref := s.BGPPeer.Spec.InterfaceRef; ref != nil { + intf, err := r.reconcileInterfaceRef(ctx, s, ref.Name, "peer interface") + if err != nil { + return err } - sourceInterface = intf.Spec.Name + peerInterface = intf.Spec.Name + // Recorded even if configuring the device fails below, so that a partially + // applied configuration can still be cleaned up once the Interface is gone. + s.BGPPeer.Status.PeerInterface = peerInterface } if s.BGPPeer.Spec.LocalAS != nil && s.BGPPeer.Spec.ASNumber.String() == bgp.Spec.ASNumber.String() { @@ -468,6 +493,7 @@ func (r *BGPPeerReconciler) reconcile(ctx context.Context, s *bgpPeerScope) (ret BGPPeer: s.BGPPeer, ProviderConfig: s.ProviderConfig, SourceInterface: sourceInterface, + PeerInterface: peerInterface, BGP: bgp, VRF: vrf, InboundRoutingPolicies: inbound, @@ -484,6 +510,7 @@ func (r *BGPPeerReconciler) reconcile(ctx context.Context, s *bgpPeerScope) (ret status, err := s.Provider.GetPeerStatus(ctx, &provider.BGPPeerStatusRequest{ BGPPeer: s.BGPPeer, ProviderConfig: s.ProviderConfig, + PeerInterface: peerInterface, VRF: vrf, }) if err != nil { @@ -559,6 +586,28 @@ func (r *BGPPeerReconciler) finalize(ctx context.Context, s *bgpPeerScope) (rete } } + // The interface is the identity of an unnumbered peer on the device. Prefer the name + // recorded during reconciliation, as the Interface may already have been deleted. + var peerInterface string + if ref := s.BGPPeer.Spec.InterfaceRef; ref != nil { + peerInterface = s.BGPPeer.Status.PeerInterface + if peerInterface == "" { + intf := new(v1alpha1.Interface) + if err := r.Get(ctx, types.NamespacedName{ + Name: ref.Name, + Namespace: s.BGPPeer.Namespace, + }, intf); err != nil { + // Without the recorded name and the Interface the peer cannot be + // identified on the device, so we can only proceed with deletion. + return client.IgnoreNotFound(err) + } + if intf.Spec.DeviceRef.Name != s.Device.Name { + return reconcile.TerminalError(fmt.Errorf("interface %s belongs to different device", ref.Name)) + } + peerInterface = intf.Spec.Name + } + } + if err := s.Provider.Connect(ctx, s.Connection); err != nil { return fmt.Errorf("failed to connect to provider: %w", err) } @@ -569,6 +618,7 @@ func (r *BGPPeerReconciler) finalize(ctx context.Context, s *bgpPeerScope) (rete }() return s.Provider.DeleteBGPPeer(ctx, &provider.DeleteBGPPeerRequest{ + PeerInterface: peerInterface, BGPPeer: s.BGPPeer, ProviderConfig: s.ProviderConfig, BGP: bgp, @@ -576,6 +626,37 @@ func (r *BGPPeerReconciler) finalize(ctx context.Context, s *bgpPeerScope) (rete }) } +// reconcileInterfaceRef resolves an Interface referenced by the BGPPeer and validates that +// it belongs to the same Device. The description is used in conditions and errors to tell +// the source interface and the unnumbered peer interface apart. +func (r *BGPPeerReconciler) reconcileInterfaceRef(ctx context.Context, s *bgpPeerScope, name, description string) (*v1alpha1.Interface, error) { + intf := new(v1alpha1.Interface) + if err := r.Get(ctx, client.ObjectKey{Name: name, Namespace: s.BGPPeer.Namespace}, intf); err != nil { + if apierrors.IsNotFound(err) { + conditions.Set(s.BGPPeer, metav1.Condition{ + Type: v1alpha1.ConfiguredCondition, + Status: metav1.ConditionFalse, + Reason: v1alpha1.InterfaceNotFoundReason, + Message: fmt.Sprintf("%s %q not found", description, name), + }) + return nil, reconcile.TerminalError(fmt.Errorf("%s %q not found", description, name)) + } + return nil, fmt.Errorf("failed to get %s %q: %w", description, name, err) + } + + if intf.Spec.DeviceRef.Name != s.Device.Name { + conditions.Set(s.BGPPeer, metav1.Condition{ + Type: v1alpha1.ConfiguredCondition, + Status: metav1.ConditionFalse, + Reason: v1alpha1.CrossDeviceReferenceReason, + Message: fmt.Sprintf("%s %q does not belong to device %q", description, intf.Name, s.Device.Name), + }) + return nil, reconcile.TerminalError(fmt.Errorf("%s %q does not belong to device %q", description, intf.Name, s.Device.Name)) + } + + return intf, nil +} + // reconcileBGP resolves the referenced BGP instance. // Sets ConfiguredCondition and returns a terminal error when the BGP is not found // or belongs to a different device. @@ -771,6 +852,32 @@ func (r *BGPPeerReconciler) bgpPeersForProviderConfig(ctx context.Context, obj c // bgpToBGPPeers is a [handler.MapFunc] to be used to enqueue requests for reconciliation // for BGPPeers when a BGP resource is created, deleted or updated on the same device. +func (r *BGPPeerReconciler) interfaceToBGPPeers(ctx context.Context, obj client.Object) []ctrl.Request { + intf, ok := obj.(*v1alpha1.Interface) + if !ok { + panic(fmt.Sprintf("Expected an Interface but got a %T", obj)) + } + + log := ctrl.LoggerFrom(ctx, "Interface", klog.KObj(intf)) + + list := new(v1alpha1.BGPPeerList) + if err := r.List( + ctx, list, + client.InNamespace(intf.Namespace), + client.MatchingFields{bgpPeerInterfaceRefIndexKey: intf.Name}, + ); err != nil { + log.Error(err, "Failed to list BGPPeers") + return nil + } + + requests := make([]ctrl.Request, 0, len(list.Items)) + for i := range list.Items { + log.V(2).Info("Enqueuing BGPPeer for reconciliation", "BGPPeer", klog.KObj(&list.Items[i])) + requests = append(requests, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(&list.Items[i])}) + } + return requests +} + func (r *BGPPeerReconciler) bgpToBGPPeers(ctx context.Context, obj client.Object) []ctrl.Request { bgp, ok := obj.(*v1alpha1.BGP) if !ok { diff --git a/internal/controller/core/bgp_peer_controller_test.go b/internal/controller/core/bgp_peer_controller_test.go index fa89c92b0..b9fa90367 100644 --- a/internal/controller/core/bgp_peer_controller_test.go +++ b/internal/controller/core/bgp_peer_controller_test.go @@ -6,6 +6,7 @@ package core import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/util/intstr" "sigs.k8s.io/controller-runtime/pkg/client" @@ -281,6 +282,164 @@ var _ = Describe("BGPPeer Controller", func() { }).Should(Succeed()) }) + It("Should handle peer interface reference to non-existing Interface", func() { + By("Creating a BGP resource for the Device") + bgp := &v1alpha1.BGP{ + GenerateName: "test-bgp-", + Namespace: metav1.NamespaceDefault, + Spec: v1alpha1.BGPSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: device.Name}, + ASNumber: intstr.FromInt(65000), + RouterID: "10.0.0.1", + }, + } + Expect(k8sClient.Create(ctx, bgp)).To(Succeed()) + + Eventually(func(g Gomega) { + b := &v1alpha1.BGP{} + g.Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(bgp), b)).To(Succeed()) + g.Expect(conditions.IsReady(b)).To(BeTrue()) + }).Should(Succeed()) + + By("Creating an unnumbered BGPPeer pointing to a non-existent Interface") + bgppeer := &v1alpha1.BGPPeer{ + GenerateName: "test-bgppeer-", + Namespace: metav1.NamespaceDefault, + Spec: v1alpha1.BGPPeerSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: device.Name}, + BgpRef: v1alpha1.LocalObjectReference{Name: bgp.Name}, + InterfaceRef: &v1alpha1.LocalObjectReference{Name: "non-existing-interface"}, + ASNumber: intstr.FromString(v1alpha1.BGPPeerASNumberExternal), + }, + } + Expect(k8sClient.Create(ctx, bgppeer)).To(Succeed()) + + By("Verifying the controller sets Interface not found status") + Eventually(func(g Gomega) { + resource := &v1alpha1.BGPPeer{} + g.Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(bgppeer), resource)).To(Succeed()) + g.Expect(resource.Status.Conditions).To(HaveLen(4)) + g.Expect(conditions.IsConfigured(resource)).To(BeFalse()) + g.Expect(resource.Status.Conditions[1].Type).To(Equal(v1alpha1.ConfiguredCondition)) + g.Expect(resource.Status.Conditions[1].Reason).To(Equal(v1alpha1.InterfaceNotFoundReason)) + g.Expect(resource.Status.Conditions[1].Message).To(ContainSubstring("peer interface")) + }).Should(Succeed()) + }) + + It("Should remove an unnumbered BGP peer from the provider after its Interface was deleted", func() { + By("Creating a BGP resource for the Device") + bgp := &v1alpha1.BGP{ + GenerateName: "test-bgppeer-bgp-", + Namespace: metav1.NamespaceDefault, + Spec: v1alpha1.BGPSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: device.Name}, + ASNumber: intstr.FromInt(65000), + RouterID: "10.0.0.10", + }, + } + Expect(k8sClient.Create(ctx, bgp)).To(Succeed()) + + Eventually(func(g Gomega) { + b := &v1alpha1.BGP{} + g.Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(bgp), b)).To(Succeed()) + g.Expect(conditions.IsReady(b)).To(BeTrue()) + }).Should(Succeed()) + + By("Creating a link-local-only Interface resource") + intf := &v1alpha1.Interface{ + GenerateName: "test-bgppeer-intf-", + Namespace: metav1.NamespaceDefault, + Spec: v1alpha1.InterfaceSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: device.Name}, + Name: "Ethernet1/1", + AdminState: v1alpha1.AdminStateUp, + Type: v1alpha1.InterfaceTypePhysical, + IPv6: &v1alpha1.InterfaceIPv6{UseLinkLocalOnly: true}, + }, + } + Expect(k8sClient.Create(ctx, intf)).To(Succeed()) + + By("Creating an unnumbered BGPPeer over the Interface") + bgppeer := &v1alpha1.BGPPeer{ + GenerateName: "test-bgppeer-", + Namespace: metav1.NamespaceDefault, + Spec: v1alpha1.BGPPeerSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: device.Name}, + BgpRef: v1alpha1.LocalObjectReference{Name: bgp.Name}, + InterfaceRef: &v1alpha1.LocalObjectReference{Name: intf.Name}, + ASNumber: intstr.FromString(v1alpha1.BGPPeerASNumberExternal), + }, + } + Expect(k8sClient.Create(ctx, bgppeer)).To(Succeed()) + + By("Verifying the peer is configured and its interface is recorded") + Eventually(func(g Gomega) { + resource := &v1alpha1.BGPPeer{} + g.Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(bgppeer), resource)).To(Succeed()) + g.Expect(resource.Status.PeerInterface).To(Equal("Ethernet1/1")) + g.Expect(testProvider.BGPPeers.Has("Ethernet1/1")).To(BeTrue()) + }).Should(Succeed()) + + By("Deleting the Interface before the BGPPeer") + Expect(k8sClient.Delete(ctx, intf)).To(Succeed()) + Eventually(func(g Gomega) { + err := k8sClient.Get(ctx, client.ObjectKeyFromObject(intf), &v1alpha1.Interface{}) + g.Expect(apierrors.IsNotFound(err)).To(BeTrue()) + }).Should(Succeed()) + + By("Deleting the BGPPeer") + Expect(k8sClient.Delete(ctx, bgppeer)).To(Succeed()) + Eventually(func(g Gomega) { + g.Expect(testProvider.BGPPeers.Has("Ethernet1/1")).To(BeFalse(), "Provider should not have the unnumbered BGP peer configured") + }).Should(Succeed()) + }) + + It("Should reject changes to the peer identity", func() { + By("Creating a BGPPeer resource") + bgppeer := &v1alpha1.BGPPeer{ + GenerateName: "test-bgppeer-", + Namespace: metav1.NamespaceDefault, + Spec: v1alpha1.BGPPeerSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: device.Name}, + BgpRef: v1alpha1.LocalObjectReference{Name: "bgp"}, + Address: host, + ASNumber: intstr.FromInt(65000), + }, + } + Expect(k8sClient.Create(ctx, bgppeer)).To(Succeed()) + + for _, tc := range []struct { + mutate func(*v1alpha1.BGPPeer) + message string + }{ + { + mutate: func(p *v1alpha1.BGPPeer) { p.Spec.Address = "10.0.0.2" }, + message: "Address is immutable", + }, + { + mutate: func(p *v1alpha1.BGPPeer) { + p.Spec.Address = "" + p.Spec.InterfaceRef = &v1alpha1.LocalObjectReference{Name: "eth1-1"} + }, + message: "InterfaceRef is immutable", + }, + { + mutate: func(p *v1alpha1.BGPPeer) { p.Spec.BgpRef.Name = "other-bgp" }, + message: "BgpRef is immutable", + }, + } { + By("Attempting an update that must be rejected with: " + tc.message) + Eventually(func(g Gomega) { + resource := &v1alpha1.BGPPeer{} + g.Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(bgppeer), resource)).To(Succeed()) + tc.mutate(resource) + err := k8sClient.Update(ctx, resource) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring(tc.message)) + }).Should(Succeed()) + } + }) + It("Should reject local address reference to Interface on different device", func() { By("Creating a BGP resource for the Device") bgp := &v1alpha1.BGP{ diff --git a/internal/controller/core/suite_test.go b/internal/controller/core/suite_test.go index 48ca12a49..e786b2da2 100644 --- a/internal/controller/core/suite_test.go +++ b/internal/controller/core/suite_test.go @@ -4,6 +4,7 @@ package core import ( + "cmp" "context" "errors" "fmt" @@ -793,14 +794,14 @@ func (p *Provider) DeleteBGP(context.Context, *provider.DeleteBGPRequest) error func (p *Provider) EnsureBGPPeer(_ context.Context, req *provider.EnsureBGPPeerRequest) error { p.Lock() defer p.Unlock() - p.BGPPeers.Insert(req.BGPPeer.Spec.Address) + p.BGPPeers.Insert(cmp.Or(req.PeerInterface, req.BGPPeer.Spec.Address)) return nil } func (p *Provider) DeleteBGPPeer(_ context.Context, req *provider.DeleteBGPPeerRequest) error { p.Lock() defer p.Unlock() - p.BGPPeers.Delete(req.BGPPeer.Spec.Address) + p.BGPPeers.Delete(cmp.Or(req.PeerInterface, req.BGPPeer.Spec.Address)) return nil } diff --git a/internal/provider/cisco/iosxr/provider.go b/internal/provider/cisco/iosxr/provider.go index 7a7b733d5..353350455 100644 --- a/internal/provider/cisco/iosxr/provider.go +++ b/internal/provider/cisco/iosxr/provider.go @@ -11,6 +11,7 @@ import ( "time" "github.com/ironcore-dev/network-operator/api/core/v1alpha1" + "github.com/ironcore-dev/network-operator/internal/apistatus" "github.com/ironcore-dev/network-operator/internal/deviceutil" "github.com/ironcore-dev/network-operator/internal/provider" "github.com/ironcore-dev/network-operator/internal/transport/gnmiext" @@ -444,6 +445,13 @@ func (p *Provider) DeleteBGP(context.Context, *provider.DeleteBGPRequest) error } func (p *Provider) EnsureBGPPeer(ctx context.Context, req *provider.EnsureBGPPeerRequest) error { + if req.BGPPeer.Spec.InterfaceRef != nil { + return apistatus.NewUnsupportedFieldError(apistatus.FieldViolation{ + Field: "spec.interfaceRef", + Description: "iosxr provider does not support unnumbered BGP peering", + }) + } + // Ensure that the BGP instance exists and is configured on the "default" domain bgp := new(BGP) bgp.InstanceName = BGPDefaultInstance @@ -540,6 +548,11 @@ func (p *Provider) EnsureBGPPeer(ctx context.Context, req *provider.EnsureBGPPee } func (p *Provider) DeleteBGPPeer(ctx context.Context, req *provider.DeleteBGPPeerRequest) error { + // Unnumbered peers are rejected by EnsureBGPPeer, so there is nothing to delete. + if req.BGPPeer.Spec.InterfaceRef != nil { + return nil + } + // Fetch the default BGP instance id bgp := new(BGP) bgp.InstanceName = BGPDefaultInstance diff --git a/internal/provider/cisco/iosxr/provider_test.go b/internal/provider/cisco/iosxr/provider_test.go index 8d2840c8b..4bc9d1d9a 100644 --- a/internal/provider/cisco/iosxr/provider_test.go +++ b/internal/provider/cisco/iosxr/provider_test.go @@ -263,3 +263,20 @@ func Test_NewMTU(t *testing.T) { }) } } + +func Test_DeleteBGPPeer_Unnumbered(t *testing.T) { + // The mock has no functions set, so any device call panics. + p := &Provider{client: &gnmiext.ClientMock{}} + + err := p.DeleteBGPPeer(t.Context(), &provider.DeleteBGPPeerRequest{ + BGPPeer: &v1alpha1.BGPPeer{ + Spec: v1alpha1.BGPPeerSpec{ + InterfaceRef: &v1alpha1.LocalObjectReference{Name: "eth1-1"}, + }, + }, + PeerInterface: "HundredGigE0/0/0/1", + }) + if err != nil { + t.Fatalf("DeleteBGPPeer() error = %v", err) + } +} diff --git a/internal/provider/cisco/nxos/bgp.go b/internal/provider/cisco/nxos/bgp.go index 92996735e..f872eaca8 100644 --- a/internal/provider/cisco/nxos/bgp.go +++ b/internal/provider/cisco/nxos/bgp.go @@ -19,6 +19,8 @@ var ( _ gnmiext.DataElement = (*BGPDom)(nil) _ gnmiext.DataElement = (*BGPDomItems)(nil) _ gnmiext.DataElement = (*BGPPeerGroup)(nil) + _ gnmiext.DataElement = (*BGPPeerIf)(nil) + _ gnmiext.DataElement = (*BGPPeerIfOperItems)(nil) ) // ownershipMarkerPrefix is used to build per-VRF peer template names written @@ -194,22 +196,51 @@ func (af *BGPDomAfItem) SetMultipath(m *v1alpha1.BGPMultipath) error { } type BGPPeer struct { - VRFName string `json:"-"` - Addr string `json:"addr"` - AdminSt AdminSt `json:"adminSt"` - Asn string `json:"asn"` - AsnType PeerAsnType `json:"asnType"` - Name string `json:"name,omitempty"` - SrcIf string `json:"srcIf,omitempty"` - LocalAsnItems struct { - AsnPropagate AsnPropagate `json:"asnPropagate"` - LocalAsn string `json:"localAsn"` - } `json:"localasn-items,omitzero"` - AfItems struct { + VRFName string `json:"-"` + Addr string `json:"addr"` + AdminSt AdminSt `json:"adminSt"` + // Asn is empty for peers with a dynamic AS number, which is indicated by AsnType. + // The device reports it as an empty string in that case, so the zero value matches. + Asn string `json:"asn,omitempty"` + AsnType PeerAsnType `json:"asnType"` + Name string `json:"name,omitempty"` + SrcIf string `json:"srcIf,omitempty"` + LocalAsnItems BGPPeerLocalAsn `json:"localasn-items,omitzero"` + AfItems struct { + PeerAfList gnmiext.List[AddressFamily, *BGPPeerAfItem] `json:"PeerAf-list,omitzero"` + } `json:"af-items,omitzero"` +} + +// BGPPeerLocalAsn is the local AS number a peer sees instead of the AS number of the +// BGP instance, and how both AS numbers factor into the announcements towards the peer. +type BGPPeerLocalAsn struct { + AsnPropagate AsnPropagate `json:"asnPropagate"` + LocalAsn string `json:"localAsn"` +} + +// BGPPeerIf is an unnumbered (interface-based) BGP peer. The session is established over +// the IPv6 link-local address the peer advertises on the interface, so the peer has no +// address of its own and, unlike [BGPPeer], no source interface. +type BGPPeerIf struct { + VRFName string `json:"-"` + ID string `json:"id"` + AdminSt AdminSt `json:"adminSt"` + // Asn is empty for peers with a dynamic AS number, which is indicated by AsnType. + Asn string `json:"asn,omitempty"` + AsnType PeerAsnType `json:"asnType"` + Name string `json:"name,omitempty"` + LocalAsnItems BGPPeerLocalAsn `json:"localasn-items,omitzero"` + AfItems struct { PeerAfList gnmiext.List[AddressFamily, *BGPPeerAfItem] `json:"PeerAf-list,omitzero"` } `json:"af-items,omitzero"` } +func (*BGPPeerIf) IsListItem() {} + +func (p *BGPPeerIf) XPath() string { + return "System/bgp-items/inst-items/dom-items/Dom-list[name=" + p.VRFName + "]/peerif-items/PeerIf-list[id=" + p.ID + "]" +} + type AsnPropagate string const ( @@ -275,6 +306,19 @@ func (p *BGPPeerOperItems) XPath() string { return "System/bgp-items/inst-items/dom-items/Dom-list[name=" + p.VRFName + "]/peer-items/Peer-list[addr=" + p.Addr + "]/ent-items/PeerEntry-list[addr=" + p.Addr + "]" } +// BGPPeerIfOperItems holds the peer entries of an unnumbered BGP peer. The entries are +// keyed by the link-local address of the peer, which is only learned at runtime, so the +// whole container is retrieved instead of a single entry. +type BGPPeerIfOperItems struct { + VRFName string `json:"-"` + ID string `json:"-"` + PeerEntryList []*BGPPeerOperItems `json:"PeerEntry-list,omitempty"` +} + +func (p *BGPPeerIfOperItems) XPath() string { + return "System/bgp-items/inst-items/dom-items/Dom-list[name=" + p.VRFName + "]/peerif-items/PeerIf-list[id=" + p.ID + "]/ent-items" +} + type BGPPeerAfOperItems struct { AcceptedPaths uint32 `json:"acceptedPaths"` PfxSent string `json:"pfxSent"` diff --git a/internal/provider/cisco/nxos/bgp_test.go b/internal/provider/cisco/nxos/bgp_test.go index 41482dcff..3987930ce 100644 --- a/internal/provider/cisco/nxos/bgp_test.go +++ b/internal/provider/cisco/nxos/bgp_test.go @@ -42,6 +42,32 @@ func init() { }) Register("bgp_peer", bgpPeer) + // Unnumbered peer with a dynamic AS number ("remote-as external"). The device + // reports asn as an empty string in that case, so it is omitted from the payload. + bgpPeerIf := &BGPPeerIf{ + VRFName: DefaultVRFName, + ID: "eth1/1", + AdminSt: AdminStEnabled, + AsnType: PeerAsnTypeExternal, + Name: "Unnumbered peering with spine", + } + bgpPeerIf.AfItems.PeerAfList.Set(&BGPPeerAfItem{ + SendComExt: AdminStDisabled, + SendComStd: AdminStDisabled, + Type: AddressFamilyIPv4Unicast, + }) + Register("bgp_peer_if", bgpPeerIf) + + // Unnumbered peer with an explicit AS number ("remote-as 65020"). + bgpPeerIfAsn := &BGPPeerIf{ + VRFName: DefaultVRFName, + ID: "eth1/2", + AdminSt: AdminStEnabled, + Asn: "65020", + AsnType: PeerAsnTypeNone, + } + Register("bgp_peer_if_asn", bgpPeerIfAsn) + bgwPeer := &MultisitePeer{Addr: "1.1.1.1", PeerType: BorderGatewayPeerTypeFabricExternal} Register("bgw_peer", bgwPeer) diff --git a/internal/provider/cisco/nxos/provider.go b/internal/provider/cisco/nxos/provider.go index 451898e88..b75737935 100644 --- a/internal/provider/cisco/nxos/provider.go +++ b/internal/provider/cisco/nxos/provider.go @@ -792,16 +792,52 @@ func (p *Provider) EnsureBGPPeer(ctx context.Context, req *provider.EnsureBGPPee return apistatus.NewFailedPreconditionError(fmt.Sprintf("bgp peer: BGP instance %q must be configured on the device before peers can be realized: %v", bgp.Name, err)) } + adminSt := AdminStEnabled + if req.BGPPeer.Spec.AdminState == v1alpha1.AdminStateDown { + adminSt = AdminStDisabled + } + + // A peer with a dynamic AS number carries no AS number of its own. + asn, asnType := req.BGPPeer.Spec.ASNumber.String(), PeerAsnTypeNone + if req.BGPPeer.Spec.IsExternalASNumber() { + asn, asnType = "", PeerAsnTypeExternal + } + + localAsn, err := bgpPeerLocalAsn(req) + if err != nil { + return err + } + + // Unnumbered peers are identified by the interface they are reachable over instead + // of by an address, and are configured under a separate list on the device. + if req.PeerInterface != "" { + id, err := ShortName(req.PeerInterface) + if err != nil { + return fmt.Errorf("bgp peer: invalid peer interface name %q: %w", req.PeerInterface, err) + } + + pe := new(BGPPeerIf) + pe.VRFName = bgp.Name + pe.ID = id + pe.AdminSt = adminSt + pe.Asn = asn + pe.AsnType = asnType + pe.Name = req.BGPPeer.Spec.Description + pe.LocalAsnItems = localAsn + pe.AfItems.PeerAfList = bgpPeerAfItems(req) + + return p.client.Update(ctx, pe) + } + pe := new(BGPPeer) pe.VRFName = bgp.Name pe.Addr = req.BGPPeer.Spec.Address - pe.AdminSt = AdminStEnabled - if req.BGPPeer.Spec.AdminState == v1alpha1.AdminStateDown { - pe.AdminSt = AdminStDisabled - } - pe.Asn = req.BGPPeer.Spec.ASNumber.String() - pe.AsnType = PeerAsnTypeNone + pe.AdminSt = adminSt + pe.Asn = asn + pe.AsnType = asnType pe.Name = req.BGPPeer.Spec.Description + pe.LocalAsnItems = localAsn + pe.AfItems.PeerAfList = bgpPeerAfItems(req) if req.SourceInterface != "" { srcIf, err := ShortName(req.SourceInterface) @@ -811,76 +847,101 @@ func (p *Provider) EnsureBGPPeer(ctx context.Context, req *provider.EnsureBGPPee pe.SrcIf = srcIf } - if req.BGPPeer.Spec.LocalAS != nil { - if req.BGPPeer.Spec.LocalAS.ASNumber.String() == req.BGP.Spec.ASNumber.String() { - return apistatus.NewInvalidArgumentError(apistatus.FieldViolation{ - Field: "spec.localAS", - Description: "local-as cannot be configured on iBGP peers", - }) - } + return p.client.Update(ctx, pe) +} - pe.LocalAsnItems.LocalAsn = req.BGPPeer.Spec.LocalAS.ASNumber.String() +// bgpPeerLocalAsn builds the local AS configuration shared by both peer kinds. +func bgpPeerLocalAsn(req *provider.EnsureBGPPeerRequest) (items BGPPeerLocalAsn, err error) { + if req.BGPPeer.Spec.LocalAS == nil { + return items, nil + } - prependLocalAS := req.BGPPeer.Spec.LocalAS.PrependLocalAS == nil || *req.BGPPeer.Spec.LocalAS.PrependLocalAS - prependGlobalAS := req.BGPPeer.Spec.LocalAS.PrependGlobalAS == nil || *req.BGPPeer.Spec.LocalAS.PrependGlobalAS + if req.BGPPeer.Spec.LocalAS.ASNumber.String() == req.BGP.Spec.ASNumber.String() { + return items, apistatus.NewInvalidArgumentError(apistatus.FieldViolation{ + Field: "spec.localAS", + Description: "local-as cannot be configured on iBGP peers", + }) + } - switch { - case !prependLocalAS && prependGlobalAS: - pe.LocalAsnItems.AsnPropagate = AsnPropagateNoPrep - case !prependLocalAS && !prependGlobalAS: - pe.LocalAsnItems.AsnPropagate = AsnPropagateReplaceAs - case prependLocalAS && !prependGlobalAS: - return apistatus.NewInvalidArgumentError(apistatus.FieldViolation{ - Field: "spec.localAS.prependGlobalAS", - Description: "prependGlobalAS=false (replace-as mode) requires prependLocalAS=false (no-prepend on inbound)", - }) - default: - pe.LocalAsnItems.AsnPropagate = AsnPropagateNone - } + items.LocalAsn = req.BGPPeer.Spec.LocalAS.ASNumber.String() + + prependLocalAS := req.BGPPeer.Spec.LocalAS.PrependLocalAS == nil || *req.BGPPeer.Spec.LocalAS.PrependLocalAS + prependGlobalAS := req.BGPPeer.Spec.LocalAS.PrependGlobalAS == nil || *req.BGPPeer.Spec.LocalAS.PrependGlobalAS + + switch { + case !prependLocalAS && prependGlobalAS: + items.AsnPropagate = AsnPropagateNoPrep + case !prependLocalAS && !prependGlobalAS: + items.AsnPropagate = AsnPropagateReplaceAs + case prependLocalAS && !prependGlobalAS: + return items, apistatus.NewInvalidArgumentError(apistatus.FieldViolation{ + Field: "spec.localAS.prependGlobalAS", + Description: "prependGlobalAS=false (replace-as mode) requires prependLocalAS=false (no-prepend on inbound)", + }) + default: + items.AsnPropagate = AsnPropagateNone } - if req.BGPPeer.Spec.AddressFamilies != nil { - for t, af := range map[AddressFamily]*v1alpha1.BGPPeerAddressFamily{ - AddressFamilyIPv4Unicast: req.BGPPeer.Spec.AddressFamilies.Ipv4Unicast, - AddressFamilyIPv6Unicast: req.BGPPeer.Spec.AddressFamilies.Ipv6Unicast, - AddressFamilyL2EVPN: req.BGPPeer.Spec.AddressFamilies.L2vpnEvpn, - } { - if af == nil || !af.Enabled { - continue - } - item := new(BGPPeerAfItem) - item.Type = t - item.SendComStd = AdminStDisabled - if af.SendCommunity == v1alpha1.BGPCommunityTypeStandard || af.SendCommunity == v1alpha1.BGPCommunityTypeBoth { - item.SendComStd = AdminStEnabled - } - item.SendComExt = AdminStDisabled - if af.SendCommunity == v1alpha1.BGPCommunityTypeExtended || af.SendCommunity == v1alpha1.BGPCommunityTypeBoth { - item.SendComExt = AdminStEnabled - } - if af.RouteReflectorClient { - item.Ctrl = NewOption(RouteReflectorClient) - } - afType := t.ToAddressFamilyType() - if name, ok := req.InboundRoutingPolicies[afType]; ok { - item.RtCtrlPItems.RtCtrlPList.Set(&BGPPeerAfRtCtrlP{Direction: RtCtrlDirectionIn, RtMap: name}) - } - if name, ok := req.OutboundRoutingPolicies[afType]; ok { - item.RtCtrlPItems.RtCtrlPList.Set(&BGPPeerAfRtCtrlP{Direction: RtCtrlDirectionOut, RtMap: name}) - } - pe.AfItems.PeerAfList.Set(item) + return items, nil +} + +// bgpPeerAfItems builds the per-address-family configuration shared by both peer kinds. +func bgpPeerAfItems(req *provider.EnsureBGPPeerRequest) gnmiext.List[AddressFamily, *BGPPeerAfItem] { + var list gnmiext.List[AddressFamily, *BGPPeerAfItem] + if req.BGPPeer.Spec.AddressFamilies == nil { + return list + } + + for t, af := range map[AddressFamily]*v1alpha1.BGPPeerAddressFamily{ + AddressFamilyIPv4Unicast: req.BGPPeer.Spec.AddressFamilies.Ipv4Unicast, + AddressFamilyIPv6Unicast: req.BGPPeer.Spec.AddressFamilies.Ipv6Unicast, + AddressFamilyL2EVPN: req.BGPPeer.Spec.AddressFamilies.L2vpnEvpn, + } { + if af == nil || !af.Enabled { + continue + } + item := new(BGPPeerAfItem) + item.Type = t + item.SendComStd = AdminStDisabled + if af.SendCommunity == v1alpha1.BGPCommunityTypeStandard || af.SendCommunity == v1alpha1.BGPCommunityTypeBoth { + item.SendComStd = AdminStEnabled + } + item.SendComExt = AdminStDisabled + if af.SendCommunity == v1alpha1.BGPCommunityTypeExtended || af.SendCommunity == v1alpha1.BGPCommunityTypeBoth { + item.SendComExt = AdminStEnabled + } + if af.RouteReflectorClient { + item.Ctrl = NewOption(RouteReflectorClient) } + afType := t.ToAddressFamilyType() + if name, ok := req.InboundRoutingPolicies[afType]; ok { + item.RtCtrlPItems.RtCtrlPList.Set(&BGPPeerAfRtCtrlP{Direction: RtCtrlDirectionIn, RtMap: name}) + } + if name, ok := req.OutboundRoutingPolicies[afType]; ok { + item.RtCtrlPItems.RtCtrlPList.Set(&BGPPeerAfRtCtrlP{Direction: RtCtrlDirectionOut, RtMap: name}) + } + list.Set(item) } - return p.client.Update(ctx, pe) + return list } func (p *Provider) DeleteBGPPeer(ctx context.Context, req *provider.DeleteBGPPeerRequest) error { - b := new(BGPPeer) - b.VRFName = DefaultVRFName + vrfName := DefaultVRFName if req.VRF != nil { - b.VRFName = req.VRF.Spec.Name + vrfName = req.VRF.Spec.Name } + + if req.PeerInterface != "" { + id, err := ShortName(req.PeerInterface) + if err != nil { + return fmt.Errorf("bgp peer: invalid peer interface name %q: %w", req.PeerInterface, err) + } + return p.client.Delete(ctx, &BGPPeerIf{VRFName: vrfName, ID: id}) + } + + b := new(BGPPeer) + b.VRFName = vrfName b.Addr = req.BGPPeer.Spec.Address return p.client.Delete(ctx, b) } @@ -892,7 +953,23 @@ func (p *Provider) GetPeerStatus(ctx context.Context, req *provider.BGPPeerStatu ps.VRFName = req.VRF.Spec.Name } ps.Addr = req.BGPPeer.Spec.Address - if err := p.client.GetState(ctx, ps); err != nil && !errors.Is(err, gnmiext.ErrNil) { + + // An unnumbered peer has no address of its own: its entry is keyed by the link-local + // address learned at runtime, so the whole entry container is retrieved instead. + if req.PeerInterface != "" { + id, err := ShortName(req.PeerInterface) + if err != nil { + return provider.BGPPeerStatus{}, fmt.Errorf("bgp peer status: invalid peer interface name %q: %w", req.PeerInterface, err) + } + ents := &BGPPeerIfOperItems{VRFName: ps.VRFName, ID: id} + if err := p.client.GetState(ctx, ents); err != nil && !errors.Is(err, gnmiext.ErrNil) { + return provider.BGPPeerStatus{}, err + } + if len(ents.PeerEntryList) == 0 { + return provider.BGPPeerStatus{SessionState: v1alpha1.BGPPeerSessionStateIdle}, nil + } + ps = ents.PeerEntryList[0] + } else if err := p.client.GetState(ctx, ps); err != nil && !errors.Is(err, gnmiext.ErrNil) { return provider.BGPPeerStatus{}, err } @@ -1659,12 +1736,16 @@ func (p *Provider) EnsureInterface(ctx context.Context, req *provider.EnsureInte sb.Patch(stp) } - // Add the address items last, as they depend on the interface being created first. + // The address items are replaced rather than merged, so that addresses removed + // from the spec are also removed from the device. They depend on the interface + // being created and routed first, but a gNMI Set processes replace operations + // before update operations, so they are sent in a separate, later Set. + ab := new(gnmiext.SetBuilder).Limit(maxSetOperations) if addr != nil { - sb.Patch(addr) + ab.Update(addr) } if ipv6Addr != nil { - sb.Patch(ipv6Addr) + ab.Update(ipv6Addr) } switch { @@ -1736,7 +1817,10 @@ func (p *Provider) EnsureInterface(ctx context.Context, req *provider.EnsureInte } } - return p.Do(ctx, sb) + if err := p.Do(ctx, sb); err != nil { + return err + } + return p.Do(ctx, ab) } func (p *Provider) DeleteInterface(ctx context.Context, req *provider.InterfaceRequest) error { diff --git a/internal/provider/cisco/nxos/testdata/bgp_peer_if.json b/internal/provider/cisco/nxos/testdata/bgp_peer_if.json new file mode 100644 index 000000000..76e50f92c --- /dev/null +++ b/internal/provider/cisco/nxos/testdata/bgp_peer_if.json @@ -0,0 +1,33 @@ +{ + "bgp-items": { + "inst-items": { + "dom-items": { + "Dom-list": [ + { + "name": "default", + "peerif-items": { + "PeerIf-list": [ + { + "id": "eth1/1", + "adminSt": "enabled", + "asnType": "external", + "name": "Unnumbered peering with spine", + "af-items": { + "PeerAf-list": [ + { + "ctrl": "DME_UNSET_PROPERTY_MARKER", + "sendComExt": "disabled", + "sendComStd": "disabled", + "type": "ipv4-ucast" + } + ] + } + } + ] + } + } + ] + } + } + } +} diff --git a/internal/provider/cisco/nxos/testdata/bgp_peer_if.json.txt b/internal/provider/cisco/nxos/testdata/bgp_peer_if.json.txt new file mode 100644 index 000000000..09883f63e --- /dev/null +++ b/internal/provider/cisco/nxos/testdata/bgp_peer_if.json.txt @@ -0,0 +1,5 @@ +router bgp 65000 + neighbor Ethernet1/1 + description Unnumbered peering with spine + remote-as external + address-family ipv4 unicast diff --git a/internal/provider/cisco/nxos/testdata/bgp_peer_if_asn.json b/internal/provider/cisco/nxos/testdata/bgp_peer_if_asn.json new file mode 100644 index 000000000..fcba03ef7 --- /dev/null +++ b/internal/provider/cisco/nxos/testdata/bgp_peer_if_asn.json @@ -0,0 +1,23 @@ +{ + "bgp-items": { + "inst-items": { + "dom-items": { + "Dom-list": [ + { + "name": "default", + "peerif-items": { + "PeerIf-list": [ + { + "id": "eth1/2", + "adminSt": "enabled", + "asn": "65020", + "asnType": "none" + } + ] + } + } + ] + } + } + } +} diff --git a/internal/provider/cisco/nxos/testdata/bgp_peer_if_asn.json.txt b/internal/provider/cisco/nxos/testdata/bgp_peer_if_asn.json.txt new file mode 100644 index 000000000..8d14de31f --- /dev/null +++ b/internal/provider/cisco/nxos/testdata/bgp_peer_if_asn.json.txt @@ -0,0 +1,3 @@ +router bgp 65000 + neighbor Ethernet1/2 + remote-as 65020 diff --git a/internal/provider/openconfig/bgp_test.go b/internal/provider/openconfig/bgp_test.go index 6b5e7bb74..7e85e7c45 100644 --- a/internal/provider/openconfig/bgp_test.go +++ b/internal/provider/openconfig/bgp_test.go @@ -7,6 +7,10 @@ import ( "testing" "k8s.io/apimachinery/pkg/util/intstr" + + "github.com/ironcore-dev/network-operator/api/core/v1alpha1" + "github.com/ironcore-dev/network-operator/internal/provider" + "github.com/ironcore-dev/network-operator/internal/transport/gnmiext" ) func TestAsnToUint32(t *testing.T) { @@ -32,3 +36,20 @@ func TestAsnToUint32(t *testing.T) { }) } } + +func TestDeleteBGPPeer_Unnumbered(t *testing.T) { + // The mock has no functions set, so any device call panics. + p := newProviderWithClient(&gnmiext.ClientMock{}) + + err := p.DeleteBGPPeer(t.Context(), &provider.DeleteBGPPeerRequest{ + BGPPeer: &v1alpha1.BGPPeer{ + Spec: v1alpha1.BGPPeerSpec{ + InterfaceRef: &v1alpha1.LocalObjectReference{Name: "eth1-1"}, + }, + }, + PeerInterface: "ethernet-1/1", + }) + if err != nil { + t.Fatalf("DeleteBGPPeer() error = %v", err) + } +} diff --git a/internal/provider/openconfig/bgppeer.go b/internal/provider/openconfig/bgppeer.go index 79505dbd7..78ece95fb 100644 --- a/internal/provider/openconfig/bgppeer.go +++ b/internal/provider/openconfig/bgppeer.go @@ -24,6 +24,13 @@ const bgpPeerGroupName = "NETOP-DEFAULT" func (p *Provider) EnsureBGPPeer(ctx context.Context, req *provider.EnsureBGPPeerRequest) error { spec := req.BGPPeer.Spec + if spec.InterfaceRef != nil { + return apistatus.NewUnsupportedFieldError(apistatus.FieldViolation{ + Field: "spec.interfaceRef", + Description: "openconfig provider does not support unnumbered BGP peering on SRLinux", + }) + } + peerAS, err := asnToUint32(spec.ASNumber) if err != nil { return err @@ -143,6 +150,11 @@ func (p *Provider) EnsureBGPPeer(ctx context.Context, req *provider.EnsureBGPPee } func (p *Provider) DeleteBGPPeer(ctx context.Context, req *provider.DeleteBGPPeerRequest) error { + // Unnumbered peers are rejected by EnsureBGPPeer, so there is nothing to delete. + if req.BGPPeer.Spec.InterfaceRef != nil { + return nil + } + ni := DefaultNetworkInstance if req.VRF != nil { ni = req.VRF.Spec.Name diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 64a11b3cd..db7033791 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -530,6 +530,9 @@ type EnsureBGPPeerRequest struct { BGPPeer *v1alpha1.BGPPeer ProviderConfig *ProviderConfig SourceInterface string + // PeerInterface is the device-level name of the interface an unnumbered + // (interface-based) peer is reachable over. Empty for peers with an address. + PeerInterface string // BGP is the resolved BGP instance referenced by BGPPeer.Spec.BgpRef. BGP *v1alpha1.BGP // VRF is the resolved VRF referenced by BGP.Spec.VrfRef. @@ -546,6 +549,9 @@ type EnsureBGPPeerRequest struct { type DeleteBGPPeerRequest struct { BGPPeer *v1alpha1.BGPPeer ProviderConfig *ProviderConfig + // PeerInterface is the device-level name of the interface an unnumbered + // (interface-based) peer is reachable over. Empty for peers with an address. + PeerInterface string // BGP is the resolved BGP instance referenced by BGPPeer.Spec.BgpRef. BGP *v1alpha1.BGP // VRF is the resolved VRF referenced by BGP.Spec.VrfRef. @@ -556,6 +562,9 @@ type DeleteBGPPeerRequest struct { type BGPPeerStatusRequest struct { BGPPeer *v1alpha1.BGPPeer ProviderConfig *ProviderConfig + // PeerInterface is the device-level name of the interface an unnumbered + // (interface-based) peer is reachable over. Empty for peers with an address. + PeerInterface string // VRF is the resolved VRF referenced by the BGP instance of this peer. // When nil, the provider shall use the default VRF. VRF *v1alpha1.VRF diff --git a/internal/webhook/core/v1alpha1/bgppeer_webhook.go b/internal/webhook/core/v1alpha1/bgppeer_webhook.go index 93576b747..09ed071df 100644 --- a/internal/webhook/core/v1alpha1/bgppeer_webhook.go +++ b/internal/webhook/core/v1alpha1/bgppeer_webhook.go @@ -5,6 +5,8 @@ package v1alpha1 import ( "context" + "errors" + "fmt" ctrl "sigs.k8s.io/controller-runtime" logf "sigs.k8s.io/controller-runtime/pkg/log" @@ -49,7 +51,21 @@ func (v *BGPPeerCustomValidator) ValidateDelete(_ context.Context, _ *v1alpha1.B } func validateBGPPeer(bgppeer v1alpha1.BGPPeerSpec) error { - if err := validateASNumber(bgppeer.ASNumber); err != nil { + if (bgppeer.Address == "") == (bgppeer.InterfaceRef == nil) { + return errors.New("exactly one of address or interfaceRef must be specified") + } + + if bgppeer.InterfaceRef != nil && bgppeer.LocalAddress != nil { + return errors.New("localAddress must not be specified for interface-based peers") + } + + // A peer with a dynamic AS number accepts any AS number that differs from the local + // one, which is only meaningful for unnumbered, interface-based peers. + if bgppeer.IsExternalASNumber() { + if bgppeer.InterfaceRef == nil { + return fmt.Errorf("AS number %q requires interfaceRef", v1alpha1.BGPPeerASNumberExternal) + } + } else if err := validateASNumber(bgppeer.ASNumber); err != nil { return err } diff --git a/internal/webhook/core/v1alpha1/bgppeer_webhook_test.go b/internal/webhook/core/v1alpha1/bgppeer_webhook_test.go index 8240eb37a..9a01057b8 100644 --- a/internal/webhook/core/v1alpha1/bgppeer_webhook_test.go +++ b/internal/webhook/core/v1alpha1/bgppeer_webhook_test.go @@ -36,6 +36,61 @@ var _ = Describe("BGPPeer Webhook", func() { Expect(obj).NotTo(BeNil(), "Expected obj to be initialized") }) + Context("When creating an unnumbered BGPPeer", func() { + It("Should admit an interface-based peer with a dynamic AS number", func() { + obj.Spec.Address = "" + obj.Spec.InterfaceRef = &v1alpha1.LocalObjectReference{Name: "eth1-1"} + obj.Spec.ASNumber = intstr.FromString(v1alpha1.BGPPeerASNumberExternal) + + _, err := validator.ValidateCreate(ctx, obj) + Expect(err).NotTo(HaveOccurred()) + }) + + It("Should admit an interface-based peer with an explicit AS number", func() { + obj.Spec.Address = "" + obj.Spec.InterfaceRef = &v1alpha1.LocalObjectReference{Name: "eth1-1"} + obj.Spec.ASNumber = intstr.FromInt32(65020) + + _, err := validator.ValidateCreate(ctx, obj) + Expect(err).NotTo(HaveOccurred()) + }) + + It("Should deny both address and interfaceRef", func() { + obj.Spec.InterfaceRef = &v1alpha1.LocalObjectReference{Name: "eth1-1"} + obj.Spec.ASNumber = intstr.FromInt32(65001) + + _, err := validator.ValidateCreate(ctx, obj) + Expect(err).To(MatchError(ContainSubstring("exactly one of address or interfaceRef"))) + }) + + It("Should deny neither address nor interfaceRef", func() { + obj.Spec.Address = "" + obj.Spec.ASNumber = intstr.FromInt32(65001) + + _, err := validator.ValidateCreate(ctx, obj) + Expect(err).To(MatchError(ContainSubstring("exactly one of address or interfaceRef"))) + }) + + It("Should deny localAddress on an interface-based peer", func() { + obj.Spec.Address = "" + obj.Spec.InterfaceRef = &v1alpha1.LocalObjectReference{Name: "eth1-1"} + obj.Spec.ASNumber = intstr.FromString(v1alpha1.BGPPeerASNumberExternal) + obj.Spec.LocalAddress = &v1alpha1.BGPPeerLocalAddress{ + InterfaceRef: v1alpha1.LocalObjectReference{Name: "lo0"}, + } + + _, err := validator.ValidateCreate(ctx, obj) + Expect(err).To(MatchError(ContainSubstring("localAddress must not be specified"))) + }) + + It("Should deny a dynamic AS number without interfaceRef", func() { + obj.Spec.ASNumber = intstr.FromString(v1alpha1.BGPPeerASNumberExternal) + + _, err := validator.ValidateCreate(ctx, obj) + Expect(err).To(MatchError(ContainSubstring("requires interfaceRef"))) + }) + }) + Context("When creating BGPPeer under Validating Webhook", func() { It("Should admit creation with valid integer AS number", func() { obj.Spec.ASNumber = intstr.FromInt32(65001) diff --git a/test/gnmi/testdata/nx.cisco.networking.metal.ironcore.dev/bgp_peer_unnumbered.txtar b/test/gnmi/testdata/nx.cisco.networking.metal.ironcore.dev/bgp_peer_unnumbered.txtar new file mode 100644 index 000000000..4cf5314f9 --- /dev/null +++ b/test/gnmi/testdata/nx.cisco.networking.metal.ironcore.dev/bgp_peer_unnumbered.txtar @@ -0,0 +1,206 @@ +-- interfaces/uplink -- +apiVersion: networking.metal.ironcore.dev/v1alpha1 +kind: Interface +metadata: + name: uplink + namespace: default +spec: + deviceRef: + name: device + name: eth1/1 + adminState: Up + type: Physical + ipv6: + useLinkLocalOnly: true + +-- bgps/fabric-bgp -- +apiVersion: networking.metal.ironcore.dev/v1alpha1 +kind: BGP +metadata: + name: fabric-bgp + namespace: default +spec: + deviceRef: + name: device + asNumber: 65010 + routerId: "10.0.0.1" + +-- bgppeers/spine1 -- +apiVersion: networking.metal.ironcore.dev/v1alpha1 +kind: BGPPeer +metadata: + name: spine1 + namespace: default +spec: + deviceRef: + name: device + bgpRef: + name: fabric-bgp + interfaceRef: + name: uplink + asNumber: external + description: "Unnumbered peering with spine1" + addressFamilies: + ipv4Unicast: + enabled: true + +-- state/preload -- +{ + "System": { + "procsys-items": { + "bootTime": "1700000000" + } + } +} + +-- state/expect -- + +{ + "System": { + "procsys-items": { + "bootTime": "1700000000" + }, + "intf-items": { + "phys-items": { + "PhysIf-list": [ + { + "accessVlan": "unknown", + "adminSt": "up", + "descr": "DME_UNSET_PROPERTY_MARKER", + "FECMode": "auto", + "id": "eth1/1", + "layer": "Layer3", + "mtu": 1500, + "medium": "broadcast", + "mode": "access", + "nativeVlan": "unknown", + "userCfgdFlags": "admin_layer,admin_state", + "rtvrfMbr-items": { + "tDn": "/System/inst-items/Inst-list[name='default']" + }, + "physExtd-items": { + "bufferBoost": "enable" + } + } + ] + } + }, + "ipv6-items": { + "inst-items": { + "dom-items": { + "Dom-list": [ + { + "name": "default", + "if-items": { + "If-list": [ + { + "id": "eth1/1", + "useLinkLocalAddr": "enabled" + } + ] + } + } + ] + } + } + }, + "fm-items": { + "bgp-items": { + "adminSt": "enabled" + }, + "evpn-items": { + "adminSt": "enabled" + } + }, + "bgp-items": { + "inst-items": { + "adminSt": "enabled", + "asn": "65010", + "dom-items": { + "Dom-list": [ + { + "name": "default", + "rtrId": "10.0.0.1", + "rtrIdAuto": "disabled", + "peerif-items": { + "PeerIf-list": [ + { + "id": "eth1/1", + "adminSt": "enabled", + "asnType": "external", + "name": "Unnumbered peering with spine1", + "af-items": { + "PeerAf-list": [ + { + "ctrl": "DME_UNSET_PROPERTY_MARKER", + "sendComExt": "disabled", + "sendComStd": "disabled", + "type": "ipv4-ucast" + } + ] + } + } + ] + } + } + ] + } + } + } + } +} + +-- state/delete -- + +{ + "System": { + "procsys-items": { + "bootTime": "1700000000" + }, + "intf-items": { + "phys-items": { + "PhysIf-list": [ + { + "accessVlan": "vlan-1", + "descr": "DME_UNSET_PROPERTY_MARKER", + "FECMode": "auto", + "id": "eth1/1", + "layer": "Layer2", + "mtu": 1500, + "medium": "broadcast", + "mode": "access", + "nativeVlan": "vlan-1", + "userCfgdFlags": "", + "physExtd-items": { + "bufferBoost": "enable" + }, + "trunkVlans": "1-4094" + } + ] + } + }, + "ipv6-items": { + "inst-items": { + "dom-items": { + "Dom-list": [ + { + "name": "default", + "if-items": { + "If-list": [] + } + } + ] + } + } + }, + "fm-items": { + "bgp-items": { + "adminSt": "enabled" + }, + "evpn-items": { + "adminSt": "enabled" + } + }, + "bgp-items": {} + } +}