diff --git a/cmd/exec.go b/cmd/exec.go index a836ac381c..8935a26fb6 100644 --- a/cmd/exec.go +++ b/cmd/exec.go @@ -8,19 +8,25 @@ import ( "context" "errors" "fmt" + "os" + "os/exec" + "strings" + "syscall" "github.com/spf13/cobra" clabconstants "github.com/srl-labs/containerlab/constants" clabcore "github.com/srl-labs/containerlab/core" clabexec "github.com/srl-labs/containerlab/exec" + clabruntime "github.com/srl-labs/containerlab/runtime" ) func execCmd(o *Options) (*cobra.Command, error) { c := &cobra.Command{ - Use: "exec", + Use: "exec [containername]", Short: "execute a command in one or multiple containers", - RunE: func(cobraCmd *cobra.Command, _ []string) error { - return execFn(cobraCmd, o) + Args: cobra.MaximumNArgs(1), + RunE: func(cobraCmd *cobra.Command, args []string) error { + return execFn(cobraCmd, o, args) }, } @@ -45,14 +51,43 @@ func execCmd(o *Options) (*cobra.Command, error) { o.Exec.Format, "output format. One of [json, plain]", ) + c.Flags().BoolVarP( + &o.Exec.Interactive, + "interactive", + "i", + o.Exec.Interactive, + "open an interactive shell in a single matched container", + ) + c.Flags().StringVarP( + &o.Exec.Shell, + "shell", + "s", + o.Exec.Shell, + "shell to use for --interactive (overrides image-based auto-detection)", + ) + + c.MarkFlagsMutuallyExclusive("cmd", "interactive") return c, nil } -func execFn(_ *cobra.Command, o *Options) error { +func execFn(cobraCmd *cobra.Command, o *Options, args []string) error { ctx, cancel := context.WithCancel(context.Background()) defer cancel() + if o.Exec.Interactive { + var nameFilter string + if len(args) == 1 { + nameFilter = args[0] + } + + return execInteractive(ctx, o, nameFilter) + } + + if len(args) == 1 { + return fmt.Errorf("positional argument %q is only valid with --interactive", args[0]) + } + if len(o.Exec.Commands) == 0 { return errors.New("provide command to execute") } @@ -102,3 +137,94 @@ func execFn(_ *cobra.Command, o *Options) error { return err } + +// execInteractive drops the user into an interactive shell inside a single matched container. +// nameFilter, if non-empty, is a substring matched against container names after the +// topology/label filters have been applied. The topology file is auto-detected from the +// current directory when neither --topo nor --name was given. +func execInteractive(ctx context.Context, o *Options, nameFilter string) error { + // Auto-detect topology file the same way tools dc does. + if o.Global.TopologyFile == "" && o.Global.TopologyName == "" { + if found, err := clabcore.FindTopoFileByPath("."); err == nil { + o.Global.TopologyFile = found + } + } + + c, err := clabcore.NewContainerLab(o.ToClabOptions()...) + if err != nil { + return err + } + + err = c.CheckConnectivity(ctx) + if err != nil { + return err + } + + listOptions := []clabcore.ListOption{ + clabcore.WithListFromCliArgs(o.Filter.LabelFilter), + } + + if o.Global.TopologyFile != "" { + listOptions = append(listOptions, clabcore.WithListLabName(c.Config.Name)) + } + + containers, err := c.ListContainers(ctx, listOptions...) + if err != nil { + return err + } + + // Apply optional substring filter on container name. + if nameFilter != "" { + var matched []clabruntime.GenericContainer + for _, ct := range containers { + if strings.Contains(strings.TrimPrefix(ct.Names[0], "/"), nameFilter) { + matched = append(matched, ct) + } + } + containers = matched + } + + switch len(containers) { + case 0: + return errors.New("no containers matched the given filters") + case 1: + // exactly one match — proceed + default: + fmt.Fprintln(os.Stderr, "ambiguous match; narrow with a more specific name, --label, or --topo:") + + for _, ct := range containers { + name := strings.TrimPrefix(ct.Names[0], "/") + fmt.Fprintf(os.Stderr, " %s (%s)\n", name, ct.Image) + } + + return fmt.Errorf("interactive exec requires exactly one container, got %d", len(containers)) + } + + ct := containers[0] + name := strings.TrimPrefix(ct.Names[0], "/") + + var shell []string + + shortName := ct.Labels[clabconstants.NodeName] + node, nodeKnown := c.Nodes[shortName] + + switch { + case o.Exec.Shell != "": + shell = strings.Fields(o.Exec.Shell) + case nodeKnown && node.Config().Env["CLAB_EXEC_INTERACTIVE_SHELL"] != "": + shell = strings.Fields(node.Config().Env["CLAB_EXEC_INTERACTIVE_SHELL"]) + case nodeKnown: + shell = node.ExecInteractiveShell() + default: + shell = []string{"/bin/sh"} + } + + dockerPath, err := exec.LookPath("docker") + if err != nil { + return fmt.Errorf("docker executable not found in PATH: %w", err) + } + + argv := append([]string{"docker", "exec", "-it", name}, shell...) + + return syscall.Exec(dockerPath, argv, os.Environ()) +} diff --git a/cmd/options.go b/cmd/options.go index 47cd0ee8cf..80427470c1 100644 --- a/cmd/options.go +++ b/cmd/options.go @@ -354,8 +354,10 @@ type SaveOptions struct { } type ExecOptions struct { - Format string - Commands []string + Format string + Commands []string + Interactive bool + Shell string } type InspectOptions struct { diff --git a/docs/cmd/exec.md b/docs/cmd/exec.md index 1c6e15c0c0..55dbe181e3 100644 --- a/docs/cmd/exec.md +++ b/docs/cmd/exec.md @@ -4,13 +4,15 @@ The `exec` command allows a user to execute a command inside the nodes (containers). -This command is similar to `docker exec`, but it allows a user to run the same command across multiple lab nodes matching the filter. Users can provide a path to the topology file and use the `--label` argument to narrow down the list of nodes to execute the command on. +This command allows a user to run the same command across multiple lab nodes matching the filter. Users can provide a path to the topology file and use the `--label` argument to narrow down the list of nodes to execute the command on. + +With `--interactive` / `-i` the command drops you into an interactive shell inside a **single** matched container, replacing the current process. The shell is auto-detected from the container image, or overridden with `--shell` / `-s`. Because an interactive session targets one container, `--interactive` and `--cmd` are mutually exclusive. --8<-- "docs/cmd/deploy.md:env-vars-flags" ## Usage -`containerlab [global-flags] exec [local-flags]` +`containerlab [global-flags] exec [local-flags] [containername]` ## Flags @@ -24,6 +26,8 @@ Note, that with the nodes of [`ext-container` type](../manual/kinds/ext-containe The command to be executed on the nodes is provided with `--cmd` flag. The command is provided as a string, thus it needs to be quoted to accommodate for spaces or special characters. +Mutually exclusive with `--interactive`. + ### format The `--format | -f` flag allows selecting between plain text format output or a json variant. Consult with the examples below to see the differences between these two formatting options. @@ -38,6 +42,35 @@ Exec command should either be provided with a topology file, or labels, or both. Recall that you can check the labels attached to the nodes with `docker inspect -f '{{.Config.Labels | json}}' ` command. +### interactive + +`--interactive | -i` opens an interactive shell inside the single container matched by the topology/label filters and the optional `containername` positional argument. The current process is replaced by the shell, so stdin/stdout/stderr are connected directly. + +When `--topo` / `--name` are not given, the topology file is auto-detected from the current directory. The optional `containername` argument is a substring matched against container names after all other filters have been applied. + +When more than one container matches, the command prints the list and exits with an error. Narrow the selection with a more specific name substring, `--label clab-node-name=`, or `--topo`. + +Mutually exclusive with `--cmd`. + +### shell + +`--shell | -s` overrides the shell used when `--interactive` is given. The value is split on whitespace and passed as the command to the container runtime's exec, e.g. `--shell '/bin/sh'` or `--shell '/bin/bash -l'`. + +When omitted the shell is resolved in the following priority order: + +1. **`--shell` flag** — explicit CLI override, highest priority. +2. **`CLAB_EXEC_INTERACTIVE_SHELL` env var** — set on the node via the topology `env:` block (per-node, per-group, per-kind, or under `defaults:`). Useful when a specific node needs a different shell from the kind default, for example: + ```yaml + nodes: + web: + kind: linux + image: example.com/myimage:latest + env: + CLAB_EXEC_INTERACTIVE_SHELL: /bin/bash -l + ``` +3. **Kind default** — each node kind ships a built-in default shell (e.g. `ceos` uses `/usr/bin/Cli -p 15`, `srl` uses `/opt/srlinux/bin/sr_cli`, `fdio_vpp` uses `/usr/bin/nsenter --net=/run/netns/dataplane /bin/bash`). +4. **`/bin/sh`** — final fallback when no topology is loaded or the node kind has no specific default. + ## Examples ### Execute a command on all nodes of the lab @@ -165,3 +198,23 @@ Free Memory : 21911914 kB } } ``` + +### Open an interactive shell in a single node + +Connect to `clab-srl02-srl1` using the auto-detected shell for its image (topology auto-detected from the current directory): + +```bash +❯ containerlab exec -i srl1 +``` + +Same but with an explicit topology file: + +```bash +❯ containerlab exec -t srl02.clab.yml -i srl1 +``` + +Connect to the same node but force a specific shell: + +```bash +❯ containerlab exec -i srl1 -s /bin/bash +``` diff --git a/exec/exec.go b/exec/exec.go index 108d2381a6..2293d75a8b 100644 --- a/exec/exec.go +++ b/exec/exec.go @@ -245,6 +245,9 @@ func (ec *ExecCollection) Log() { defer ec.m.RUnlock() for k, execResults := range ec.execEntries { for _, er := range execResults { + if er == nil { + continue + } switch { case er.GetReturnCode() != 0: log.Error( diff --git a/nodes/ceos/ceos.go b/nodes/ceos/ceos.go index a89a098666..27292a4f24 100644 --- a/nodes/ceos/ceos.go +++ b/nodes/ceos/ceos.go @@ -385,6 +385,8 @@ func (n *ceos) ceosPostDeploy(_ context.Context) error { return err } +func (*ceos) ExecInteractiveShell() []string { return []string{"/usr/bin/Cli", "-p", "15"} } + // CheckInterfaceName checks if a name of the interface referenced in the topology file correct. func (n *ceos) CheckInterfaceName() error { // allow eth and et interfaces diff --git a/nodes/default_node.go b/nodes/default_node.go index efa4ee6ca1..43ba604a5a 100644 --- a/nodes/default_node.go +++ b/nodes/default_node.go @@ -89,6 +89,7 @@ func (d *DefaultNode) WithRuntime(r clabruntime.ContainerRuntime) { d func (d *DefaultNode) GetRuntime() clabruntime.ContainerRuntime { return d.Runtime } func (d *DefaultNode) Config() *clabtypes.NodeConfig { return d.Cfg } func (*DefaultNode) PostDeploy(_ context.Context, _ *PostDeployParams) error { return nil } +func (*DefaultNode) ExecInteractiveShell() []string { return []string{"/bin/sh"} } // PreDeploy is a common method for all nodes that is called before the node is deployed. func (d *DefaultNode) PreDeploy(_ context.Context, params *PreDeployParams) error { diff --git a/nodes/fdio_vpp/fdio_vpp.go b/nodes/fdio_vpp/fdio_vpp.go index 936aaae878..e8c76ec960 100644 --- a/nodes/fdio_vpp/fdio_vpp.go +++ b/nodes/fdio_vpp/fdio_vpp.go @@ -175,6 +175,10 @@ func (n *fdio_vpp) SaveConfig(ctx context.Context) (*clabnodes.SaveConfigResult, return nil, nil } +func (*fdio_vpp) ExecInteractiveShell() []string { + return []string{"/usr/bin/nsenter", "--net=/run/netns/dataplane", "/bin/bash"} +} + // CheckInterfaceName allows any interface name for vpp nodes, but checks // if eth0 is only used with network-mode=none. func (n *fdio_vpp) CheckInterfaceName() error { diff --git a/nodes/node.go b/nodes/node.go index 6e36562a59..4298b1cc0a 100644 --- a/nodes/node.go +++ b/nodes/node.go @@ -142,6 +142,10 @@ type Node interface { GetNSPath(ctx context.Context) (string, error) // Generate the host entries for this node GetHostsEntries(ctx context.Context) (clabtypes.HostEntries, error) + // ExecInteractiveShell returns the argv for the interactive shell to use + // when the user runs 'exec --interactive'. Nodes override this to provide + // their NOS CLI or a specific shell invocation. + ExecInteractiveShell() []string } type NodeOption func(Node) diff --git a/nodes/srl/srl.go b/nodes/srl/srl.go index 8ba7570588..5e7bede88d 100644 --- a/nodes/srl/srl.go +++ b/nodes/srl/srl.go @@ -352,6 +352,8 @@ func (n *srl) PostDeploy(ctx context.Context, params *clabnodes.PostDeployParams return n.generateCheckpoint(ctx) } +func (*srl) ExecInteractiveShell() []string { return []string{"/opt/srlinux/bin/sr_cli"} } + func (n *srl) SaveConfig(ctx context.Context) (*clabnodes.SaveConfigResult, error) { cmd, _ := clabexec.NewExecCmdFromString(saveCmd)