Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions constants/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
58 changes: 58 additions & 0 deletions docs/manual/kinds/ceos.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
192 changes: 157 additions & 35 deletions nodes/ceos/ceos.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:<name> 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)
Expand Down Expand Up @@ -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
}

Expand All @@ -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")

Expand All @@ -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,
)
}
}
Expand Down
Loading