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
134 changes: 130 additions & 4 deletions cmd/exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
},
}

Expand All @@ -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")
}
Expand Down Expand Up @@ -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()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

still execinteractiveshell is missleading...
also see my comment here:
2b5bb90#r181794850

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())
}
6 changes: 4 additions & 2 deletions cmd/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
57 changes: 55 additions & 2 deletions docs/cmd/exec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
Expand All @@ -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}}' <container-name>` 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=<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
Expand Down Expand Up @@ -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
```
3 changes: 3 additions & 0 deletions exec/exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 2 additions & 0 deletions nodes/ceos/ceos.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions nodes/default_node.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 4 additions & 0 deletions nodes/fdio_vpp/fdio_vpp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 4 additions & 0 deletions nodes/node.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This is rather GetInteractiveShellCommand... not ExecInteractiveShell ... nothing is executed really when calling this function

}

type NodeOption func(Node)
Expand Down
2 changes: 2 additions & 0 deletions nodes/srl/srl.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down