diff --git a/docs/manual/kinds/ceos.md b/docs/manual/kinds/ceos.md index 03e995420a..d22101b9db 100644 --- a/docs/manual/kinds/ceos.md +++ b/docs/manual/kinds/ceos.md @@ -34,6 +34,18 @@ 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: +### Post-deploy configuration + +After cEOS starts, containerlab configures its management address and any topology-assigned data +interface addresses. The commands are sent to EOS as one non-interactive batch through the +configured container runtime's `exec` API. This replaces the previous host-side +`docker/podman exec -it` subprocess and keeps execution within containerlab's Docker or Podman +runtime connection. + +The batch enters configuration mode, uses `--abort-on-error`, and finishes with `end` and +`write memory`. Containerlab captures the command's return code, standard output, and standard +error, and retries CLI startup up to 60 times while cEOS is becoming ready. + /// tab | bash to connect to a `bash` shell of a running ceos container: diff --git a/nodes/ceos/ceos.go b/nodes/ceos/ceos.go index ab2b302afa..d3ccb4f7b9 100644 --- a/nodes/ceos/ceos.go +++ b/nodes/ceos/ceos.go @@ -8,7 +8,6 @@ import ( "context" _ "embed" "encoding/json" - "errors" "fmt" "net" "os" @@ -16,6 +15,7 @@ import ( "path/filepath" "regexp" "strings" + "time" "github.com/charmbracelet/log" clabconstants "github.com/srl-labs/containerlab/constants" @@ -57,9 +57,30 @@ 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) + } + ceosPostDeployWait = waitForCeosPostDeployRetry + defaultCredentials = clabnodes.NewCredentials("admin", "admin") ) +func waitForCeosPostDeployRetry(ctx context.Context, delay time.Duration) error { + timer := time.NewTimer(delay) + defer timer.Stop() + + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} + // Register registers the node in the NodeRegistry. func Register(r *clabnodes.NodeRegistry) { generateNodeAttributes := clabnodes.NewGenerateNodeAttributes(generateable, generateIfFormat) @@ -347,16 +368,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", @@ -410,18 +425,80 @@ 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) + var lastErr error + var lastResp *clabexec.ExecResult + readyCmd := clabexec.NewExecCmdFromSlice([]string{"Cli", "-p", "15", "-c", "show version"}) + ready := false + + for attempt := range 60 { + if err := ctx.Err(); err != nil { + return fmt.Errorf("cEOS CLI readiness canceled: %w", err) + } + + lastResp, lastErr = ceosPostDeployExec(n, ctx, readyCmd) + if lastErr == nil && lastResp != nil && lastResp.GetReturnCode() == 0 { + ready = true + break + } + + log.Debugf( + "%s - Cli not ready (%v, %v) - waiting.", + nodeCfg.LongName, + lastErr, + lastResp, + ) + if attempt == 59 { + break + } + if err := ceosPostDeployWait(ctx, 2*time.Second); err != nil { + return fmt.Errorf("cEOS CLI readiness canceled: %w", err) + } + } + + if !ready { + if lastErr != nil { + return fmt.Errorf("failed waiting for cEOS CLI readiness: %w", lastErr) + } + if lastResp != nil { + return fmt.Errorf( + "cEOS CLI did not become ready: rc=%d stdout=%q stderr=%q", + lastResp.GetReturnCode(), + lastResp.GetStdOutString(), + lastResp.GetStdErrString(), + ) + } + + return fmt.Errorf("cEOS CLI did not become ready") + } + + cliCmd := []string{ + "Cli", + "-p", "15", + "--abort-on-error", + "-c", strings.Join(cfgs, "\n"), + } + execCmd := clabexec.NewExecCmdFromSlice(cliCmd) + resp, err := ceosPostDeployExec(n, ctx, execCmd) if err != nil { - return err - } else if resp.Failed != nil { - return errors.New("failed CLI configuration") + return fmt.Errorf("failed CLI configuration: %w", err) + } + if resp == nil { + return fmt.Errorf("failed CLI configuration: empty runtime response") + } + if resp.GetReturnCode() != 0 { + return fmt.Errorf( + "failed CLI configuration: rc=%d stdout=%q stderr=%q", + resp.GetReturnCode(), + resp.GetStdOutString(), + resp.GetStdErrString(), + ) } - return err + return nil } // CheckInterfaceName checks if a name of the interface referenced in the topology file correct. diff --git a/nodes/ceos/ceos_test.go b/nodes/ceos/ceos_test.go index e1f6a3b8c7..ace34f2a0e 100644 --- a/nodes/ceos/ceos_test.go +++ b/nodes/ceos/ceos_test.go @@ -6,9 +6,16 @@ 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) { @@ -16,3 +23,241 @@ func TestCeosLinkApplyMode(t *testing.T) { 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.GetCmd() + return execResult(execCmd, 0, "", ""), nil + }, + func(context.Context, time.Duration) error { return nil }, + ) + defer restore() + + if err := node.ceosPostDeploy(context.Background()); err != nil { + t.Fatalf("ceosPostDeploy() unexpected error: %v", err) + } + + wantArgs := []string{"Cli", "-p", "15", "--abort-on-error", "-c"} + if len(gotCmd) != len(wantArgs)+1 { + t.Fatalf("exec command has %d arguments, want %d: %q", len(gotCmd), len(wantArgs)+1, gotCmd) + } + for i, want := range wantArgs { + if gotCmd[i] != want { + t.Fatalf("exec command argument %d = %q, want %q", i, gotCmd[i], want) + } + } + + wantConfigSnippets := []string{ + "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", + } + + config := gotCmd[len(wantArgs)] + for _, want := range wantConfigSnippets { + if !strings.Contains(config, want) { + t.Fatalf("CLI configuration missing %q\nconfiguration: %s", want, config) + } + } +} + +func TestCeosPostDeployWaitsForCLIThenConfiguresOnce(t *testing.T) { + node := newTestCEOSNode() + + var calls, configCalls, waits int + restore := stubCeosPostDeploy( + func(_ *ceos, _ context.Context, execCmd *clabexec.ExecCmd) (*clabexec.ExecResult, error) { + calls++ + if isCeosReadinessCommand(execCmd) && calls == 1 { + return execResult(execCmd, 1, "", "Cli not ready"), nil + } + if !isCeosReadinessCommand(execCmd) { + configCalls++ + } + return execResult(execCmd, 0, "", ""), nil + }, + func(context.Context, time.Duration) error { + waits++ + return nil + }, + ) + defer restore() + + if err := node.ceosPostDeploy(context.Background()); err != nil { + t.Fatalf("ceosPostDeploy() unexpected error: %v", err) + } + + if calls != 3 { + t.Fatalf("exec calls = %d, want 3", calls) + } + if configCalls != 1 { + t.Fatalf("configuration calls = %d, want 1", configCalls) + } + if waits != 1 { + t.Fatalf("wait calls = %d, want 1", waits) + } +} + +func TestCeosPostDeployReturnsExecErrorAfterRetries(t *testing.T) { + node := newTestCEOSNode() + wantErr := errors.New("runtime unavailable") + + var calls, waits int + restore := stubCeosPostDeploy( + func(_ *ceos, _ context.Context, _ *clabexec.ExecCmd) (*clabexec.ExecResult, error) { + calls++ + return nil, wantErr + }, + func(context.Context, time.Duration) error { + waits++ + return nil + }, + ) + 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 waits != 59 { + t.Fatalf("wait calls = %d, want 59", waits) + } +} + +func TestCeosPostDeployStopsReadinessRetriesWhenCanceled(t *testing.T) { + node := newTestCEOSNode() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + var calls, waits int + restore := stubCeosPostDeploy( + func(_ *ceos, _ context.Context, execCmd *clabexec.ExecCmd) (*clabexec.ExecResult, error) { + calls++ + cancel() + return execResult(execCmd, 1, "", "Cli not ready"), nil + }, + func(ctx context.Context, delay time.Duration) error { + waits++ + return waitForCeosPostDeployRetry(ctx, delay) + }, + ) + defer restore() + + err := node.ceosPostDeploy(ctx) + if !errors.Is(err, context.Canceled) { + t.Fatalf("ceosPostDeploy() error = %v, want context.Canceled", err) + } + if calls != 1 { + t.Fatalf("exec calls = %d, want 1", calls) + } + if waits != 1 { + t.Fatalf("wait calls = %d, want 1", waits) + } +} + +func TestCeosPostDeployReturnsConfigurationErrorImmediately(t *testing.T) { + node := newTestCEOSNode() + + var calls int + restore := stubCeosPostDeploy( + func(_ *ceos, _ context.Context, execCmd *clabexec.ExecCmd) (*clabexec.ExecResult, error) { + calls++ + if isCeosReadinessCommand(execCmd) { + return execResult(execCmd, 0, "", ""), nil + } + return execResult(execCmd, 1, "partial output", "syntax error"), nil + }, + func(context.Context, time.Duration) error { return nil }, + ) + defer restore() + + err := node.ceosPostDeploy(context.Background()) + if err == nil { + t.Fatal("ceosPostDeploy() error = nil, want non-nil") + } + if calls != 2 { + t.Fatalf("exec calls = %d, want one readiness check and one configuration attempt", calls) + } + + 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 isCeosReadinessCommand(execCmd *clabexec.ExecCmd) bool { + cmd := execCmd.GetCmd() + return len(cmd) == 5 && + cmd[0] == "Cli" && + cmd[1] == "-p" && + cmd[2] == "15" && + cmd[3] == "-c" && + cmd[4] == "show version" +} + +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), + waitFn func(context.Context, time.Duration) error, +) func() { + origExec := ceosPostDeployExec + origWait := ceosPostDeployWait + + ceosPostDeployExec = execFn + ceosPostDeployWait = waitFn + + return func() { + ceosPostDeployExec = origExec + ceosPostDeployWait = origWait + } +} diff --git a/tests/03-basic-ceos/01-two-ceos.robot b/tests/03-basic-ceos/01-two-ceos.robot index 470405098f..50ee289e18 100644 --- a/tests/03-basic-ceos/01-two-ceos.robot +++ b/tests/03-basic-ceos/01-two-ceos.robot @@ -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}