From 72398ee6c7556b1f46bf5949a4c30355fa3dde0d Mon Sep 17 00:00:00 2001 From: David LAROCHETTE Date: Thu, 3 Sep 2026 21:20:30 +0200 Subject: [PATCH] service: add 'docker service exec' command Add a 'docker service exec' subcommand that executes a command in a running task of a service, on whichever node the task is scheduled, addressing a long-standing request (moby/moby#27552). The service is resolved to its first running task (or the task given with --task-id). When the task runs on the node the client is talking to, this is a plain container exec. When it runs on another node, the client connects to that node's engine through the existing SSH connection helper (the DOCKER_HOST=ssh:// mechanism) and reuses the regular exec plumbing, so interactive mode, TTY allocation, and exit-code propagation behave exactly like a local 'docker exec'. The node is reached at the address advertised in its status, falling back to the node hostname; --ssh-user and --ssh-option allow adjusting the SSH connection (e.g. jump hosts). Signed-off-by: David LAROCHETTE --- cli/command/service/cmd.go | 1 + cli/command/service/exec.go | 170 +++++++++++++++++++++ cli/command/service/exec_test.go | 75 +++++++++ docs/reference/commandline/service.md | 23 +-- docs/reference/commandline/service_exec.md | 83 ++++++++++ 5 files changed, 341 insertions(+), 11 deletions(-) create mode 100644 cli/command/service/exec.go create mode 100644 cli/command/service/exec_test.go create mode 100644 docs/reference/commandline/service_exec.md diff --git a/cli/command/service/cmd.go b/cli/command/service/cmd.go index 1620edc19f43..206514ddddb6 100644 --- a/cli/command/service/cmd.go +++ b/cli/command/service/cmd.go @@ -26,6 +26,7 @@ func newServiceCommand(dockerCLI command.Cli) *cobra.Command { } cmd.AddCommand( newCreateCommand(dockerCLI), + newExecCommand(dockerCLI), newInspectCommand(dockerCLI), newPsCommand(dockerCLI), newListCommand(dockerCLI), diff --git a/cli/command/service/exec.go b/cli/command/service/exec.go new file mode 100644 index 000000000000..1d9ea845bb84 --- /dev/null +++ b/cli/command/service/exec.go @@ -0,0 +1,170 @@ +package service + +import ( + "context" + "fmt" + + "github.com/docker/cli/cli" + "github.com/docker/cli/cli/command" + "github.com/docker/cli/cli/command/container" + "github.com/docker/cli/cli/connhelper" + "github.com/moby/moby/api/types/swarm" + "github.com/moby/moby/client" + "github.com/spf13/cobra" +) + +type execOptions struct { + container.ExecOptions + + service string + taskID string + sshUser string + sshOpts []string +} + +// newExecCommand creates a new cobra.Command for "docker service exec". +func newExecCommand(dockerCLI command.Cli) *cobra.Command { + options := execOptions{ExecOptions: container.NewExecOptions()} + + cmd := &cobra.Command{ + Use: "exec [OPTIONS] SERVICE COMMAND [ARG...]", + Short: "Execute a command in a running task of a service, on whichever node it runs", + Args: cli.RequiresMinArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + options.service = args[0] + options.Command = args[1:] + return runExec(cmd.Context(), dockerCLI, options) + }, + Annotations: map[string]string{"version": "1.29"}, + ValidArgsFunction: completeServiceNames(dockerCLI), + DisableFlagsInUseLine: true, + } + + flags := cmd.Flags() + flags.SetInterspersed(false) + flags.BoolVarP(&options.Interactive, "interactive", "i", false, "Keep STDIN open even if not attached") + flags.BoolVarP(&options.TTY, "tty", "t", false, "Allocate a pseudo-TTY") + flags.StringVarP(&options.User, "user", "u", "", `Username or UID (format: "[:]")`) + flags.StringVarP(&options.Workdir, "workdir", "w", "", "Working directory inside the container") + flags.VarP(&options.Env, "env", "e", "Set environment variables") + flags.StringVar(&options.DetachKeys, "detach-keys", "", "Override the key sequence for detaching a container") + flags.StringVar(&options.taskID, "task-id", "", "Exec into a specific task instead of the first running one") + flags.StringVar(&options.sshUser, "ssh-user", "", "Username for the SSH connection to the node") + flags.StringSliceVar(&options.sshOpts, "ssh-option", nil, `Additional flags passed to ssh (e.g. "-J bastion")`) + return cmd +} + +// runExec is the swarm implementation of docker service exec. It resolves the +// service to a running task, then runs the exec either directly (if the task +// runs on the current node) or through an SSH tunnel to the node running the +// task, using the same connection helper as DOCKER_HOST=ssh://. +func runExec(ctx context.Context, dockerCLI command.Cli, options execOptions) error { + apiClient := dockerCLI.Client() + + service, err := apiClient.ServiceInspect(ctx, options.service, client.ServiceInspectOptions{}) + if err != nil { + return err + } + + tasks, err := apiClient.TaskList(ctx, client.TaskListOptions{ + Filters: make(client.Filters). + Add("service", service.Service.ID). + Add("desired-state", string(swarm.TaskStateRunning)), + }) + if err != nil { + return err + } + task, err := pickTask(tasks.Items, options.taskID) + if err != nil { + return fmt.Errorf("service %s: %w", options.service, err) + } + + info, err := apiClient.Info(ctx, client.InfoOptions{}) + if err != nil { + return err + } + + containerID := task.Status.ContainerStatus.ContainerID + if info.Info.Swarm.NodeID == task.NodeID { + // The task runs on the node we are already talking to: plain exec. + return container.RunExec(ctx, dockerCLI, containerID, options.ExecOptions) + } + + node, err := apiClient.NodeInspect(ctx, task.NodeID, client.NodeInspectOptions{}) + if err != nil { + return err + } + + remoteClient, err := newNodeClient(nodeSSHHost(node.Node, options.sshUser), options.sshOpts) + if err != nil { + return err + } + defer remoteClient.Close() + + fmt.Fprintf(dockerCLI.Err(), "executing on node %s (%s)\n", node.Node.Description.Hostname, task.NodeID) + return container.RunExec(ctx, &nodeCli{Cli: dockerCLI, client: remoteClient}, containerID, options.ExecOptions) +} + +// pickTask returns the task to exec into: the one matching taskID if given +// (which must be running), otherwise the first running task. +func pickTask(tasks []swarm.Task, taskID string) (swarm.Task, error) { + for _, t := range tasks { + if taskID != "" { + if t.ID != taskID { + continue + } + if t.Status.State != swarm.TaskStateRunning || t.Status.ContainerStatus == nil { + return swarm.Task{}, fmt.Errorf("task %s is not running (state: %s)", taskID, t.Status.State) + } + return t, nil + } + if t.Status.State == swarm.TaskStateRunning && t.Status.ContainerStatus != nil { + return t, nil + } + } + if taskID != "" { + return swarm.Task{}, fmt.Errorf("task %s not found among tasks of the service", taskID) + } + return swarm.Task{}, fmt.Errorf("no running task found") +} + +// nodeSSHHost returns the ssh:// URL used to reach the node running the +// task. It prefers the address advertised in the node status, falling back +// to the node hostname (relying on DNS) when it is unspecified. +func nodeSSHHost(node swarm.Node, sshUser string) string { + addr := node.Status.Addr + if addr == "" || addr == "0.0.0.0" { + addr = node.Description.Hostname + } + if sshUser != "" { + return "ssh://" + sshUser + "@" + addr + } + return "ssh://" + addr +} + +// newNodeClient returns an API client connected to the docker engine on the +// given node through an SSH tunnel ("docker system dial-stdio"), like +// DOCKER_HOST=ssh:// does. +func newNodeClient(sshHost string, sshFlags []string) (client.APIClient, error) { + helper, err := connhelper.GetConnectionHelperWithSSHOpts(sshHost, sshFlags) + if err != nil { + return nil, err + } + return client.New( + client.WithHost(helper.Host), + client.WithDialContext(helper.Dialer), + client.WithAPIVersionNegotiation(), + ) +} + +// nodeCli decorates a command.Cli, substituting the API client with one +// connected to the node running the task, so that the regular exec plumbing +// (TTY, resize, exit code) is reused as-is. +type nodeCli struct { + command.Cli + client client.APIClient +} + +func (c *nodeCli) Client() client.APIClient { + return c.client +} diff --git a/cli/command/service/exec_test.go b/cli/command/service/exec_test.go new file mode 100644 index 000000000000..17b1d3c001a4 --- /dev/null +++ b/cli/command/service/exec_test.go @@ -0,0 +1,75 @@ +package service + +import ( + "testing" + + "github.com/moby/moby/api/types/swarm" + "gotest.tools/v3/assert" + is "gotest.tools/v3/assert/cmp" +) + +func TestPickTask(t *testing.T) { + running := swarm.TaskStatus{ + State: swarm.TaskStateRunning, + ContainerStatus: &swarm.ContainerStatus{ContainerID: "c2"}, + } + tasks := []swarm.Task{ + {ID: "t1", NodeID: "n1", Status: swarm.TaskStatus{State: swarm.TaskStateFailed}}, + {ID: "t2", NodeID: "n2", Status: running}, + {ID: "t3", NodeID: "n3", Status: running}, + } + + t.Run("first running task by default", func(t *testing.T) { + task, err := pickTask(tasks, "") + assert.NilError(t, err) + assert.Check(t, is.Equal(task.ID, "t2")) + }) + + t.Run("explicit task id", func(t *testing.T) { + task, err := pickTask(tasks, "t3") + assert.NilError(t, err) + assert.Check(t, is.Equal(task.ID, "t3")) + }) + + t.Run("explicit task id not running", func(t *testing.T) { + _, err := pickTask(tasks, "t1") + assert.ErrorContains(t, err, "not running") + }) + + t.Run("explicit task id not found", func(t *testing.T) { + _, err := pickTask(tasks, "nope") + assert.ErrorContains(t, err, "not found") + }) + + t.Run("no running task", func(t *testing.T) { + _, err := pickTask(nil, "") + assert.ErrorContains(t, err, "no running task") + }) + + t.Run("running task without container status", func(t *testing.T) { + _, err := pickTask([]swarm.Task{ + {ID: "t4", Status: swarm.TaskStatus{State: swarm.TaskStateRunning}}, + }, "") + assert.ErrorContains(t, err, "no running task") + }) +} + +func TestNodeSSHHost(t *testing.T) { + t.Run("prefers status addr", func(t *testing.T) { + n := swarm.Node{Status: swarm.NodeStatus{Addr: "10.0.0.5"}} + assert.Check(t, is.Equal(nodeSSHHost(n, ""), "ssh://10.0.0.5")) + }) + + t.Run("falls back to hostname on zero addr", func(t *testing.T) { + n := swarm.Node{ + Status: swarm.NodeStatus{Addr: "0.0.0.0"}, + Description: swarm.NodeDescription{Hostname: "pallas"}, + } + assert.Check(t, is.Equal(nodeSSHHost(n, ""), "ssh://pallas")) + }) + + t.Run("ssh user is prepended", func(t *testing.T) { + n := swarm.Node{Status: swarm.NodeStatus{Addr: "10.0.0.5"}} + assert.Check(t, is.Equal(nodeSSHHost(n, "core"), "ssh://core@10.0.0.5")) + }) +} diff --git a/docs/reference/commandline/service.md b/docs/reference/commandline/service.md index 9ab7b9b4862c..4e57dfe3d8b2 100644 --- a/docs/reference/commandline/service.md +++ b/docs/reference/commandline/service.md @@ -5,17 +5,18 @@ Manage Swarm services ### Subcommands -| Name | Description | -|:----------------------------------|:-----------------------------------------------------| -| [`create`](service_create.md) | Create a new service | -| [`inspect`](service_inspect.md) | Display detailed information on one or more services | -| [`logs`](service_logs.md) | Fetch the logs of a service or task | -| [`ls`](service_ls.md) | List services | -| [`ps`](service_ps.md) | List the tasks of one or more services | -| [`rm`](service_rm.md) | Remove one or more services | -| [`rollback`](service_rollback.md) | Revert changes to a service's configuration | -| [`scale`](service_scale.md) | Scale one or multiple replicated services | -| [`update`](service_update.md) | Update a service | +| Name | Description | +|:----------------------------------|:----------------------------------------------------------------------------| +| [`create`](service_create.md) | Create a new service | +| [`exec`](service_exec.md) | Execute a command in a running task of a service, on whichever node it runs | +| [`inspect`](service_inspect.md) | Display detailed information on one or more services | +| [`logs`](service_logs.md) | Fetch the logs of a service or task | +| [`ls`](service_ls.md) | List services | +| [`ps`](service_ps.md) | List the tasks of one or more services | +| [`rm`](service_rm.md) | Remove one or more services | +| [`rollback`](service_rollback.md) | Revert changes to a service's configuration | +| [`scale`](service_scale.md) | Scale one or multiple replicated services | +| [`update`](service_update.md) | Update a service | diff --git a/docs/reference/commandline/service_exec.md b/docs/reference/commandline/service_exec.md new file mode 100644 index 000000000000..83a55dfc9bf7 --- /dev/null +++ b/docs/reference/commandline/service_exec.md @@ -0,0 +1,83 @@ +# service exec + + +Execute a command in a running task of a service, on whichever node it runs + +### Options + +| Name | Type | Default | Description | +|:----------------------|:--------------|:--------|:-----------------------------------------------------------| +| `--detach-keys` | `string` | | Override the key sequence for detaching a container | +| `-e`, `--env` | `list` | | Set environment variables | +| `-i`, `--interactive` | `bool` | | Keep STDIN open even if not attached | +| `--ssh-option` | `stringSlice` | | Additional flags passed to ssh (e.g. `-J bastion`) | +| `--ssh-user` | `string` | | Username for the SSH connection to the node | +| `--task-id` | `string` | | Exec into a specific task instead of the first running one | +| `-t`, `--tty` | `bool` | | Allocate a pseudo-TTY | +| `-u`, `--user` | `string` | | Username or UID (format: `[:]`) | +| `-w`, `--workdir` | `string` | | Working directory inside the container | + + + + +## Description + +Executes a command in a running task of a service, wherever that task is +currently scheduled, without having to figure out the node and container +yourself. + +The command resolves the service to a running task (the first running one, +or the one given with `--task-id`). If the task runs on the node the client +is already connected to, this behaves exactly like `docker exec`. If the +task runs on another node, the client opens an SSH connection to that node +(the same mechanism as `DOCKER_HOST=ssh://`, using `docker system +dial-stdio` on the remote side) and runs the exec through it, so +interactive sessions, TTY allocation, and exit-code propagation work the +same as a local `docker exec`. + +Reaching a remote node requires: + +- SSH access to the node, as the current user or the one given with + `--ssh-user` (key-based authentication; the connection is established + with the local `ssh` binary, so `~/.ssh/config` is honored). +- The `docker` CLI in the `PATH` of the remote user, with access to the + local engine socket. + +The address used to reach the node is the address advertised in the node's +status (`docker node inspect --format '{{ .Status.Addr }}'`), falling back +to the node hostname when unspecified. Use `--ssh-option` to pass extra +flags to ssh (for example `--ssh-option "-J bastion"` to go through a jump +host). Options taking a value are best passed in `-o Key=value` form (for +example `--ssh-option "-oIdentityFile=~/.ssh/swarm_key"`), matching the +behavior of `DOCKER_HOST=ssh://` connections. + +> [!NOTE] +> This command has to be run targeting a manager node. + +## Examples + +Open a shell in the (single) task of a service, wherever it runs: + +```console +$ docker service exec -it myapp_web sh +executing on node worker-2 (kf1r2caqpuivn5fq1o0dj1c1s) +/ # +``` + +Run a command in a specific task of a service: + +```console +$ docker service ps myapp_web --format '{{ .ID }} {{ .Node }}' +wxn1w1twpr5f worker-2 +u1o8lhbmja0z worker-3 +$ docker service exec --task-id u1o8lhbmja0z myapp_web cat /etc/hostname +executing on node worker-3 (bpn0umb69befk32u9k1hfrf0k) +1742f9c6f1e4 +``` + +## Related commands + +* [service inspect](service_inspect.md) +* [service logs](service_logs.md) +* [service ls](service_ls.md) +* [service ps](service_ps.md)