Skip to content

Commit e77520b

Browse files
committed
cisco-nxos-provider: enable vPC
This commit enables configuring Virtual Port Channels (`vPC`s) on Cisco NXOS devices. It supports the following configuration (and other features not show here): ``` feature vpc vpc domain 1 peer-switch peer-keepalive destination 10.201.182.26 source 10.201.182.25 peer-gateway interface port-channel10 vpc peer-link interface port-channel20 vpc 20 ``` which can be achieved with: ``` vpcCfg, err := vpc.NewVPC( 1, vpc.WithPeerLink(vpc.PeerLinkConfig{ PortChannel: "po10", KeepAliveDstIP: "10.201.182.26", KeepAliveSrcIP: "10.201.182.25", }), vpc.WithMembers([]vpc.Member{ {PortChannel: "po20", VPCID: 20}, }), ) ```
1 parent bb66d02 commit e77520b

File tree

2 files changed

+818
-0
lines changed

2 files changed

+818
-0
lines changed
Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and IronCore contributors
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
// The vpc package provides functionality to configure Virtual Port Channels (vPC) on Cisco NX-OS devices.
5+
//
6+
// References:
7+
// - [Cisco vPC] https://www.cisco.com/c/en/us/td/docs/dcn/nx-os/nexus9000/104x/configuration/interfaces/cisco-nexus-9000-series-nx-os-interfaces-configuration-guide-release-104x/m_configuring_vpcs_9x.html
8+
package vpc
9+
10+
import (
11+
"context"
12+
"errors"
13+
"fmt"
14+
"net/netip"
15+
16+
"github.com/openconfig/ygot/ygot"
17+
18+
nxos "github.com/ironcore-dev/network-operator/internal/provider/cisco/nxos/genyang"
19+
"github.com/ironcore-dev/network-operator/internal/provider/cisco/nxos/gnmiext"
20+
"github.com/ironcore-dev/network-operator/internal/provider/cisco/nxos/iface"
21+
)
22+
23+
var (
24+
// Operation error: for failed actions due to underlying errors, i.e., gNMI Get.
25+
ErrVPCOperation = func(field string, err error) error {
26+
return fmt.Errorf("vpc: failed to get %s from remote device: %w", field, err)
27+
}
28+
// Resource error: for missing resources or invalid resource state
29+
ErrVPCResource = func(reason string) error {
30+
return fmt.Errorf("vpc: condition failed: %s", reason)
31+
}
32+
// Validation error: for invalid input parameters
33+
ErrVPCValidation = func(field string, reason error) error {
34+
return fmt.Errorf("vpc: invalid %s: %w", field, reason)
35+
}
36+
)
37+
38+
// VPC represents a Virtual Port Channel (vPC). New instances must be created using the `NewVPC()` function .
39+
type VPC struct {
40+
domainID uint16
41+
peerLinkPortChannel string
42+
keepaliveDstIP *netip.Addr
43+
keepaliveSrcIP *netip.Addr
44+
keepaliveVRF string
45+
members map[string]memberInfo
46+
peerSwitch bool
47+
peerGateway bool
48+
}
49+
50+
type memberInfo struct {
51+
VPCID uint16
52+
}
53+
54+
type Option func(*VPC) error
55+
56+
// NewVPC creates a new VPC instance with the given domain ID and options. The domain ID must be between 1 and 1000.
57+
func NewVPC(domainID int, opts ...Option) (*VPC, error) {
58+
if domainID < 1 || domainID > 1000 {
59+
return nil, ErrVPCValidation("domain ID", errors.New("must be between 1 and 1000"))
60+
}
61+
v := &VPC{
62+
domainID: uint16(domainID),
63+
members: make(map[string]memberInfo),
64+
}
65+
for _, opt := range opts {
66+
if err := opt(v); err != nil {
67+
return nil, err
68+
}
69+
}
70+
// Check that peerLinkPortChannel is not included in members
71+
if v.peerLinkPortChannel != "" {
72+
if _, exists := v.members[v.peerLinkPortChannel]; exists {
73+
return nil, ErrVPCValidation("peer-link port-channel", errors.New("port-channel is included in the vPC members list"))
74+
}
75+
}
76+
return v, nil
77+
}
78+
79+
type PeerLinkConfig struct {
80+
// peerLink is the name of port-channel to be used as the vPC peer-link. It must be configured
81+
// as a trunk port-channel interface.
82+
PortChannel string
83+
// KeepAliveDstIP must be a valid IPv4 or IPv6 address to use as destination for the peer-keepalive link.
84+
KeepAliveDstIP string
85+
// KeepAliveSrcIP must be a valid IP address for the peer-keepalive link source. Must use the same version as the destination IP.
86+
// If nil, the device will use its default.
87+
KeepAliveSrcIP *string
88+
// KeepAliveVRF is the VRF to be used for the peer-keepalive link. If nil, the device will use the default VRF.
89+
KeepAliveVRF *string
90+
}
91+
92+
// WithPeerLink configures the vPC peer-link and the peer-keepalive configuration.
93+
func WithPeerLink(cfg PeerLinkConfig) Option {
94+
return func(v *VPC) error {
95+
errs := []error{}
96+
shortName, err := iface.ShortNamePortChannel(cfg.PortChannel)
97+
if err != nil {
98+
errs = append(errs, ErrVPCValidation("peer-link port-channel name", err))
99+
} else {
100+
v.peerLinkPortChannel = shortName
101+
}
102+
103+
dst, err := netip.ParseAddr(cfg.KeepAliveDstIP)
104+
switch {
105+
case err != nil:
106+
errs = append(errs, ErrVPCValidation("keep-alive link destination IP address", err))
107+
case !dst.IsValid():
108+
errs = append(errs, ErrVPCValidation("keep-alive link destination IP address", errors.New("must be a valid IPv4 or IPv6 address")))
109+
default:
110+
v.keepaliveDstIP = &dst
111+
}
112+
113+
if cfg.KeepAliveSrcIP != nil && v.keepaliveDstIP != nil && v.keepaliveDstIP.IsValid() {
114+
src, err := netip.ParseAddr(*cfg.KeepAliveSrcIP)
115+
switch {
116+
case err != nil:
117+
errs = append(errs, ErrVPCValidation("keep-alive link source IP address", err))
118+
case !src.IsValid():
119+
errs = append(errs, ErrVPCValidation("keep-alive link source IP address", errors.New("must be a valid IPv4 or IPv6 address")))
120+
default:
121+
v.keepaliveSrcIP = &src
122+
}
123+
}
124+
125+
if cfg.KeepAliveVRF != nil {
126+
if *cfg.KeepAliveVRF == "" {
127+
errs = append(errs, ErrVPCValidation("keep-alive link VRF", errors.New("VRF cannot be empty")))
128+
} else {
129+
v.keepaliveVRF = *cfg.KeepAliveVRF
130+
}
131+
}
132+
133+
if len(errs) > 0 {
134+
return errors.Join(errs...)
135+
}
136+
return nil
137+
}
138+
}
139+
140+
type Member struct {
141+
PortChannel string
142+
VPCID uint16
143+
}
144+
145+
// WithMembers configures the members of this vPC. The list must contain at least one member. Each port-channel
146+
// in the provided list must have a valid name and vPC ID (between 1 and 4096). Duplicate vPC IDs are not allowed.
147+
func WithMembers(members []Member) Option {
148+
return func(v *VPC) error {
149+
if len(members) == 0 {
150+
return ErrVPCValidation("members list", errors.New("members list cannot be empty"))
151+
}
152+
153+
v.members = make(map[string]memberInfo, len(members))
154+
seenVPCIDs := make(map[uint16]struct{}, len(members))
155+
156+
errs := []error{}
157+
for _, pc := range members {
158+
shortName, err := iface.ShortNamePortChannel(pc.PortChannel)
159+
if err != nil {
160+
errs = append(errs, ErrVPCValidation("member port-channel name", err))
161+
continue
162+
}
163+
164+
if _, exists := v.members[shortName]; exists {
165+
errs = append(errs, ErrVPCValidation("member port-channel", fmt.Errorf("port-channel %q is used more than once", shortName)))
166+
continue
167+
}
168+
169+
if pc.VPCID < 1 || pc.VPCID > 4096 {
170+
errs = append(errs, ErrVPCValidation("member vPC ID", errors.New("must be between 1 and 4096")))
171+
continue
172+
}
173+
174+
if _, exists := seenVPCIDs[pc.VPCID]; exists {
175+
errs = append(errs, ErrVPCValidation("member vPC ID", fmt.Errorf("vPC ID %d is used more than once", pc.VPCID)))
176+
continue
177+
}
178+
seenVPCIDs[pc.VPCID] = struct{}{}
179+
180+
v.members[shortName] = memberInfo{VPCID: pc.VPCID}
181+
}
182+
if len(errs) > 0 {
183+
v.members = nil
184+
return errors.Join(errs...)
185+
}
186+
return nil
187+
}
188+
}
189+
190+
// EnablePeerSwitchFeature enables the peer-switch feature on the vPC. See [Cisco vPC] for details.
191+
func EnablePeerSwitchFeature() Option {
192+
return func(v *VPC) error {
193+
v.peerSwitch = true
194+
return nil
195+
}
196+
}
197+
198+
// EnablePeerGatewayFeature enables the peer-gateway feature on the vPC. See [Cisco vPC] for details.
199+
func EnablePeerGatewayFeature() Option {
200+
return func(v *VPC) error {
201+
v.peerGateway = true
202+
return nil
203+
}
204+
}
205+
206+
var _ gnmiext.DeviceConf = (*VPC)(nil)
207+
208+
// ToYGOT enables the vPC feature and configures the vPC domain, peer-link, and member port-channels.
209+
// It gets config from remote file to validate that the peer-link port-channel is set and configured as an L2 trunk.
210+
// If validation succeeds it will also enables the vPC feature
211+
func (v *VPC) ToYGOT(ctx context.Context, c gnmiext.Client) ([]gnmiext.Update, error) {
212+
val := &nxos.Cisco_NX_OSDevice_System_VpcItems_InstItems_DomItems{
213+
Id: ygot.Uint16(v.domainID),
214+
AdminSt: nxos.Cisco_NX_OSDevice_Nw_AdminSt_enabled,
215+
}
216+
217+
if v.peerLinkPortChannel != "" {
218+
// Check if the port-channel exists and is configured as an L2 trunk
219+
pc := &nxos.Cisco_NX_OSDevice_System_IntfItems_AggrItems_AggrIfList{}
220+
if err := c.Get(ctx, "System/intf-items/aggr-items/AggrIf-list[id="+v.peerLinkPortChannel+"]", pc); err != nil {
221+
return nil, ErrVPCOperation("port-channel '"+v.peerLinkPortChannel+"'", err)
222+
}
223+
if pc.Layer != nxos.Cisco_NX_OSDevice_L1_Layer_AggrIfLayer_Layer2 || pc.Mode != nxos.Cisco_NX_OSDevice_L1_Mode_trunk {
224+
return nil, ErrVPCResource("peer-link port-channel must be configured as an L2 trunk")
225+
}
226+
val.GetOrCreateKeepaliveItems().GetOrCreatePeerlinkItems().Id = ygot.String(v.peerLinkPortChannel)
227+
228+
// Keepalive link configuration
229+
if v.keepaliveDstIP != nil {
230+
val.GetOrCreateKeepaliveItems().DestIp = ygot.String(v.keepaliveDstIP.String())
231+
if v.keepaliveSrcIP != nil {
232+
val.GetOrCreateKeepaliveItems().SrcIp = ygot.String(v.keepaliveSrcIP.String())
233+
}
234+
if v.keepaliveVRF != "" {
235+
val.GetOrCreateKeepaliveItems().Vrf = ygot.String(v.keepaliveVRF)
236+
}
237+
}
238+
}
239+
240+
for member, info := range v.members {
241+
isConfigured, err := iface.Exists(ctx, c, member)
242+
if err != nil {
243+
return nil, ErrVPCOperation("port-channel '"+member+"'", err)
244+
}
245+
if !isConfigured {
246+
return nil, ErrVPCResource("member port-channel " + member + " does not exist on the device")
247+
}
248+
val.GetOrCreateIfItems().GetOrCreateIfList(info.VPCID).GetOrCreateRsvpcConfItems().TDn = ygot.String("/System/intf-items/aggr-items/AggrIf-list[id='" + member + "']")
249+
}
250+
251+
if v.peerSwitch {
252+
val.PeerSwitch = nxos.Cisco_NX_OSDevice_Nw_AdminSt_enabled
253+
}
254+
255+
if v.peerGateway {
256+
val.PeerGw = nxos.Cisco_NX_OSDevice_Nw_AdminSt_enabled
257+
}
258+
259+
return []gnmiext.Update{
260+
gnmiext.EditingUpdate{
261+
XPath: "System/fm-items/vpc-items",
262+
Value: &nxos.Cisco_NX_OSDevice_System_FmItems_VpcItems{
263+
AdminSt: nxos.Cisco_NX_OSDevice_Fm_AdminState_enabled,
264+
},
265+
},
266+
gnmiext.ReplacingUpdate{
267+
XPath: "System/vpc-items/inst-items/dom-items",
268+
Value: val,
269+
},
270+
}, nil
271+
}
272+
273+
func (v *VPC) Reset(_ context.Context, _ gnmiext.Client) ([]gnmiext.Update, error) {
274+
return []gnmiext.Update{
275+
gnmiext.DeletingUpdate{
276+
XPath: "System/vpc-items/inst-items/dom-items",
277+
},
278+
}, nil
279+
}

0 commit comments

Comments
 (0)