|
| 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