container/exec: Support ConsoleSize

Now client have the possibility to set the console size of the executed
process immediately at the creation. This makes a difference for example
when executing commands that output some kind of text user interface
which is bounded by the console dimensions.

Signed-off-by: Paweł Gronowski <pawel.gronowski@docker.com>
This commit is contained in:
Paweł Gronowski 2022-06-15 09:28:20 +02:00
parent 9c4987ee6b
commit 56a20dbc19
14 changed files with 142 additions and 11 deletions

View File

@ -17,7 +17,7 @@ type execBackend interface {
ContainerExecCreate(name string, config *types.ExecConfig) (string, error)
ContainerExecInspect(id string) (*backend.ExecInspect, error)
ContainerExecResize(name string, height, width int) error
ContainerExecStart(ctx context.Context, name string, stdin io.Reader, stdout io.Writer, stderr io.Writer) error
ContainerExecStart(ctx context.Context, name string, options container.ExecStartOptions) error
ExecExists(name string) (bool, error)
}

View File

@ -9,6 +9,7 @@ import (
"github.com/docker/docker/api/server/httputils"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/versions"
"github.com/docker/docker/errdefs"
"github.com/docker/docker/pkg/stdcopy"
@ -46,6 +47,12 @@ func (s *containerRouter) postContainerExecCreate(ctx context.Context, w http.Re
return execCommandError{}
}
version := httputils.VersionFromContext(ctx)
if versions.LessThan(version, "1.42") {
// Not supported by API versions before 1.42
execConfig.ConsoleSize = nil
}
// Register an instance of Exec in container.
id, err := s.backend.ContainerExecCreate(vars["name"], execConfig)
if err != nil {
@ -88,6 +95,18 @@ func (s *containerRouter) postContainerExecStart(ctx context.Context, w http.Res
return err
}
if execStartCheck.ConsoleSize != nil {
// Not supported before 1.42
if versions.LessThan(version, "1.42") {
execStartCheck.ConsoleSize = nil
}
// No console without tty
if !execStartCheck.Tty {
execStartCheck.ConsoleSize = nil
}
}
if !execStartCheck.Detach {
var err error
// Setting up the streaming http interface.
@ -121,9 +140,16 @@ func (s *containerRouter) postContainerExecStart(ctx context.Context, w http.Res
}
}
options := container.ExecStartOptions{
Stdin: stdin,
Stdout: stdout,
Stderr: stderr,
ConsoleSize: execStartCheck.ConsoleSize,
}
// Now run the user process in container.
// Maybe we should we pass ctx here if we're not detaching?
if err := s.backend.ContainerExecStart(context.Background(), execName, stdin, stdout, stderr); err != nil {
if err := s.backend.ContainerExecStart(context.Background(), execName, options); err != nil {
if execStartCheck.Detach {
return err
}

View File

@ -966,6 +966,7 @@ definitions:
type: "array"
description: |
Initial console size, as an `[height, width]` array.
x-nullable: true
minItems: 2
maxItems: 2
items:
@ -9207,6 +9208,15 @@ paths:
AttachStderr:
type: "boolean"
description: "Attach to `stderr` of the exec command."
ConsoleSize:
type: "array"
description: "Initial console size, as an `[height, width]` array."
x-nullable: true
minItems: 2
maxItems: 2
items:
type: "integer"
minimum: 0
DetachKeys:
type: "string"
description: |
@ -9296,9 +9306,19 @@ paths:
Tty:
type: "boolean"
description: "Allocate a pseudo-TTY."
ConsoleSize:
type: "array"
description: "Initial console size, as an `[height, width]` array."
x-nullable: true
minItems: 2
maxItems: 2
items:
type: "integer"
minimum: 0
example:
Detach: false
Tty: false
Tty: true
ConsoleSize: [80, 64]
- name: "id"
in: "path"
description: "Exec instance ID"

View File

@ -33,6 +33,7 @@ type ExecConfig struct {
User string // User that will run the command
Privileged bool // Is the container in privileged mode
Tty bool // Attach standard streams to a tty.
ConsoleSize *[2]uint `json:",omitempty"` // Initial console size [height, width]
AttachStdin bool // Attach the standard input, makes possible user interaction
AttachStderr bool // Attach the standard error
AttachStdout bool // Attach the standard output

View File

@ -1,6 +1,7 @@
package container // import "github.com/docker/docker/api/types/container"
import (
"io"
"time"
"github.com/docker/docker/api/types/strslice"
@ -52,6 +53,14 @@ type HealthConfig struct {
Retries int `json:",omitempty"`
}
// ExecStartOptions holds the options to start container's exec.
type ExecStartOptions struct {
Stdin io.Reader
Stdout io.Writer
Stderr io.Writer
ConsoleSize *[2]uint `json:",omitempty"`
}
// Config contains the configuration data about a container.
// It should hold only portable information about the container.
// Here, "portable" means "independent from the host we are running on".

View File

@ -390,6 +390,8 @@ type ExecStartCheck struct {
Detach bool
// Check if there's a tty
Tty bool
// Terminal size [height, width], unused if Tty == false
ConsoleSize *[2]uint `json:",omitempty"`
}
// HealthcheckResult stores information about a single run of a healthcheck probe

View File

@ -5,6 +5,7 @@ import (
"encoding/json"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/versions"
)
// ContainerExecCreate creates a new exec configuration to run an exec process.
@ -14,6 +15,9 @@ func (cli *Client) ContainerExecCreate(ctx context.Context, container string, co
if err := cli.NewVersionError("1.25", "env"); len(config.Env) != 0 && err != nil {
return response, err
}
if versions.LessThan(cli.ClientVersion(), "1.42") {
config.ConsoleSize = nil
}
resp, err := cli.post(ctx, "/containers/"+container+"/exec", nil, config, nil)
defer ensureReaderClosed(resp)
@ -26,6 +30,9 @@ func (cli *Client) ContainerExecCreate(ctx context.Context, container string, co
// ContainerExecStart starts an exec process already created in the docker host.
func (cli *Client) ContainerExecStart(ctx context.Context, execID string, config types.ExecStartCheck) error {
if versions.LessThan(cli.ClientVersion(), "1.42") {
config.ConsoleSize = nil
}
resp, err := cli.post(ctx, "/exec/"+execID+"/start", nil, config, nil)
ensureReaderClosed(resp)
return err
@ -36,6 +43,9 @@ func (cli *Client) ContainerExecStart(ctx context.Context, execID string, config
// and the a reader to get output. It's up to the called to close
// the hijacked connection by calling types.HijackedResponse.Close.
func (cli *Client) ContainerExecAttach(ctx context.Context, execID string, config types.ExecStartCheck) (types.HijackedResponse, error) {
if versions.LessThan(cli.ClientVersion(), "1.42") {
config.ConsoleSize = nil
}
headers := map[string][]string{
"Content-Type": {"application/json"},
}

View File

@ -9,6 +9,7 @@ import (
"time"
"github.com/docker/docker/api/types"
containertypes "github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/strslice"
"github.com/docker/docker/container"
"github.com/docker/docker/container/stream"
@ -121,6 +122,7 @@ func (daemon *Daemon) ContainerExecCreate(name string, config *types.ExecConfig)
execConfig.Entrypoint = entrypoint
execConfig.Args = args
execConfig.Tty = config.Tty
execConfig.ConsoleSize = config.ConsoleSize
execConfig.Privileged = config.Privileged
execConfig.User = config.User
execConfig.WorkingDir = config.WorkingDir
@ -150,7 +152,7 @@ func (daemon *Daemon) ContainerExecCreate(name string, config *types.ExecConfig)
// ContainerExecStart starts a previously set up exec instance. The
// std streams are set up.
// If ctx is cancelled, the process is terminated.
func (daemon *Daemon) ContainerExecStart(ctx context.Context, name string, stdin io.Reader, stdout io.Writer, stderr io.Writer) (err error) {
func (daemon *Daemon) ContainerExecStart(ctx context.Context, name string, options containertypes.ExecStartOptions) (err error) {
var (
cStdin io.ReadCloser
cStdout, cStderr io.Writer
@ -199,20 +201,20 @@ func (daemon *Daemon) ContainerExecStart(ctx context.Context, name string, stdin
}
}()
if ec.OpenStdin && stdin != nil {
if ec.OpenStdin && options.Stdin != nil {
r, w := io.Pipe()
go func() {
defer w.Close()
defer logrus.Debug("Closing buffered stdin pipe")
pools.Copy(w, stdin)
pools.Copy(w, options.Stdin)
}()
cStdin = r
}
if ec.OpenStdout {
cStdout = stdout
cStdout = options.Stdout
}
if ec.OpenStderr {
cStderr = stderr
cStderr = options.Stderr
}
if ec.OpenStdin {
@ -238,6 +240,18 @@ func (daemon *Daemon) ContainerExecStart(ctx context.Context, name string, stdin
p.Cwd = ec.WorkingDir
p.Terminal = ec.Tty
consoleSize := options.ConsoleSize
// If size isn't specified for start, use the one provided for create
if consoleSize == nil {
consoleSize = ec.ConsoleSize
}
if p.Terminal && consoleSize != nil {
p.ConsoleSize = &specs.Box{
Height: consoleSize[0],
Width: consoleSize[1],
}
}
if p.Cwd == "" {
p.Cwd = "/"
}

View File

@ -35,6 +35,7 @@ type Config struct {
WorkingDir string
Env []string
Pid int
ConsoleSize *[2]uint
}
// NewConfig initializes the a new exec configuration

View File

@ -10,6 +10,7 @@ import (
"time"
"github.com/docker/docker/api/types"
containertypes "github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/strslice"
"github.com/docker/docker/container"
"github.com/docker/docker/daemon/exec"
@ -97,7 +98,13 @@ func (p *cmdProbe) run(ctx context.Context, d *Daemon, cntr *container.Container
probeCtx, cancelProbe := context.WithCancel(ctx)
defer cancelProbe()
execErr := make(chan error, 1)
go func() { execErr <- d.ContainerExecStart(probeCtx, execConfig.ID, nil, output, output) }()
options := containertypes.ExecStartOptions{
Stdout: output,
Stderr: output,
}
go func() { execErr <- d.ContainerExecStart(probeCtx, execConfig.ID, options) }()
// Starting an exec can take a significant amount of time: on the order
// of 1s in extreme cases. The time it takes dockerd and containerd to

View File

@ -101,6 +101,8 @@ keywords: "API, Docker, rcli, REST, documentation"
created if missing. This brings parity with `Binds`
* `POST /containers/create` rejects request if BindOptions|VolumeOptions|TmpfsOptions
is set with a non-matching mount Type.
* `POST /containers/{id}/exec` now accepts an optional `ConsoleSize` parameter.
It allows to set the console size of the executed process immediately when it's created.
## v1.41 API changes

View File

@ -0,0 +1,34 @@
package container // import "github.com/docker/docker/integration/container"
import (
"context"
"strings"
"testing"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/versions"
"github.com/docker/docker/integration/internal/container"
"gotest.tools/v3/assert"
"gotest.tools/v3/skip"
)
func TestExecConsoleSize(t *testing.T) {
skip.If(t, testEnv.DaemonInfo.OSType != "linux")
skip.If(t, versions.LessThan(testEnv.DaemonAPIVersion(), "1.42"), "skip test from new feature")
defer setupTest(t)()
client := testEnv.APIClient()
ctx := context.Background()
cID := container.Run(ctx, t, client, container.WithImage("busybox"))
result, err := container.Exec(ctx, client, cID, []string{"stty", "size"},
func(ec *types.ExecConfig) {
ec.Tty = true
ec.ConsoleSize = &[2]uint{57, 123}
},
)
assert.NilError(t, err)
assert.Equal(t, strings.TrimSpace(result.Stdout()), "57 123")
}

View File

@ -187,7 +187,7 @@ func TestPrivilegedHostDevices(t *testing.T) {
}
}
func TestConsoleSize(t *testing.T) {
func TestRunConsoleSize(t *testing.T) {
skip.If(t, testEnv.DaemonInfo.OSType != "linux")
skip.If(t, versions.LessThan(testEnv.DaemonAPIVersion(), "1.42"), "skip test from new feature")

View File

@ -35,13 +35,18 @@ func (res *ExecResult) Combined() string {
// containing stdout, stderr, and exit code. Note:
// - this is a synchronous operation;
// - cmd stdin is closed.
func Exec(ctx context.Context, cli client.APIClient, id string, cmd []string) (ExecResult, error) {
func Exec(ctx context.Context, cli client.APIClient, id string, cmd []string, ops ...func(*types.ExecConfig)) (ExecResult, error) {
// prepare exec
execConfig := types.ExecConfig{
AttachStdout: true,
AttachStderr: true,
Cmd: cmd,
}
for _, op := range ops {
op(&execConfig)
}
cresp, err := cli.ContainerExecCreate(ctx, id, execConfig)
if err != nil {
return ExecResult{}, err