diff --git a/docs/manual/kinds/ceos.md b/docs/manual/kinds/ceos.md index 82e10ff710..203a1164dc 100644 --- a/docs/manual/kinds/ceos.md +++ b/docs/manual/kinds/ceos.md @@ -228,6 +228,75 @@ This topology will be equivalent to `ceos1:Ethernet1/1` connected to `ceos2:Ethe This feature can not be used together with interface mapping. If the interface mapping is in use, all names must be redefined in the map and the underscore naming option will not work. Also, it's only possible to rename Ethernet interfaces this way, not management ports. /// +### Management interface + +By default a ceos node uses `Management0` as its management interface, reachable over the [containerlab management network](../network.md). cEOS derives the EOS management interface name from the kernel netdev that backs it, which is controlled by the `MGMT_INTF` environment variable: + +* `MGMT_INTF=eth0` (the default) --> `Management0` +* `MGMT_INTF=ma1` --> `Management1` (and `maN` --> `ManagementN` in general) + +`Management1` is the management interface name used by vEOS and physical EOS switches. Exposing the management interface as `Management1` lets you reuse startup-configs and automation written for those devices without renaming the management interface. + +Only `eth0` (the default) and `maN` values are handled by containerlab. Any other value (for example `eth1`) is passed through to cEOS unchanged and logged with a warning, as cEOS would consume that interface as the management interface. + +/// note +The legacy `MAPETH0` environment variable has no effect on current cEOS images and is automatically omitted when `MGMT_INTF` is set to a `maN` value. The management interface is selected solely by `MGMT_INTF`. +/// + +#### Keep the management network, expose it as `Management1` + +To keep the automatic containerlab management network (and its assigned management address) but have it appear as `Management1`, set `MGMT_INTF` to `ma1`. Containerlab renames the runtime-provided `eth0` netdev to `ma1` during boot so cEOS maps it to `Management1`; the management address, default route and `ssh`/`gNMI` reachability are preserved. + +```yaml +name: ceos +topology: + kinds: + arista_ceos: + env: + MGMT_INTF: ma1 # all ceos nodes use Management1 as their mgmt interface + nodes: + ceos1: + kind: arista_ceos + image: ceos:4.32.0F + ceos2: + kind: arista_ceos + image: ceos:4.32.0F + links: + - endpoints: ["ceos1:eth1", "ceos2:eth1"] +``` + +This keeps the `eth1_1` --> `Ethernet1/1` underscore naming working, unlike the [interface mapping file](#user-defined-interface-mapping) approach. + +#### Wiring the management interface manually + +The management interface can also be wired as a regular point-to-point link to any interface on any other node, instead of being attached to the containerlab management network. This makes it behave like the management port of a physical switch: it can be cabled directly to a management host (as shown below), or to a dedicated management switch that aggregates the management ports of several nodes into an out-of-band (OOB) network. + +To do this, set [`network-mode: none`](../nodes.md#network-mode) on the node (so it is not attached to the management network and no management address is auto-assigned), set `MGMT_INTF` to the desired `maN` netdev, and add a link that uses that netdev as the ceos endpoint: + +```yaml +name: ceos_oob +topology: + nodes: + ceos: + kind: arista_ceos + image: ceos:4.32.0F + network-mode: none # opt out of the management network + env: + MGMT_INTF: ma1 # ma1 -> Management1 (use ma0 to keep Management0) + mgmt-host: + kind: linux + image: alpine:latest + links: + - endpoints: ["ceos:ma1", "mgmt-host:eth1"] # Management1 cabled directly to the management host + ipv4: ["172.31.0.1/24", "172.31.0.254/24"] # optional, applied to Management1 +``` + +With `network-mode: none` the runtime does not assign a management address, so the management interface only gets an address if the link defines `ipv4`/`ipv6` or the startup-config configures one. Use `ma0` instead of `ma1` to keep the interface as `Management0`. + +/// note +Management link endpoints must be named `maN` (e.g. `ma1`). They are only accepted when `network-mode: none` is set, and require the matching `MGMT_INTF=maN` environment variable so cEOS maps the netdev to `ManagementN`. +/// + ## Features and options ### Node configuration diff --git a/nodes/ceos/ceos.go b/nodes/ceos/ceos.go index 68f99830a5..99352c373d 100644 --- a/nodes/ceos/ceos.go +++ b/nodes/ceos/ceos.go @@ -36,6 +36,15 @@ const ( tlsKeyFile = "node.key" tlsCertFile = "node.crt" tlsCAFile = "ca.crt" + + // MGMT_INTF selects the container netdev cEOS maps to the management + // interface; MAPETH0 is a legacy companion flag kept in the default env. + mgmtIntfEnvVar = "MGMT_INTF" + mapEth0EnvVar = "MAPETH0" + // default management netdev, mapped to Management0 by cEOS. + defaultMgmtIntfNetdev = "eth0" + + networkModeNone = "none" ) var ( @@ -48,8 +57,8 @@ var ( "ETBA": "1", "SKIP_ZEROTOUCH_BARRIER_IN_SYSDBINIT": "1", "INTFTYPE": "eth", - "MAPETH0": "1", - "MGMT_INTF": "eth0", + mapEth0EnvVar: "1", + mgmtIntfEnvVar: defaultMgmtIntfNetdev, } //go:embed ceos.cfg @@ -58,6 +67,9 @@ var ( saveCmd = "Cli -p 15 -c wr" defaultCredentials = clabnodes.NewCredentials("admin", "admin") + + // maN management netdev (e.g. ma1 -> Management1). + mgmtIntfRegexp = regexp.MustCompile(`^ma([0-9]+)$`) ) // Register registers the node in the NodeRegistry. @@ -107,11 +119,39 @@ func (n *ceos) Init(cfg *clabtypes.NodeConfig, opts ...clabnodes.NodeOption) err n.Cfg.Env = clabutils.MergeStringMaps(ceosEnv, n.Cfg.Env) + // containerlab only special-cases a maN management netdev; anything else is + // passed through to cEOS unchanged. Warn about values that cEOS would map to + // an unexpected interface (e.g. MGMT_INTF=eth1 hijacks a data interface). + mgmtNetdev := n.mgmtNetdev() + if mgmtNetdev != defaultMgmtIntfNetdev && !n.mgmtRemapped() { + log.Warnf( + "node %q: MGMT_INTF=%q is not handled by containerlab; only %q (default) or maN are supported, cEOS may use an unexpected interface for management", + n.Cfg.ShortName, mgmtNetdev, defaultMgmtIntfNetdev, + ) + } + + // MGMT_INTF alone selects the management interface, so drop the legacy + // MAPETH0 flag once the mgmt netdev is remapped to maN. + if n.mgmtRemapped() { + delete(n.Cfg.Env, mapEth0EnvVar) + } + // the node.Cmd should be aligned with the environment. // prepending original Cmd with if-wait.sh script to make sure that interfaces are available // before init process starts var envSb strings.Builder - envSb.WriteString("bash -c '" + ifWaitScriptContainerPath + " ; exec /sbin/init ") + envSb.WriteString("bash -c '" + ifWaitScriptContainerPath + " ; ") + + // The runtime always names the mgmt veth eth0. When MGMT_INTF remaps the mgmt + // interface (e.g. ma1 -> Management1) rename eth0 accordingly before init. + // Only do this when attached to the runtime mgmt network: with host or + // container network modes eth0 belongs to a foreign namespace, and with + // network-mode none there is no eth0 (the netdev comes from a link). + if n.mgmtRemapped() && n.mgmtViaRuntimeNetwork() { + envSb.WriteString(renameMgmtNetdevCmd(mgmtNetdev) + " ; ") + } + + envSb.WriteString("exec /sbin/init ") for k, v := range n.Cfg.Env { envSb.WriteString("systemd.setenv=\"" + k + "=" + v + "\" ") } @@ -202,9 +242,13 @@ func (n *ceos) createCEOSFiles(ctx context.Context) error { // set mgmt ipv4 gateway as it is already known by now // since the container network has been created before we launch nodes - // and mgmt gateway can be used in ceos.Cfg template to configure default route for mgmt - nodeCfg.MgmtIPv4Gateway = n.Runtime.Mgmt().IPv4Gw - nodeCfg.MgmtIPv6Gateway = n.Runtime.Mgmt().IPv6Gw + // and mgmt gateway can be used in ceos.Cfg template to configure default route for mgmt. + // Skip it when the mgmt interface is wired manually via a link, as the node + // is not attached to the runtime mgmt network. + if !n.mgmtWiredViaLink() { + nodeCfg.MgmtIPv4Gateway = n.Runtime.Mgmt().IPv4Gw + nodeCfg.MgmtIPv6Gateway = n.Runtime.Mgmt().IPv6Gw + } // set the mgmt interface name for the node err := setMgmtInterface(nodeCfg) @@ -304,9 +348,19 @@ func (n *ceos) createCEOSCertificates() error { } func setMgmtInterface(node *clabtypes.NodeConfig) error { - // use interface mapping file to set the Management interface if it is provided in the binds - // section - // default is Management0 + mgmtNetdev := node.Env[mgmtIntfEnvVar] + if mgmtNetdev == "" { + mgmtNetdev = defaultMgmtIntfNetdev + } + + // a maN netdev (e.g. ma1) is exposed by cEOS as ManagementN + if m := mgmtIntfRegexp.FindStringSubmatch(mgmtNetdev); m != nil { + node.MgmtIntf = "Management" + m[1] + log.Debugf("Management interface for '%s' node is set to %s.", node.ShortName, node.MgmtIntf) + return nil + } + + // otherwise eth0 maps to Management0, unless an EosIntfMapping.json overrides it mgmtInterface := "Management0" for _, bindelement := range node.Binds { if !strings.Contains(bindelement, "EosIntfMapping.json") { @@ -342,46 +396,113 @@ func setMgmtInterface(node *clabtypes.NodeConfig) error { return nil } -// ceosPostDeploy runs postdeploy actions which are required for ceos nodes. -func (n *ceos) ceosPostDeploy(_ context.Context) error { - nodeCfg := n.Config() - d, err := clabutils.SpawnCLIviaExec("arista_eos", nodeCfg.LongName, n.Runtime.GetName()) - if err != nil { - return err +// mgmtNetdev returns the container netdev cEOS maps to the management interface, +// as set by MGMT_INTF (default eth0). +func (n *ceos) mgmtNetdev() string { + if d := n.Cfg.Env[mgmtIntfEnvVar]; d != "" { + return d } + return defaultMgmtIntfNetdev +} - defer d.Close() +// mgmtRemapped reports whether the management netdev is remapped to a maN netdev +// (e.g. ma1 -> Management1). This is the single switch that enables the +// management interface handling; any other MGMT_INTF value is left to cEOS. +func (n *ceos) mgmtRemapped() bool { + return mgmtIntfRegexp.MatchString(n.mgmtNetdev()) +} - cfgs := []string{ - "interface " + nodeCfg.MgmtIntf, - "no ip address", - "no ipv6 address", - } +// mgmtWiredViaLink reports whether the management interface is wired manually via +// a topology link: the netdev is remapped to maN and the node opts out of the +// runtime mgmt network with network-mode: none. +func (n *ceos) mgmtWiredViaLink() bool { + return n.mgmtRemapped() && n.Cfg.NetworkMode == networkModeNone +} + +// mgmtViaRuntimeNetwork reports whether the node uses the runtime's management +// network (the default mode), where eth0 is a runtime-created veth that can be +// safely renamed. Explicit modes (none, host, container:) either have no +// eth0 or share a foreign namespace whose eth0 must not be renamed. +func (n *ceos) mgmtViaRuntimeNetwork() bool { + return n.Cfg.NetworkMode == "" +} - // adding ipv4 address to configs +// renameMgmtNetdevCmd returns a shell snippet renaming eth0 to the netdev cEOS +// expects (e.g. ma1 -> Management1). The existence guard is defence-in-depth. +func renameMgmtNetdevCmd(netdev string) string { + return fmt.Sprintf( + "if [ -e /sys/class/net/%[1]s ]; then "+ + "ip link set %[1]s down && ip link set %[1]s name %[2]s && ip link set %[2]s up; fi", + defaultMgmtIntfNetdev, netdev, + ) +} + +// mgmtAddressFromConfig returns the runtime-assigned management IPv4 and IPv6 +// addresses in CIDR notation, or empty strings when none were assigned. +func mgmtAddressFromConfig(nodeCfg *clabtypes.NodeConfig) (v4, v6 string) { if nodeCfg.MgmtIPv4Address != "" { + v4 = fmt.Sprintf("%s/%d", nodeCfg.MgmtIPv4Address, nodeCfg.MgmtIPv4PrefixLength) + } + if nodeCfg.MgmtIPv6Address != "" { + v6 = fmt.Sprintf("%s/%d", nodeCfg.MgmtIPv6Address, nodeCfg.MgmtIPv6PrefixLength) + } + return v4, v6 +} + +// postDeployConfig builds the EOS configuration pushed after deploy: the +// management interface addressing (when an address is known) followed by the +// data interface addressing. It returns nil when there is nothing to configure, +// for example a manually wired management interface left to its startup-config. +func (n *ceos) postDeployConfig() []string { + nodeCfg := n.Config() + + var cfgs []string + + // mgmt address comes from the runtime mgmt network; when wired manually + // (network-mode: none) fall back to the maN link endpoint address, if any. + mgmtV4, mgmtV6 := mgmtAddressFromConfig(nodeCfg) + if n.mgmtRemapped() && mgmtV4 == "" && mgmtV6 == "" { + for _, e := range n.Endpoints { + if e.GetIfaceName() != n.mgmtNetdev() { + continue + } + if v4 := e.GetIPv4Addr(); v4.IsValid() { + mgmtV4 = v4.String() + } + if v6 := e.GetIPv6Addr(); v6.IsValid() { + mgmtV6 = v6.String() + } + } + } + + // the legacy path always (re)configures the mgmt interface; the remapped path + // only does so when it has an address, leaving a startup-config address intact. + if !n.mgmtRemapped() || mgmtV4 != "" || mgmtV6 != "" { cfgs = append(cfgs, - fmt.Sprintf("ip address %s/%d", nodeCfg.MgmtIPv4Address, nodeCfg.MgmtIPv4PrefixLength), + "interface "+nodeCfg.MgmtIntf, + "no ip address", + "no ipv6 address", ) + if mgmtV4 != "" { + cfgs = append(cfgs, "ip address "+mgmtV4) + } + if mgmtV6 != "" { + cfgs = append(cfgs, "ipv6 address "+mgmtV6) + } } - // adding ipv6 address to configs - if nodeCfg.MgmtIPv6Address != "" { - cfgs = append( - cfgs, - fmt.Sprintf( - "ipv6 address %s/%d", - nodeCfg.MgmtIPv6Address, - nodeCfg.MgmtIPv6PrefixLength, - ), - ) + // skip the mgmt interface in the data loop. When remapped it is a maN kernel + // netdev; otherwise the EOS name is used, which never matches a kernel + // endpoint name (legacy behaviour, effectively no skip). + skipIface := nodeCfg.MgmtIntf + if n.mgmtRemapped() { + skipIface = n.mgmtNetdev() } // configure data interfaces for _, e := range n.Endpoints { ifName := e.GetIfaceName() - // skip management interface - if ifName == nodeCfg.MgmtIntf { + if ifName == skipIface { continue } @@ -405,6 +526,27 @@ func (n *ceos) ceosPostDeploy(_ context.Context) error { } } + return cfgs +} + +// ceosPostDeploy runs postdeploy actions which are required for ceos nodes. +func (n *ceos) ceosPostDeploy(_ context.Context) error { + cfgs := n.postDeployConfig() + + // nothing to configure (e.g. manually wired mgmt without addresses) - leave + // the running/startup config untouched and skip opening a CLI session. + if len(cfgs) == 0 { + return nil + } + + nodeCfg := n.Config() + d, err := clabutils.SpawnCLIviaExec("arista_eos", nodeCfg.LongName, n.Runtime.GetName()) + if err != nil { + return err + } + + defer d.Close() + // add save to startup cmd cfgs = append(cfgs, "wr") @@ -425,14 +567,42 @@ func (n *ceos) CheckInterfaceName() error { // allow eth and et interfaces // https://regex101.com/r/umQW5Z/2 ifRe := regexp.MustCompile(`eth[1-9][\w.]*$|et[1-9][\w.]*$`) + mgmtNetdev := n.mgmtNetdev() + for _, e := range n.Endpoints { - if !ifRe.MatchString(e.GetIfaceName()) { - return fmt.Errorf( - "arista cEOS node %q has an interface named %q which doesn't match the required pattern. Interfaces should be named as ethX or etX, where X consists of alpanumerical characters", - n.Cfg.ShortName, - e.GetIfaceName(), - ) + ifName := e.GetIfaceName() + if ifRe.MatchString(ifName) { + continue } + + // a maN interface may be wired manually, but only with network-mode: none + // and only the netdev MGMT_INTF points at (e.g. ma1 -> Management1) + if mgmtIntfRegexp.MatchString(ifName) { + switch { + case n.Cfg.NetworkMode != networkModeNone: + return fmt.Errorf( + "arista cEOS node %q wires management interface %q via a link, which requires network-mode: none", + n.Cfg.ShortName, + ifName, + ) + case ifName != mgmtNetdev: + return fmt.Errorf( + "arista cEOS node %q wires management interface %q but its MGMT_INTF env is %q; the link endpoint name must match MGMT_INTF (e.g. set MGMT_INTF=%s)", + n.Cfg.ShortName, + ifName, + mgmtNetdev, + ifName, + ) + default: + continue + } + } + + return fmt.Errorf( + "arista cEOS node %q has an interface named %q which doesn't match the required pattern. Interfaces should be named as ethX or etX, where X consists of alpanumerical characters", + n.Cfg.ShortName, + ifName, + ) } return nil diff --git a/nodes/ceos/ceos_test.go b/nodes/ceos/ceos_test.go new file mode 100644 index 0000000000..ea007f018d --- /dev/null +++ b/nodes/ceos/ceos_test.go @@ -0,0 +1,392 @@ +// Copyright 2020 Nokia +// Licensed under the BSD 3-Clause License. +// SPDX-License-Identifier: BSD-3-Clause + +package ceos + +import ( + "net/netip" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + clablinks "github.com/srl-labs/containerlab/links" + clabnodes "github.com/srl-labs/containerlab/nodes" + clabtypes "github.com/srl-labs/containerlab/types" +) + +// TestCEOSManagementInterfaceResolution checks that the EOS management interface +// name is derived from the MGMT_INTF env var. +func TestCEOSManagementInterfaceResolution(t *testing.T) { + tests := map[string]struct { + env map[string]string + wantIntf string + }{ + "default-is-management0": { + env: nil, + wantIntf: "Management0", + }, + "explicit-eth0-is-management0": { + env: map[string]string{mgmtIntfEnvVar: "eth0"}, + wantIntf: "Management0", + }, + "ma1-is-management1": { + env: map[string]string{mgmtIntfEnvVar: "ma1"}, + wantIntf: "Management1", + }, + "ma0-is-management0": { + env: map[string]string{mgmtIntfEnvVar: "ma0"}, + wantIntf: "Management0", + }, + "ma5-is-management5": { + env: map[string]string{mgmtIntfEnvVar: "ma5"}, + wantIntf: "Management5", + }, + "eth1-is-management0-legacy": { + env: map[string]string{mgmtIntfEnvVar: "eth1"}, + wantIntf: "Management0", + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + node := &clabtypes.NodeConfig{ + ShortName: "ceos", + Env: tc.env, + } + + if err := setMgmtInterface(node); err != nil { + t.Fatalf("setMgmtInterface returned error: %v", err) + } + + if node.MgmtIntf != tc.wantIntf { + t.Errorf("got MgmtIntf %q, want %q", node.MgmtIntf, tc.wantIntf) + } + }) + } +} + +// TestCEOSInitManagementEnv checks that MAPETH0 is dropped when MGMT_INTF is +// remapped and that eth0 is renamed only when attached to the mgmt network. +func TestCEOSInitManagementEnv(t *testing.T) { + tests := map[string]struct { + env map[string]string + networkMode string + wantMapEth0 bool + wantMgmtIntf string + wantRename string // substring expected in the boot cmd, empty if none + }{ + "default-keeps-mapeth0-no-rename": { + env: nil, + wantMapEth0: true, + wantMgmtIntf: "eth0", + wantRename: "", + }, + "eth1-keeps-mapeth0-no-rename-legacy": { + env: map[string]string{mgmtIntfEnvVar: "eth1"}, + wantMapEth0: true, + wantMgmtIntf: "eth1", + wantRename: "", // not maN: passed through unchanged + }, + "ma1-drops-mapeth0-and-renames": { + env: map[string]string{mgmtIntfEnvVar: "ma1"}, + wantMapEth0: false, + wantMgmtIntf: "ma1", + wantRename: "ip link set eth0 name ma1", + }, + "ma1-with-network-mode-none-does-not-rename": { + env: map[string]string{mgmtIntfEnvVar: "ma1"}, + networkMode: networkModeNone, + wantMapEth0: false, + wantMgmtIntf: "ma1", + wantRename: "", // no eth0; netdev provided by a link + }, + "ma1-with-network-mode-host-does-not-rename": { + env: map[string]string{mgmtIntfEnvVar: "ma1"}, + networkMode: "host", + wantMapEth0: false, + wantMgmtIntf: "ma1", + wantRename: "", // must never rename a shared/host eth0 + }, + "ma1-with-network-mode-container-does-not-rename": { + env: map[string]string{mgmtIntfEnvVar: "ma1"}, + networkMode: "container:other", + wantMapEth0: false, + wantMgmtIntf: "ma1", + wantRename: "", // must never rename a peer container's eth0 + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + n := &ceos{} + cfg := &clabtypes.NodeConfig{ + ShortName: "ceos", + LongName: "clab-test-ceos", + Env: tc.env, + NetworkMode: tc.networkMode, + Certificate: &clabtypes.CertificateConfig{}, + } + + if err := n.Init(cfg); err != nil { + t.Fatalf("Init returned error: %v", err) + } + + if _, ok := n.Cfg.Env[mapEth0EnvVar]; ok != tc.wantMapEth0 { + t.Errorf("MAPETH0 present=%v, want %v", ok, tc.wantMapEth0) + } + + if got := n.Cfg.Env[mgmtIntfEnvVar]; got != tc.wantMgmtIntf { + t.Errorf("MGMT_INTF=%q, want %q", got, tc.wantMgmtIntf) + } + + switch { + case tc.wantRename == "" && strings.Contains(n.Cfg.Cmd, "ip link set eth0 name"): + t.Errorf("boot cmd unexpectedly renames eth0: %q", n.Cfg.Cmd) + case tc.wantRename != "" && !strings.Contains(n.Cfg.Cmd, tc.wantRename): + t.Errorf("boot cmd missing %q, got: %q", tc.wantRename, n.Cfg.Cmd) + } + }) + } +} + +// TestCEOSCheckInterfaceName checks data interface validation and that a maN +// mgmt interface is only accepted with network-mode none and a matching MGMT_INTF. +func TestCEOSCheckInterfaceName(t *testing.T) { + tests := map[string]struct { + ifaceNames []string + networkMode string + env map[string]string + wantErr bool + }{ + "data-eth-ok": { + ifaceNames: []string{"eth1", "eth2"}, + wantErr: false, + }, + "data-et-ok": { + ifaceNames: []string{"et1", "et10"}, + wantErr: false, + }, + "eth0-rejected": { + ifaceNames: []string{"eth0"}, + wantErr: true, + }, + "ma1-requires-network-mode-none": { + ifaceNames: []string{"ma1"}, + env: map[string]string{mgmtIntfEnvVar: "ma1"}, + wantErr: true, + }, + "ma1-ok-with-network-mode-none-and-matching-env": { + ifaceNames: []string{"ma1"}, + networkMode: networkModeNone, + env: map[string]string{mgmtIntfEnvVar: "ma1"}, + wantErr: false, + }, + "ma0-ok-with-matching-env": { + ifaceNames: []string{"ma0"}, + networkMode: networkModeNone, + env: map[string]string{mgmtIntfEnvVar: "ma0"}, + wantErr: false, + }, + "ma1-and-data-ok": { + ifaceNames: []string{"ma1", "eth1"}, + networkMode: networkModeNone, + env: map[string]string{mgmtIntfEnvVar: "ma1"}, + wantErr: false, + }, + "ma1-mismatched-env-rejected": { + ifaceNames: []string{"ma1"}, + networkMode: networkModeNone, + env: map[string]string{mgmtIntfEnvVar: "ma2"}, + wantErr: true, + }, + "ma1-without-mgmt-intf-env-rejected": { + ifaceNames: []string{"ma1"}, + networkMode: networkModeNone, + wantErr: true, + }, + "unknown-name-rejected": { + ifaceNames: []string{"foo0"}, + wantErr: true, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + n := &ceos{ + DefaultNode: clabnodes.DefaultNode{ + Cfg: &clabtypes.NodeConfig{ + ShortName: "ceos", + NetworkMode: tc.networkMode, + Env: tc.env, + }, + }, + } + n.OverwriteNode = n + + for _, ifName := range tc.ifaceNames { + ep := &clablinks.EndpointVeth{ + EndpointGeneric: clablinks.EndpointGeneric{IfaceName: ifName}, + } + if err := n.AddEndpoint(ep); err != nil { + t.Fatalf("AddEndpoint(%q) returned error: %v", ifName, err) + } + } + + err := n.CheckInterfaceName() + if tc.wantErr && err == nil { + t.Errorf("expected an error, got nil") + } + if !tc.wantErr && err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + } +} + +// TestCEOSPostDeployConfig checks the EOS configuration generated after deploy +// for the legacy, remapped (runtime mgmt network) and manually wired management +// cases, including the no-op case where nothing should be configured. +func TestCEOSPostDeployConfig(t *testing.T) { + endpoint := func(name, v4, v6 string) *clablinks.EndpointVeth { + ep := &clablinks.EndpointVeth{ + EndpointGeneric: clablinks.EndpointGeneric{IfaceName: name}, + } + if v4 != "" { + ep.IPv4 = netip.MustParsePrefix(v4) + } + if v6 != "" { + ep.IPv6 = netip.MustParsePrefix(v6) + } + return ep + } + + tests := map[string]struct { + cfg *clabtypes.NodeConfig + endpoints []*clablinks.EndpointVeth + want []string + }{ + "legacy-mgmt-ipv4": { + cfg: &clabtypes.NodeConfig{ + ShortName: "ceos", + MgmtIntf: "Management0", + MgmtIPv4Address: "172.20.20.2", + MgmtIPv4PrefixLength: 24, + }, + want: []string{ + "interface Management0", + "no ip address", + "no ipv6 address", + "ip address 172.20.20.2/24", + }, + }, + "legacy-mgmt-dualstack-with-data": { + cfg: &clabtypes.NodeConfig{ + ShortName: "ceos", + MgmtIntf: "Management0", + MgmtIPv4Address: "172.20.20.2", + MgmtIPv4PrefixLength: 24, + MgmtIPv6Address: "2001:db8::2", + MgmtIPv6PrefixLength: 64, + }, + endpoints: []*clablinks.EndpointVeth{endpoint("eth1", "10.0.0.1/30", "")}, + want: []string{ + "interface Management0", + "no ip address", + "no ipv6 address", + "ip address 172.20.20.2/24", + "ipv6 address 2001:db8::2/64", + "interface eth1", + "no switchport", + "no ip address", + "no ipv6 address", + "ip address 10.0.0.1/30", + }, + }, + "remapped-runtime-network-with-data": { + cfg: &clabtypes.NodeConfig{ + ShortName: "ceos", + Env: map[string]string{mgmtIntfEnvVar: "ma1"}, + MgmtIntf: "Management1", + MgmtIPv4Address: "172.20.20.2", + MgmtIPv4PrefixLength: 24, + }, + endpoints: []*clablinks.EndpointVeth{endpoint("eth1", "10.0.0.1/30", "")}, + want: []string{ + "interface Management1", + "no ip address", + "no ipv6 address", + "ip address 172.20.20.2/24", + "interface eth1", + "no switchport", + "no ip address", + "no ipv6 address", + "ip address 10.0.0.1/30", + }, + }, + "manually-wired-uses-endpoint-address": { + cfg: &clabtypes.NodeConfig{ + ShortName: "ceos", + Env: map[string]string{mgmtIntfEnvVar: "ma1"}, + NetworkMode: networkModeNone, + MgmtIntf: "Management1", + }, + endpoints: []*clablinks.EndpointVeth{endpoint("ma1", "172.31.0.1/24", "")}, + want: []string{ + "interface Management1", + "no ip address", + "no ipv6 address", + "ip address 172.31.0.1/24", + }, + }, + "manually-wired-no-address-is-noop": { + cfg: &clabtypes.NodeConfig{ + ShortName: "ceos", + Env: map[string]string{mgmtIntfEnvVar: "ma1"}, + NetworkMode: networkModeNone, + MgmtIntf: "Management1", + }, + endpoints: []*clablinks.EndpointVeth{endpoint("ma1", "", "")}, + want: nil, + }, + "manually-wired-no-mgmt-address-only-data": { + cfg: &clabtypes.NodeConfig{ + ShortName: "ceos", + Env: map[string]string{mgmtIntfEnvVar: "ma1"}, + NetworkMode: networkModeNone, + MgmtIntf: "Management1", + }, + endpoints: []*clablinks.EndpointVeth{ + endpoint("ma1", "", ""), + endpoint("eth1", "10.0.0.1/30", ""), + }, + want: []string{ + "interface eth1", + "no switchport", + "no ip address", + "no ipv6 address", + "ip address 10.0.0.1/30", + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + n := &ceos{ + DefaultNode: clabnodes.DefaultNode{Cfg: tc.cfg}, + } + n.OverwriteNode = n + + for _, ep := range tc.endpoints { + if err := n.AddEndpoint(ep); err != nil { + t.Fatalf("AddEndpoint(%q) returned error: %v", ep.GetIfaceName(), err) + } + } + + got := n.postDeployConfig() + if diff := cmp.Diff(tc.want, got); diff != "" { + t.Errorf("postDeployConfig() mismatch (-want +got):\n%s", diff) + } + }) + } +} diff --git a/utils/if-wait.sh b/utils/if-wait.sh index 77bede5a42..1fd50f6083 100644 --- a/utils/if-wait.sh +++ b/utils/if-wait.sh @@ -19,7 +19,7 @@ int_calc() { fi # More comprehensive interface pattern including common container interfaces - AVAIL_INTFS_NUM=$(ls -1 /sys/class/net/ 2>/dev/null | grep -cE '^(eth[1-9]|et[0-9]|ens|eno|enp|e[1-9]|net[0-9])') + AVAIL_INTFS_NUM=$(ls -1 /sys/class/net/ 2>/dev/null | grep -cE '^(eth[1-9]|et[0-9]|ens|eno|enp|e[1-9]|net[0-9]|ma[0-9])') return 0 }