diff --git a/constants/env.go b/constants/env.go index 5cc84446b7..a728b0094f 100644 --- a/constants/env.go +++ b/constants/env.go @@ -5,5 +5,10 @@ const ( // container. ClabEnvIntfs = "CLAB_INTFS" + // ClabEnvWaitEth0 when set to "1" makes if-wait.sh also wait for the eth0 + // netdev before init, used when eth0 is wired as a link rather than provided + // by the container runtime. + ClabEnvWaitEth0 = "CLAB_WAIT_ETH0" + ClabEnvNornirPlatformNameSchema = "CLAB_NORNIR_PLATFORM_NAME_SCHEMA" ) diff --git a/docs/manual/kinds/ceos.md b/docs/manual/kinds/ceos.md index 03e995420a..deadde11f0 100644 --- a/docs/manual/kinds/ceos.md +++ b/docs/manual/kinds/ceos.md @@ -231,6 +231,64 @@ 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. /// +### Wiring the management interface + +By default `eth0` is the management interface (`Management0`, unless remapped by an interface-mapping file), attached to the [containerlab management network](../network.md) and addressed automatically by the runtime. + +`eth0` can instead be wired as a regular point-to-point link to an interface on another node, modelling the management port of a physical switch. It can be cabled directly to a management host, or to a dedicated management switch that aggregates the management ports of several nodes into an out-of-band (OOB) network. This is also useful for testing zero-touch provisioning (ZTP), where the management interface must come up unaddressed and obtain its configuration over the wire. + +To wire `eth0`, set [`network-mode: none`](../nodes.md#network-mode) on the node so it is detached from the management network, and add a link that uses `eth0` as the ceos endpoint: + +```yaml +name: ceos-oob +topology: + nodes: + ceos1: + kind: arista_ceos + image: ceos:4.32.0F + network-mode: none + mgmtsw: + kind: linux + image: alpine:latest + links: + - endpoints: ["ceos1:eth0", "mgmtsw:eth1"] +``` + +When wired this way: + +* No management address or default route is assigned automatically. The management interface (`Management0` by default) comes up addressed only if the link provides an address (via [link addressing](#link-addressing)) or the [startup-config](#user-defined-config) configures one. +* `eth0` may only be used as a link endpoint when `network-mode: none` is set. In the default mode `eth0` is the runtime-provided management interface and cannot be wired. + +/// note +With `network-mode: none` the node is not attached to the containerlab management network, so it is not reachable on the management subnet and its address is not shown in `containerlab inspect`. Reach it over the wired link or through another node. +/// + +/// warning +Wiring the management interface relies on `network-mode: none`, which is currently only supported with the Docker runtime. +/// + +#### Zero-touch provisioning (ZTP) + +Wiring the management interface is what makes realistic ZTP testing possible, but it is not sufficient on its own: containerlab always renders a startup-config from its template (hostname, management APIs, etc.), and the presence of a startup-config disables ZTP. To boot a node with no configuration so it actually zero-touch provisions, also set [`suppress-startup-config: true`](../nodes.md#suppress-startup-config): + +```yaml +name: ceos-ztp +topology: + nodes: + ceos1: + kind: arista_ceos + image: ceos:4.32.0F + network-mode: none + suppress-startup-config: true # no startup-config -> node boots into ZTP + mgmtsw: + kind: arista_ceos + image: ceos:4.32.0F + links: + - endpoints: ["ceos1:eth0", "mgmtsw:eth1"] +``` + +With both options, `ceos1` boots without a startup-config and with `Management0` wired to `mgmtsw` but unaddressed, so it can obtain its configuration over that link (e.g. via DHCP/ZTP served by `mgmtsw`). + ## Features and options ### Node configuration diff --git a/nodes/ceos/ceos.go b/nodes/ceos/ceos.go index 5800b92532..6d63135eb9 100644 --- a/nodes/ceos/ceos.go +++ b/nodes/ceos/ceos.go @@ -15,12 +15,15 @@ import ( "path" "path/filepath" "regexp" + "strconv" "strings" "github.com/charmbracelet/log" clabconstants "github.com/srl-labs/containerlab/constants" clabexec "github.com/srl-labs/containerlab/exec" + clablinks "github.com/srl-labs/containerlab/links" clabnodes "github.com/srl-labs/containerlab/nodes" + clabnodesstate "github.com/srl-labs/containerlab/nodes/state" clabtypes "github.com/srl-labs/containerlab/types" clabutils "github.com/srl-labs/containerlab/utils" ) @@ -177,6 +180,49 @@ func (n *ceos) LinkApplyMode(ctx context.Context) clabnodes.LinkApplyMode { return n.ImageLinkApplyMode(ctx, clabnodes.LinkApplyModeRestart) } +// ceosInterfaceWait derives the in-container boot-wait inputs from a node's +// endpoints: dataIntfs is the number of data interfaces (the CLAB_INTFS value, +// which excludes a wired eth0 because if-wait.sh never counts eth0), and +// eth0Wired reports whether eth0 is wired as a link (driving CLAB_WAIT_ETH0). +func ceosInterfaceWait(endpoints []clablinks.Endpoint) (dataIntfs int, eth0Wired bool) { + for _, e := range endpoints { + if e.GetIfaceName() == "eth0" { + eth0Wired = true + continue + } + dataIntfs++ + } + return dataIntfs, eth0Wired +} + +func (n *ceos) Deploy(ctx context.Context, _ *clabnodes.DeployParams) error { + // Set CLAB_INTFS to the number of data interfaces so the in-container + // if-wait.sh waits for them before EOS is started. A manually wired eth0 (the + // management netdev) is excluded, since if-wait.sh never counts eth0. + dataIntfs, eth0Wired := ceosInterfaceWait(n.Endpoints) + n.Config().Env[clabconstants.ClabEnvIntfs] = strconv.Itoa(dataIntfs) + + // A wired eth0 (network-mode: none) is injected as a veth after the container + // starts, like any link, but if-wait.sh does not count eth0. cEOS maps kernel + // netdevs to interfaces only once at boot, so EOS must not start init before + // eth0 exists or Management0 is never created. Have if-wait.sh wait for it. + if eth0Wired { + n.Config().Env[clabconstants.ClabEnvWaitEth0] = "1" + } + + cID, err := n.Runtime.CreateContainer(ctx, n.Cfg) + if err != nil { + return err + } + if _, err := n.Runtime.StartContainer(ctx, cID, n); err != nil { + return err + } + + n.SetState(clabnodesstate.Deployed) + + return nil +} + func (n *ceos) SaveConfig(ctx context.Context) (*clabnodes.SaveConfigResult, error) { cmd, _ := clabexec.NewExecCmdFromString(saveCmd) execResult, err := n.RunExec(ctx, cmd) @@ -206,9 +252,16 @@ 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 node is detached from the containerlab management network: + // the none, host and container: network modes all detach the node (see + // the docker runtime's processNetworkMode), so a default route via the mgmt + // gateway would be bogus. + mode, _, _ := strings.Cut(strings.ToLower(n.Cfg.NetworkMode), ":") + if mode != "none" && mode != "host" && mode != "container" { + nodeCfg.MgmtIPv4Gateway = n.Runtime.Mgmt().IPv4Gw + nodeCfg.MgmtIPv6Gateway = n.Runtime.Mgmt().IPv6Gw + } // set the mgmt interface name for the node err := setMgmtInterface(nodeCfg) @@ -346,46 +399,77 @@ 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 { +// mgmtAddresses returns the IPv4 and IPv6 addresses (in CIDR notation) to assign +// to the management interface, or empty strings when none are known. The two +// families are resolved independently: each prefers its management-network +// address and otherwise falls back to the matching address on a manually wired +// eth0 link, used when the node is detached from that network with +// network-mode: none. +func (n *ceos) mgmtAddresses() (v4, v6 string) { nodeCfg := n.Config() - d, err := clabutils.SpawnCLIviaExec("arista_eos", nodeCfg.LongName, n.Runtime.GetName()) - if err != nil { - return err - } - - defer d.Close() - cfgs := []string{ - "interface " + nodeCfg.MgmtIntf, - "no ip address", - "no ipv6 address", + // Prefer the runtime-assigned management address. When the node is detached + // from the management network (e.g. network-mode: none) these fields are + // empty: UpdateConfigWithRuntimeInfo overwrites them with the empty runtime + // values before post-deploy, even if mgmt-ipv4/6 were set in the topology. + if nodeCfg.MgmtIPv4Address != "" { + v4 = fmt.Sprintf("%s/%d", nodeCfg.MgmtIPv4Address, nodeCfg.MgmtIPv4PrefixLength) + } + if nodeCfg.MgmtIPv6Address != "" { + v6 = fmt.Sprintf("%s/%d", nodeCfg.MgmtIPv6Address, nodeCfg.MgmtIPv6PrefixLength) } - // adding ipv4 address to configs - if nodeCfg.MgmtIPv4Address != "" { - cfgs = append(cfgs, - fmt.Sprintf("ip address %s/%d", nodeCfg.MgmtIPv4Address, nodeCfg.MgmtIPv4PrefixLength), - ) + // fall back to the wired eth0 link address for any family still unset. In the + // default mode eth0 is not a topology endpoint, so this matches nothing. + for _, e := range n.Endpoints { + if e.GetIfaceName() != "eth0" { + continue + } + if v4 == "" { + if a := e.GetIPv4Addr(); a.IsValid() { + v4 = a.String() + } + } + if v6 == "" { + if a := e.GetIPv6Addr(); a.IsValid() { + v6 = a.String() + } + } } - // adding ipv6 address to configs - if nodeCfg.MgmtIPv6Address != "" { - cfgs = append( - cfgs, - fmt.Sprintf( - "ipv6 address %s/%d", - nodeCfg.MgmtIPv6Address, - nodeCfg.MgmtIPv6PrefixLength, - ), - ) + return v4, v6 +} + +// postDeployConfigs builds the EOS configuration pushed after deploy: management +// interface addressing followed by data interface addressing. +func (n *ceos) postDeployConfigs() []string { + nodeCfg := n.Config() + + mgmtV4, mgmtV6 := n.mgmtAddresses() + + var cfgs []string + + // (Re)configure the management interface address only for the families that + // have a derived address. Resetting a family ("no ip address" / "no ipv6 + // address") that is not being set would wipe an address the user supplied via + // startup-config (e.g. a manually wired, ZTP-style management interface), so + // each family is reset only when it is also being reconfigured. + if mgmtV4 != "" || mgmtV6 != "" { + cfgs = append(cfgs, "interface "+nodeCfg.MgmtIntf) + if mgmtV4 != "" { + cfgs = append(cfgs, "no ip address", "ip address "+mgmtV4) + } + if mgmtV6 != "" { + cfgs = append(cfgs, "no ipv6 address", "ipv6 address "+mgmtV6) + } } // configure data interfaces for _, e := range n.Endpoints { ifName := e.GetIfaceName() - // skip management interface - if ifName == nodeCfg.MgmtIntf { + // skip the management interface: its EOS name (e.g. Management0) and the + // eth0 netdev that backs it when wired manually via a link. + if ifName == nodeCfg.MgmtIntf || ifName == "eth0" { continue } @@ -409,6 +493,28 @@ 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.postDeployConfigs() + + // nothing to configure (e.g. a manually wired management interface left to + // its startup-config): don't open a CLI session or save the running config, + // leaving the node's configuration untouched. + 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") @@ -429,12 +535,28 @@ 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.]*$`) + nm := strings.ToLower(n.Cfg.NetworkMode) for _, e := range n.Endpoints { - if !ifRe.MatchString(e.GetIfaceName()) { + ifName := e.GetIfaceName() + + // eth0 is the management interface; it can be wired as a regular link + // (for example to model out-of-band management or to test ZTP) only when + // the node is detached from the management network with network-mode: none. + if ifName == "eth0" { + if nm != "none" { + return fmt.Errorf( + "eth0 interface name is not allowed for %s node when network mode is not set to none", + n.Cfg.ShortName, + ) + } + continue + } + + if !ifRe.MatchString(ifName) { 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", + "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 is a number optionally followed by alphanumeric characters, underscores or dots", n.Cfg.ShortName, - e.GetIfaceName(), + ifName, ) } } diff --git a/nodes/ceos/ceos_test.go b/nodes/ceos/ceos_test.go index e1f6a3b8c7..3acc72cb12 100644 --- a/nodes/ceos/ceos_test.go +++ b/nodes/ceos/ceos_test.go @@ -6,9 +6,14 @@ package ceos import ( "context" + "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" ) func TestCeosLinkApplyMode(t *testing.T) { @@ -16,3 +21,243 @@ func TestCeosLinkApplyMode(t *testing.T) { t.Fatalf("LinkApplyMode() = %q, want %q", got, clabnodes.LinkApplyModeRestart) } } + +// testEndpoint builds an endpoint with an optional IPv4/IPv6 address (CIDR). +func testEndpoint(name, v4, v6 string) clablinks.Endpoint { + e := &clablinks.EndpointVeth{ + EndpointGeneric: clablinks.EndpointGeneric{IfaceName: name}, + } + if v4 != "" { + e.IPv4 = netip.MustParsePrefix(v4) + } + if v6 != "" { + e.IPv6 = netip.MustParsePrefix(v6) + } + return e +} + +func newTestCeos(cfg *clabtypes.NodeConfig, eps []clablinks.Endpoint) *ceos { + n := &ceos{ + DefaultNode: clabnodes.DefaultNode{Cfg: cfg}, + } + n.OverwriteNode = n + n.Endpoints = eps + return n +} + +func TestCheckInterfaceName(t *testing.T) { + tests := map[string]struct { + networkMode string + endpoints []clablinks.Endpoint + errContains string + }{ + "data interfaces ok": { + endpoints: []clablinks.Endpoint{ + testEndpoint("eth1", "", ""), + testEndpoint("et2", "", ""), + }, + }, + "eth0 rejected in default network mode": { + networkMode: "", + endpoints: []clablinks.Endpoint{testEndpoint("eth0", "", "")}, + errContains: "not set to none", + }, + "eth0 allowed with network-mode none": { + networkMode: "none", + endpoints: []clablinks.Endpoint{testEndpoint("eth0", "", "")}, + }, + "eth0 allowed with network-mode none case insensitive": { + networkMode: "None", + endpoints: []clablinks.Endpoint{testEndpoint("eth0", "", "")}, + }, + "eth0 alongside data interface with none": { + networkMode: "none", + endpoints: []clablinks.Endpoint{ + testEndpoint("eth0", "", ""), + testEndpoint("eth1", "", ""), + }, + }, + "invalid interface name rejected": { + endpoints: []clablinks.Endpoint{testEndpoint("foo", "", "")}, + errContains: "required pattern", + }, + "et0 rejected": { + endpoints: []clablinks.Endpoint{testEndpoint("et0", "", "")}, + errContains: "required pattern", + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + n := newTestCeos( + &clabtypes.NodeConfig{ShortName: "ceos1", NetworkMode: tc.networkMode}, + tc.endpoints, + ) + + err := n.CheckInterfaceName() + + if tc.errContains == "" { + if err != nil { + t.Fatalf("got unexpected error: %v", err) + } + return + } + + if err == nil { + t.Fatalf("got no error, want error containing %q", tc.errContains) + } + if !strings.Contains(err.Error(), tc.errContains) { + t.Fatalf("got error %q, want it to contain %q", err, tc.errContains) + } + }) + } +} + +func TestCeosInterfaceWait(t *testing.T) { + type result struct { + DataIntfs int + Eth0Wired bool + } + tests := map[string]struct { + endpoints []clablinks.Endpoint + want result + }{ + "data interfaces only": { + endpoints: []clablinks.Endpoint{ + testEndpoint("eth1", "", ""), + testEndpoint("et2", "", ""), + }, + want: result{DataIntfs: 2, Eth0Wired: false}, + }, + "eth0 only (oob mgmt)": { + endpoints: []clablinks.Endpoint{testEndpoint("eth0", "", "")}, + want: result{DataIntfs: 0, Eth0Wired: true}, + }, + "eth0 plus data interfaces": { + endpoints: []clablinks.Endpoint{ + testEndpoint("eth0", "", ""), + testEndpoint("eth1", "", ""), + testEndpoint("eth2", "", ""), + }, + want: result{DataIntfs: 2, Eth0Wired: true}, + }, + "no links": { + endpoints: nil, + want: result{DataIntfs: 0, Eth0Wired: false}, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + var got result + got.DataIntfs, got.Eth0Wired = ceosInterfaceWait(tc.endpoints) + if diff := cmp.Diff(tc.want, got); diff != "" { + t.Errorf("ceosInterfaceWait() mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestPostDeployConfigs(t *testing.T) { + tests := map[string]struct { + cfg *clabtypes.NodeConfig + endpoints []clablinks.Endpoint + want []string + }{ + "mgmt from runtime with data interface": { + cfg: &clabtypes.NodeConfig{ + MgmtIntf: "Management0", + MgmtIPv4Address: "172.20.20.2", + MgmtIPv4PrefixLength: 24, + }, + endpoints: []clablinks.Endpoint{testEndpoint("eth1", "10.0.0.1/30", "")}, + want: []string{ + "interface Management0", + "no ip 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", + }, + }, + "mgmt v4 and v6 from runtime": { + cfg: &clabtypes.NodeConfig{ + MgmtIntf: "Management0", + MgmtIPv4Address: "172.20.20.2", + MgmtIPv4PrefixLength: 24, + MgmtIPv6Address: "2001:db8::2", + MgmtIPv6PrefixLength: 64, + }, + want: []string{ + "interface Management0", + "no ip address", + "ip address 172.20.20.2/24", + "no ipv6 address", + "ipv6 address 2001:db8::2/64", + }, + }, + "detached eth0 wired with address sets mgmt": { + cfg: &clabtypes.NodeConfig{MgmtIntf: "Management0", NetworkMode: "none"}, + endpoints: []clablinks.Endpoint{testEndpoint("eth0", "192.168.1.10/24", "")}, + want: []string{ + "interface Management0", + "no ip address", + "ip address 192.168.1.10/24", + }, + }, + "detached eth0 wired without address leaves mgmt untouched": { + cfg: &clabtypes.NodeConfig{MgmtIntf: "Management0", NetworkMode: "none"}, + endpoints: []clablinks.Endpoint{testEndpoint("eth0", "", "")}, + want: nil, + }, + "detached eth0 plus data interface": { + cfg: &clabtypes.NodeConfig{MgmtIntf: "Management0", NetworkMode: "none"}, + endpoints: []clablinks.Endpoint{ + testEndpoint("eth0", "192.168.1.10/24", ""), + testEndpoint("et1", "10.0.0.1/30", ""), + }, + want: []string{ + "interface Management0", + "no ip address", + "ip address 192.168.1.10/24", + "interface et1", + "no switchport", + "no ip address", + "no ipv6 address", + "ip address 10.0.0.1/30", + }, + }, + "detached eth0 wired with ipv6 only resets only v6": { + cfg: &clabtypes.NodeConfig{MgmtIntf: "Management0", NetworkMode: "none"}, + endpoints: []clablinks.Endpoint{testEndpoint("eth0", "", "2001:db8::2/64")}, + want: []string{ + "interface Management0", + "no ipv6 address", + "ipv6 address 2001:db8::2/64", + }, + }, + "detached eth0 with ipv4 only does not reset v6": { + cfg: &clabtypes.NodeConfig{MgmtIntf: "Management0", NetworkMode: "none"}, + endpoints: []clablinks.Endpoint{testEndpoint("eth0", "192.0.2.10/24", "")}, + want: []string{ + "interface Management0", + "no ip address", + "ip address 192.0.2.10/24", + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + n := newTestCeos(tc.cfg, tc.endpoints) + + got := n.postDeployConfigs() + + if diff := cmp.Diff(tc.want, got); diff != "" { + t.Fatalf("postDeployConfigs() mismatch (-want +got):\n%s", diff) + } + }) + } +} diff --git a/tests/03-basic-ceos/04-ceos-mgmt-link-clab.yml b/tests/03-basic-ceos/04-ceos-mgmt-link-clab.yml new file mode 100644 index 0000000000..33cfedaa49 --- /dev/null +++ b/tests/03-basic-ceos/04-ceos-mgmt-link-clab.yml @@ -0,0 +1,28 @@ +name: 03-04-ceos-mgmt-link + +# This lab exercises wiring the cEOS management interface (eth0) as a regular +# link with network-mode: none, in two modes: +# oob - the management interface is addressed from the link (out-of-band mgmt) +# ztp - the node boots with no startup-config (suppress-startup-config) so it +# actually zero-touch provisions; its mgmt interface is wired but +# unaddressed +# mgmtsw is a regular ceos node acting as the peer for both. +topology: + kinds: + arista_ceos: + image: ceos:4.32.0F + nodes: + oob: + kind: arista_ceos + network-mode: none + ztp: + kind: arista_ceos + network-mode: none + suppress-startup-config: true + mgmtsw: + kind: arista_ceos + + links: + - endpoints: [ "oob:eth0", "mgmtsw:eth1" ] + ipv4: [ "192.168.99.2/24", "192.168.99.1/24" ] + - endpoints: [ "ztp:eth0", "mgmtsw:eth2" ] diff --git a/tests/03-basic-ceos/04-ceos-mgmt-link.robot b/tests/03-basic-ceos/04-ceos-mgmt-link.robot new file mode 100644 index 0000000000..107879ef90 --- /dev/null +++ b/tests/03-basic-ceos/04-ceos-mgmt-link.robot @@ -0,0 +1,89 @@ +*** Settings *** +Library OperatingSystem +Resource ../common.robot + +Suite Teardown Run Keyword Cleanup + + +*** Variables *** +${lab-name} 03-04-ceos-mgmt-link +${lab-file-name} 04-ceos-mgmt-link-clab.yml +${invalid-lab-name} 03-05-ceos-mgmt-link-invalid +${invalid-lab-file} 05-ceos-mgmt-link-invalid-clab.yml +${runtime} docker +${oob-mgmt-ip} 192.168.99.2 +${peer-link-ip} 192.168.99.1 + + +*** Test Cases *** +Deploy ${lab-name} lab + ${rc} ${output} = Run And Return Rc And Output + ... ${CLAB_BIN} --runtime ${runtime} deploy -t ${CURDIR}/${lab-file-name} + Log ${output} + Should Be Equal As Integers ${rc} 0 + +Wait for dataplane to settle + Sleep 10s + +Verify oob management interface uses the wired link address + [Documentation] + ... The management interface is wired as a link, so Management0 must come + ... up addressed from that link rather than from the management network. + ${rc} ${output} = Run And Return Rc And Output + ... ${CLAB_BIN} --runtime ${runtime} exec -t ${CURDIR}/${lab-file-name} --label clab-node-name\=oob --cmd "Cli -p 15 -c 'show ip interface brief'" + Log ${output} + Should Be Equal As Integers ${rc} 0 + Should Contain ${output} Management0 + Should Contain ${output} ${oob-mgmt-ip} + +Ensure oob can ping its peer over the wired management link + ${rc} ${output} = Run And Return Rc And Output + ... ${CLAB_BIN} --runtime ${runtime} exec -t ${CURDIR}/${lab-file-name} --label clab-node-name\=oob --cmd "Cli -p 15 -c 'ping ${peer-link-ip} repeat 3'" + Log ${output} + Should Be Equal As Integers ${rc} 0 + Should Contain ${output} 0% packet loss + +Ensure oob is not attached to the management network + ${rc} ${output} = Run And Return Rc And Output + ... sudo docker inspect clab-${lab-name}-oob -f '{{json .NetworkSettings.Networks}}' + Log ${output} + Should Be Equal As Integers ${rc} 0 + Should Contain ${output} none + +Ensure oob startup-config has no management default route + [Documentation] + ... A detached node has no management network to route through, so no + ... default route via the management gateway should be templated. + ${f} = OperatingSystem.Get File ${CURDIR}/clab-${lab-name}/oob/flash/startup-config + Log ${f} + Should Not Contain ${f} ip route 0.0.0.0/0 + +Ensure ztp node boots without a startup-config + [Documentation] + ... With suppress-startup-config the node has no startup-config, which is + ... what lets it actually zero-touch provision instead of booting fully + ... configured. + OperatingSystem.File Should Not Exist ${CURDIR}/clab-${lab-name}/ztp/flash/startup-config + +Verify ztp management interface is wired but unaddressed + ${rc} ${output} = Run And Return Rc And Output + ... ${CLAB_BIN} --runtime ${runtime} exec -t ${CURDIR}/${lab-file-name} --label clab-node-name\=ztp --cmd "Cli -p 15 -c 'show ip interface brief'" + Log ${output} + Should Be Equal As Integers ${rc} 0 + Should Contain ${output} Management0 + Should Not Contain ${output} ${oob-mgmt-ip} + +Fail to deploy when eth0 is wired without network-mode none + ${rc} ${output} = Run And Return Rc And Output + ... ${CLAB_BIN} --runtime ${runtime} deploy -t ${CURDIR}/${invalid-lab-file} + Log ${output} + Should Not Be Equal As Integers ${rc} 0 + Should Contain ${output} network mode is not set to none + + +*** Keywords *** +Cleanup + Run ${CLAB_BIN} --runtime ${runtime} destroy -t ${CURDIR}/${lab-file-name} --cleanup + Run ${CLAB_BIN} --runtime ${runtime} destroy -t ${CURDIR}/${invalid-lab-file} --cleanup + Run rm -rf ${CURDIR}/clab-${lab-name} + Run rm -rf ${CURDIR}/clab-${invalid-lab-name} diff --git a/tests/03-basic-ceos/05-ceos-mgmt-link-invalid-clab.yml b/tests/03-basic-ceos/05-ceos-mgmt-link-invalid-clab.yml new file mode 100644 index 0000000000..4656cfb518 --- /dev/null +++ b/tests/03-basic-ceos/05-ceos-mgmt-link-invalid-clab.yml @@ -0,0 +1,16 @@ +name: 03-05-ceos-mgmt-link-invalid + +# n1 wires its eth0 (the management interface) as a link without setting +# network-mode: none. This is not allowed and deployment must fail. +topology: + kinds: + arista_ceos: + image: ceos:4.32.0F + nodes: + n1: + kind: arista_ceos + n2: + kind: arista_ceos + + links: + - endpoints: [ "n1:eth0", "n2:eth1" ] diff --git a/utils/if-wait.sh b/utils/if-wait.sh index 77bede5a42..32c618c55f 100644 --- a/utils/if-wait.sh +++ b/utils/if-wait.sh @@ -1,9 +1,10 @@ #!/bin/sh -# Validate CLAB_INTFS environment variable +# Validate CLAB_INTFS environment variable: it must be a non-negative integer. +# A value of 0 is legitimate (e.g. when only eth0 is waited for via CLAB_WAIT_ETH0). REQUIRED_INTFS_NUM=${CLAB_INTFS:-0} -if ! echo "$REQUIRED_INTFS_NUM" | grep -qE '^[0-9]+$' || [ "$REQUIRED_INTFS_NUM" -eq 0 ]; then - echo "Warning: CLAB_INTFS not set or invalid, skipping interface wait" +if ! echo "$REQUIRED_INTFS_NUM" | grep -qE '^[0-9]+$'; then + echo "Warning: CLAB_INTFS=\"$CLAB_INTFS\" is not a valid number, treating as 0" REQUIRED_INTFS_NUM=0 fi @@ -23,29 +24,43 @@ int_calc() { return 0 } -# Only wait for interfaces if CLAB_INTFS is set -if [ "$REQUIRED_INTFS_NUM" -gt 0 ]; then - echo "Waiting for $REQUIRED_INTFS_NUM interfaces to be connected (timeout: ${TIMEOUT}s)" - +# Optionally also wait for eth0. eth0 is not matched by int_calc's pattern (it is +# normally the management interface, present from the start); when it is instead +# wired as a link it must be waited for too. Opt in with CLAB_WAIT_ETH0=1. +WAIT_ETH0=${CLAB_WAIT_ETH0:-0} + +# eth0_ready is true unless we are asked to wait for eth0 and it is not present. +eth0_ready() { + [ "$WAIT_ETH0" != "1" ] || [ -e /sys/class/net/eth0 ] +} + +# Wait for the required data interfaces and, when requested, eth0. +if [ "$REQUIRED_INTFS_NUM" -gt 0 ] || [ "$WAIT_ETH0" = "1" ]; then + WAIT_DESC="$REQUIRED_INTFS_NUM interface(s)" + [ "$WAIT_ETH0" = "1" ] && WAIT_DESC="$WAIT_DESC + eth0" + echo "Waiting for $WAIT_DESC to be connected (timeout: ${TIMEOUT}s)" + while [ "$WAIT_TIME" -lt "$TIMEOUT" ]; do if ! int_calc; then echo "Failed to check interfaces, continuing..." break fi - - if [ "$AVAIL_INTFS_NUM" -ge "$REQUIRED_INTFS_NUM" ]; then + + if [ "$AVAIL_INTFS_NUM" -ge "$REQUIRED_INTFS_NUM" ] && eth0_ready; then echo "Found $AVAIL_INTFS_NUM interfaces (required: $REQUIRED_INTFS_NUM)" break fi - + echo "Connected $AVAIL_INTFS_NUM interfaces out of $REQUIRED_INTFS_NUM (waited ${WAIT_TIME}s)" sleep 1 WAIT_TIME=$((WAIT_TIME + 1)) done - + if [ "$WAIT_TIME" -ge "$TIMEOUT" ]; then echo "Warning: Timeout reached, proceeding with $AVAIL_INTFS_NUM interfaces" fi +else + echo "No interfaces to wait for, skipping interface wait" fi if [ "$SLEEP" -ne 0 ]; then