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
1 change: 1 addition & 0 deletions cli/command/service/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ func newServiceCommand(dockerCLI command.Cli) *cobra.Command {
}
cmd.AddCommand(
newCreateCommand(dockerCLI),
newExecCommand(dockerCLI),
newInspectCommand(dockerCLI),
newPsCommand(dockerCLI),
newListCommand(dockerCLI),
Expand Down
170 changes: 170 additions & 0 deletions cli/command/service/exec.go
Original file line number Diff line number Diff line change
@@ -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: "<name|uid>[:<group|gid>]")`)
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
}
75 changes: 75 additions & 0 deletions cli/command/service/exec_test.go
Original file line number Diff line number Diff line change
@@ -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"))
})
}
23 changes: 12 additions & 11 deletions docs/reference/commandline/service.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |



Expand Down
83 changes: 83 additions & 0 deletions docs/reference/commandline/service_exec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# service exec

<!---MARKER_GEN_START-->
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: `<name\|uid>[:<group\|gid>]`) |
| `-w`, `--workdir` | `string` | | Working directory inside the container |


<!---MARKER_GEN_END-->

## 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)