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
2 changes: 2 additions & 0 deletions docs/manual/kinds/ceos.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ docker import cEOS64-lab-4.32.0F.tar.xz ceos:4.32.0F

Arista cEOS node launched with containerlab can be managed via the following interfaces:

Containerlab applies the built-in postdeploy cEOS CLI configuration through the configured container runtime `exec` path. This keeps management-interface and link-addressing setup aligned with the selected runtime, including Docker and Podman.

/// tab | bash
to connect to a `bash` shell of a running ceos container:

Expand Down
57 changes: 41 additions & 16 deletions nodes/ceos/ceos.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,14 @@ import (
"context"
_ "embed"
"encoding/json"
"errors"
"fmt"
"net"
"os"
"path"
"path/filepath"
"regexp"
"strings"
"time"

"github.com/charmbracelet/log"
clabconstants "github.com/srl-labs/containerlab/constants"
Expand Down Expand Up @@ -57,6 +57,15 @@ var (

saveCmd = "Cli -p 15 -c wr"

ceosPostDeployExec = func(
n *ceos,
ctx context.Context,
execCmd *clabexec.ExecCmd,
) (*clabexec.ExecResult, error) {
return n.RunExec(ctx, execCmd)
}
ceosPostDeploySleep = time.Sleep

defaultCredentials = clabnodes.NewCredentials("admin", "admin")
)

Expand Down Expand Up @@ -347,16 +356,10 @@ func setMgmtInterface(node *clabtypes.NodeConfig) error {
}

// ceosPostDeploy runs postdeploy actions which are required for ceos nodes.
func (n *ceos) ceosPostDeploy(_ context.Context) error {
func (n *ceos) ceosPostDeploy(ctx context.Context) error {
nodeCfg := n.Config()
d, err := clabutils.SpawnCLIviaExec("arista_eos", nodeCfg.LongName, n.Runtime.GetName())
if err != nil {
return err
}

defer d.Close()

cfgs := []string{
"configure terminal",
"interface " + nodeCfg.MgmtIntf,
"no ip address",
"no ipv6 address",
Expand Down Expand Up @@ -410,18 +413,40 @@ func (n *ceos) ceosPostDeploy(_ context.Context) error {
}

// add save to startup cmd
cfgs = append(cfgs, "wr")
cfgs = append(cfgs, "end", "write memory")

log.Debugf("cEOS PostDeploy configuration for node %s: %v", n.Cfg.ShortName, cfgs)

resp, err := d.SendConfigs(cfgs)
if err != nil {
return err
} else if resp.Failed != nil {
return errors.New("failed CLI configuration")
var lastErr error
var lastResp *clabexec.ExecResult
cliCmd := "Cli -p 15 --abort-on-error -c $'" + strings.Join(cfgs, "\n") + "'"

for range 60 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If understand this correctly, this simply throws the whatever post deploy command at cEOS 60 times without any possibility to detect syntax errors, unsupported commands, etc. While I'd expect this line to be healthy in general and not have all of that, can we consider an alternative approach?

  1. loop 60 times to detect readiness with some simple CLI
  2. once CLI is ready - stop looping and send post deploy once
  3. Return any configuration error immediately with stderr

execCmd := clabexec.NewExecCmdFromSlice([]string{"/bin/bash", "-lc", cliCmd})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we really involve bash here? Is there a specific use case for that? Is direct Cli call an option?

clabexec.NewExecCmdFromSlice([]string{
    "Cli",
    "-p", "15",
    "--abort-on-error",
    "-c", strings.Join(cfgs, "\n"),
})

resp, err := ceosPostDeployExec(n, ctx, execCmd)
if err == nil && resp.GetReturnCode() == 0 {
return nil
}

lastErr = err
lastResp = resp
log.Debugf("%s - Cli not ready (%v, %v) - waiting.", nodeCfg.LongName, err, resp)
ceosPostDeploySleep(2 * time.Second)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

have to test that in a live lab, but I suspect cancellation will not stop this loop. Please check and fix if it's the case. I'll run this fork in my lab during the final review.

}

return err
if lastErr != nil {
return lastErr
}
if lastResp != nil {
return fmt.Errorf(
"failed CLI configuration: rc=%d stdout=%q stderr=%q",
lastResp.GetReturnCode(),
lastResp.GetStdOutString(),
lastResp.GetStdErrString(),
)
}

return fmt.Errorf("failed CLI configuration")
}

// CheckInterfaceName checks if a name of the interface referenced in the topology file correct.
Expand Down
175 changes: 175 additions & 0 deletions nodes/ceos/ceos_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,188 @@ package ceos

import (
"context"
"errors"
"net/netip"
"strings"
"testing"
"time"

clabexec "github.com/srl-labs/containerlab/exec"
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) {
if got := (&ceos{}).LinkApplyMode(context.Background()); got != clabnodes.LinkApplyModeRestart {
t.Fatalf("LinkApplyMode() = %q, want %q", got, clabnodes.LinkApplyModeRestart)
}
}

func TestCeosPostDeployBuildsRuntimeExecCommand(t *testing.T) {
node := newTestCEOSNode()
node.Endpoints = []clablinks.Endpoint{
clablinks.NewEndpointVeth(&clablinks.EndpointGeneric{
IfaceName: "eth1",
IPv4: netip.MustParsePrefix("192.0.2.1/31"),
IPv6: netip.MustParsePrefix("2001:db8::1/127"),
}),
}

var gotCmd string
restore := stubCeosPostDeploy(
func(_ *ceos, _ context.Context, execCmd *clabexec.ExecCmd) (*clabexec.ExecResult, error) {
gotCmd = execCmd.GetCmdString()
return execResult(execCmd, 0, "", ""), nil
},
func(time.Duration) {},
)
defer restore()

if err := node.ceosPostDeploy(context.Background()); err != nil {
t.Fatalf("ceosPostDeploy() unexpected error: %v", err)
}

wantSnippets := []string{
"/bin/bash -lc",
"Cli -p 15 --abort-on-error -c",
"configure terminal",
"interface Management0",
"ip address 172.20.20.2/24",
"ipv6 address 2001:db8:1::2/64",
"interface eth1",
"no switchport",
"ip address 192.0.2.1/31",
"ipv6 address 2001:db8::1/127",
"end",
"write memory",
}

for _, want := range wantSnippets {
if !strings.Contains(gotCmd, want) {
t.Fatalf("exec command missing %q\ncommand: %s", want, gotCmd)
}
}
}

func TestCeosPostDeployRetriesUntilSuccess(t *testing.T) {
node := newTestCEOSNode()

var calls, sleeps int
restore := stubCeosPostDeploy(
func(_ *ceos, _ context.Context, execCmd *clabexec.ExecCmd) (*clabexec.ExecResult, error) {
calls++
if calls == 1 {
return execResult(execCmd, 1, "", "Cli not ready"), nil
}
return execResult(execCmd, 0, "", ""), nil
},
func(time.Duration) { sleeps++ },
)
defer restore()

if err := node.ceosPostDeploy(context.Background()); err != nil {
t.Fatalf("ceosPostDeploy() unexpected error: %v", err)
}

if calls != 2 {
t.Fatalf("exec calls = %d, want 2", calls)
}
if sleeps != 1 {
t.Fatalf("sleep calls = %d, want 1", sleeps)
}
}

func TestCeosPostDeployReturnsExecErrorAfterRetries(t *testing.T) {
node := newTestCEOSNode()
wantErr := errors.New("runtime unavailable")

var calls, sleeps int
restore := stubCeosPostDeploy(
func(_ *ceos, _ context.Context, _ *clabexec.ExecCmd) (*clabexec.ExecResult, error) {
calls++
return nil, wantErr
},
func(time.Duration) { sleeps++ },
)
defer restore()

err := node.ceosPostDeploy(context.Background())
if !errors.Is(err, wantErr) {
t.Fatalf("ceosPostDeploy() error = %v, want %v", err, wantErr)
}
if calls != 60 {
t.Fatalf("exec calls = %d, want 60", calls)
}
if sleeps != 60 {
t.Fatalf("sleep calls = %d, want 60", sleeps)
}
}

func TestCeosPostDeployReturnsCLIErrorAfterRetries(t *testing.T) {
node := newTestCEOSNode()

restore := stubCeosPostDeploy(
func(_ *ceos, _ context.Context, execCmd *clabexec.ExecCmd) (*clabexec.ExecResult, error) {
return execResult(execCmd, 1, "partial output", "syntax error"), nil
},
func(time.Duration) {},
)
defer restore()

err := node.ceosPostDeploy(context.Background())
if err == nil {
t.Fatal("ceosPostDeploy() error = nil, want non-nil")
}

for _, want := range []string{
"failed CLI configuration",
"rc=1",
`stdout="partial output"`,
`stderr="syntax error"`,
} {
if !strings.Contains(err.Error(), want) {
t.Fatalf("error %q missing %q", err, want)
}
}
}

func newTestCEOSNode() *ceos {
node := &ceos{}
node.DefaultNode = *clabnodes.NewDefaultNode(node)
node.Cfg = &clabtypes.NodeConfig{
ShortName: "ceos1",
LongName: "clab-test-ceos1",
MgmtIntf: "Management0",
MgmtIPv4Address: "172.20.20.2",
MgmtIPv4PrefixLength: 24,
MgmtIPv6Address: "2001:db8:1::2",
MgmtIPv6PrefixLength: 64,
}

return node
}

func execResult(execCmd *clabexec.ExecCmd, rc int, stdout, stderr string) *clabexec.ExecResult {
res := clabexec.NewExecResult(execCmd)
res.SetReturnCode(rc)
res.SetStdOut([]byte(stdout))
res.SetStdErr([]byte(stderr))
return res
}

func stubCeosPostDeploy(
execFn func(*ceos, context.Context, *clabexec.ExecCmd) (*clabexec.ExecResult, error),
sleepFn func(time.Duration),
) func() {
origExec := ceosPostDeployExec
origSleep := ceosPostDeploySleep

ceosPostDeployExec = execFn
ceosPostDeploySleep = sleepFn

return func() {
ceosPostDeployExec = origExec
ceosPostDeploySleep = origSleep
}
}
8 changes: 8 additions & 0 deletions tests/03-basic-ceos/01-two-ceos.robot
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,14 @@ Ensure MGMT VRF is present
Should Be Equal As Integers ${rc} 0
Should Contain ${output} MGMT

Ensure Management0 inherits runtime-assigned IPv4
${rc} ${output} = Run And Return Rc And Output
... ${CLAB_BIN} --runtime ${runtime} exec -t ${CURDIR}/${lab-file-name} --label clab-node-name\=${node1-name} --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} ${n1-mgmt-ip}

Ensure n1 is reachable over ssh
Login via SSH with username and password
... address=${n1-mgmt-ip}
Expand Down