mirror of
https://github.com/moby/moby.git
synced 2026-01-11 18:51:37 +00:00
This field was added in5130fe5d38, which added it for use as intermediate struct when parsing CLI flags (through `runconfig.ParseExec`) inc786a8ee5e. Commit9d9dff3d0drewrote the CLI to use Cobra, and as part of this introduced a separate `execOptions` type in `api/client/container`. Signed-off-by: Sebastiaan van Stijn <github@gone.nl>
425 lines
14 KiB
Go
425 lines
14 KiB
Go
package container
|
|
|
|
import (
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
cerrdefs "github.com/containerd/errdefs"
|
|
"github.com/docker/docker/api/types"
|
|
containertypes "github.com/docker/docker/api/types/container"
|
|
"github.com/docker/docker/integration/internal/build"
|
|
"github.com/docker/docker/integration/internal/container"
|
|
"github.com/docker/docker/testutil/fakecontext"
|
|
req "github.com/docker/docker/testutil/request"
|
|
"gotest.tools/v3/assert"
|
|
is "gotest.tools/v3/assert/cmp"
|
|
"gotest.tools/v3/skip"
|
|
)
|
|
|
|
// TestExecWithCloseStdin adds case for moby#37870 issue.
|
|
func TestExecWithCloseStdin(t *testing.T) {
|
|
skip.If(t, testEnv.RuntimeIsWindowsContainerd(), "FIXME. Hang on Windows + containerd combination")
|
|
ctx := setupTest(t)
|
|
|
|
apiClient := testEnv.APIClient()
|
|
|
|
// run top with detached mode
|
|
cID := container.Run(ctx, t, apiClient)
|
|
|
|
const expected = "closeIO"
|
|
execResp, err := apiClient.ContainerExecCreate(ctx, cID, containertypes.ExecOptions{
|
|
AttachStdin: true,
|
|
AttachStdout: true,
|
|
Cmd: []string{"sh", "-c", "cat && echo " + expected},
|
|
})
|
|
assert.NilError(t, err)
|
|
|
|
resp, err := apiClient.ContainerExecAttach(ctx, execResp.ID, containertypes.ExecAttachOptions{})
|
|
assert.NilError(t, err)
|
|
defer resp.Close()
|
|
|
|
// close stdin to send EOF to cat
|
|
assert.NilError(t, resp.CloseWrite())
|
|
|
|
var (
|
|
waitCh = make(chan struct{})
|
|
resCh = make(chan struct {
|
|
content string
|
|
err error
|
|
}, 1)
|
|
)
|
|
|
|
go func() {
|
|
close(waitCh)
|
|
defer close(resCh)
|
|
r, err := io.ReadAll(resp.Reader)
|
|
|
|
resCh <- struct {
|
|
content string
|
|
err error
|
|
}{
|
|
content: string(r),
|
|
err: err,
|
|
}
|
|
}()
|
|
|
|
<-waitCh
|
|
select {
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("failed to read the content in time")
|
|
case got := <-resCh:
|
|
assert.NilError(t, got.err)
|
|
|
|
// NOTE: using Contains because no-tty's stream contains UX information
|
|
// like size, stream type.
|
|
assert.Assert(t, is.Contains(got.content, expected))
|
|
}
|
|
}
|
|
|
|
func TestExec(t *testing.T) {
|
|
ctx := setupTest(t)
|
|
apiClient := testEnv.APIClient()
|
|
|
|
cID := container.Run(ctx, t, apiClient, container.WithTty(true), container.WithWorkingDir("/root"))
|
|
|
|
id, err := apiClient.ContainerExecCreate(ctx, cID, containertypes.ExecOptions{
|
|
WorkingDir: "/tmp",
|
|
Env: []string{"FOO=BAR"},
|
|
AttachStdout: true,
|
|
Cmd: []string{"sh", "-c", "env"},
|
|
})
|
|
assert.NilError(t, err)
|
|
|
|
inspect, err := apiClient.ContainerExecInspect(ctx, id.ID)
|
|
assert.NilError(t, err)
|
|
assert.Check(t, is.Equal(inspect.ExecID, id.ID))
|
|
|
|
resp, err := apiClient.ContainerExecAttach(ctx, id.ID, containertypes.ExecAttachOptions{})
|
|
assert.NilError(t, err)
|
|
defer resp.Close()
|
|
r, err := io.ReadAll(resp.Reader)
|
|
assert.NilError(t, err)
|
|
out := string(r)
|
|
assert.NilError(t, err)
|
|
expected := "PWD=/tmp"
|
|
if testEnv.DaemonInfo.OSType == "windows" {
|
|
expected = "PWD=C:/tmp"
|
|
}
|
|
assert.Check(t, is.Contains(out, expected), "exec command not running in expected /tmp working directory")
|
|
assert.Check(t, is.Contains(out, "FOO=BAR"), "exec command not running with expected environment variable FOO")
|
|
}
|
|
|
|
func TestExecResize(t *testing.T) {
|
|
ctx := setupTest(t)
|
|
apiClient := testEnv.APIClient()
|
|
|
|
cID := container.Run(ctx, t, apiClient, container.WithTty(true))
|
|
defer container.Remove(ctx, t, apiClient, cID, containertypes.RemoveOptions{Force: true})
|
|
|
|
cmd := []string{"top"}
|
|
if runtime.GOOS == "windows" {
|
|
cmd = []string{"sleep", "240"}
|
|
}
|
|
resp, err := apiClient.ContainerExecCreate(ctx, cID, containertypes.ExecOptions{
|
|
Tty: true, // Windows requires a TTY for the resize to work, otherwise fails with "is not a tty: failed precondition", see https://github.com/moby/moby/pull/48665#issuecomment-2412530345
|
|
Cmd: cmd,
|
|
})
|
|
assert.NilError(t, err)
|
|
execID := resp.ID
|
|
assert.NilError(t, err)
|
|
err = apiClient.ContainerExecStart(ctx, execID, containertypes.ExecStartOptions{Detach: true})
|
|
assert.NilError(t, err)
|
|
|
|
t.Run("success", func(t *testing.T) {
|
|
err := apiClient.ContainerExecResize(ctx, execID, containertypes.ResizeOptions{
|
|
Height: 40,
|
|
Width: 40,
|
|
})
|
|
assert.NilError(t, err)
|
|
// TODO(thaJeztah): also check if the resize happened
|
|
//
|
|
// Note: container inspect shows the initial size that was
|
|
// set when creating the container. Actual resize happens in
|
|
// containerd, and currently does not update the container's
|
|
// config after running (but does send a "resize" event).
|
|
})
|
|
|
|
t.Run("invalid size", func(t *testing.T) {
|
|
const valueNotSet = "unset"
|
|
|
|
sizes := []struct {
|
|
doc, height, width, expErr string
|
|
}{
|
|
{
|
|
doc: "unset height",
|
|
height: valueNotSet,
|
|
width: "100",
|
|
expErr: `invalid resize height "": invalid syntax`,
|
|
},
|
|
{
|
|
doc: "unset width",
|
|
height: "100",
|
|
width: valueNotSet,
|
|
expErr: `invalid resize width "": invalid syntax`,
|
|
},
|
|
{
|
|
doc: "empty height",
|
|
width: "100",
|
|
expErr: `invalid resize height "": invalid syntax`,
|
|
},
|
|
{
|
|
doc: "empty width",
|
|
height: "100",
|
|
expErr: `invalid resize width "": invalid syntax`,
|
|
},
|
|
{
|
|
doc: "non-numeric height",
|
|
height: "not-a-number",
|
|
width: "100",
|
|
expErr: `invalid resize height "not-a-number": invalid syntax`,
|
|
},
|
|
{
|
|
doc: "non-numeric width",
|
|
height: "100",
|
|
width: "not-a-number",
|
|
expErr: `invalid resize width "not-a-number": invalid syntax`,
|
|
},
|
|
{
|
|
doc: "negative height",
|
|
height: "-100",
|
|
width: "100",
|
|
expErr: `invalid resize height "-100": value out of range`,
|
|
},
|
|
{
|
|
doc: "negative width",
|
|
height: "100",
|
|
width: "-100",
|
|
expErr: `invalid resize width "-100": value out of range`,
|
|
},
|
|
{
|
|
doc: "out of range height",
|
|
height: "4294967296", // math.MaxUint32+1
|
|
width: "100",
|
|
expErr: `invalid resize height "4294967296": value out of range`,
|
|
},
|
|
{
|
|
doc: "out of range width",
|
|
height: "100",
|
|
width: "4294967296", // math.MaxUint32+1
|
|
expErr: `invalid resize width "4294967296": value out of range`,
|
|
},
|
|
}
|
|
for _, tc := range sizes {
|
|
t.Run(tc.doc, func(t *testing.T) {
|
|
// Manually creating a request here, as the APIClient would invalidate
|
|
// these values before they're sent.
|
|
vals := url.Values{}
|
|
if tc.height != valueNotSet {
|
|
vals.Add("h", tc.height)
|
|
}
|
|
if tc.width != valueNotSet {
|
|
vals.Add("w", tc.width)
|
|
}
|
|
res, _, err := req.Post(ctx, "/exec/"+execID+"/resize?"+vals.Encode())
|
|
assert.NilError(t, err)
|
|
assert.Check(t, is.Equal(http.StatusBadRequest, res.StatusCode))
|
|
|
|
var errorResponse types.ErrorResponse
|
|
err = json.NewDecoder(res.Body).Decode(&errorResponse)
|
|
assert.NilError(t, err)
|
|
assert.Check(t, is.ErrorContains(errorResponse, tc.expErr))
|
|
})
|
|
}
|
|
})
|
|
|
|
t.Run("unknown execID", func(t *testing.T) {
|
|
err = apiClient.ContainerExecResize(ctx, "no-such-exec-id", containertypes.ResizeOptions{
|
|
Height: 40,
|
|
Width: 40,
|
|
})
|
|
assert.Check(t, is.ErrorType(err, cerrdefs.IsNotFound))
|
|
assert.Check(t, is.ErrorContains(err, "No such exec instance: no-such-exec-id"))
|
|
})
|
|
|
|
t.Run("invalid state", func(t *testing.T) {
|
|
// FIXME(thaJeztah): Windows + builtin returns a NotFound instead of a Conflict error
|
|
//
|
|
// When using the builtin runtime, stopping the container causes
|
|
// the exec-resize to return a "NotFound" error, whereas with containerd
|
|
// as runtime, it returns the expected "Conflict" error. This could be
|
|
// either a limitation of the "builtin" runtime, or there's a bug to
|
|
// be fixed.
|
|
//
|
|
// See https://github.com/moby/moby/pull/48665#issuecomment-2412579701
|
|
//
|
|
// === RUN TestExecResize/invalid_state
|
|
// exec_test.go:234: assertion failed: error is Error response from daemon: No such exec instance: cc728a332d3f594249fb7ee9adb3bb12a59a5d1776f8f6dedc56355364361711 (errdefs.errNotFound), not errdefs.IsConflict
|
|
// exec_test.go:235: assertion failed: expected error to contain "is not running", got "Error response from daemon: No such exec instance: cc728a332d3f594249fb7ee9adb3bb12a59a5d1776f8f6dedc56355364361711"
|
|
// Error response from daemon: No such exec instance: cc728a332d3f594249fb7ee9adb3bb12a59a5d1776f8f6dedc56355364361711
|
|
skip.If(t, testEnv.DaemonInfo.OSType == "windows" && !testEnv.RuntimeIsWindowsContainerd(), "FIXME. Windows + builtin returns a NotFound instead of a Conflict error")
|
|
|
|
err := apiClient.ContainerKill(ctx, cID, "SIGKILL")
|
|
assert.NilError(t, err)
|
|
|
|
err = apiClient.ContainerExecResize(ctx, execID, containertypes.ResizeOptions{
|
|
Height: 40,
|
|
Width: 40,
|
|
})
|
|
assert.Check(t, is.ErrorType(err, cerrdefs.IsConflict))
|
|
assert.Check(t, is.ErrorContains(err, "is not running"))
|
|
})
|
|
}
|
|
|
|
func TestExecUser(t *testing.T) {
|
|
skip.If(t, testEnv.DaemonInfo.OSType == "windows", "FIXME. Probably needs to wait for container to be in running state.")
|
|
ctx := setupTest(t)
|
|
apiClient := testEnv.APIClient()
|
|
|
|
ctrOpts := []func(*container.TestContainerConfig){
|
|
container.WithTty(true),
|
|
container.WithUser("1:1"),
|
|
}
|
|
withoutEtcGroups := container.WithImage(build.Do(ctx, t, apiClient, fakecontext.New(t, "", fakecontext.WithDockerfile("FROM busybox\nRUN rm /etc/group"))))
|
|
withoutEtcPasswd := container.WithImage(build.Do(ctx, t, apiClient, fakecontext.New(t, "", fakecontext.WithDockerfile("FROM busybox\nRUN rm /etc/passwd"))))
|
|
|
|
withUser := func(user string) func(options *containertypes.ExecOptions) {
|
|
return func(options *containertypes.ExecOptions) { options.User = user }
|
|
}
|
|
|
|
tests := []struct {
|
|
doc string
|
|
user string
|
|
ctrOpts []func(*container.TestContainerConfig)
|
|
expectedErr string
|
|
expectedOut string
|
|
}{
|
|
{
|
|
doc: "default user",
|
|
expectedOut: "uid=1(daemon) gid=1(daemon)",
|
|
},
|
|
{
|
|
doc: "uid",
|
|
user: "0",
|
|
expectedOut: "uid=0(root) gid=0(root) groups=0(root)",
|
|
},
|
|
{
|
|
doc: "uid gid",
|
|
user: "0:0",
|
|
expectedOut: "uid=0(root) gid=0(root) groups=0(root)",
|
|
},
|
|
{
|
|
doc: "username groupname",
|
|
user: "root:root",
|
|
expectedOut: "uid=0(root) gid=0(root) groups=0(root)",
|
|
},
|
|
{
|
|
doc: "unknown user",
|
|
user: "no-such-user",
|
|
expectedErr: `Error response from daemon: unable to find user no-such-user: no matching entries in passwd file`,
|
|
},
|
|
{
|
|
doc: "unknown user with gid",
|
|
user: "no-such-user:1",
|
|
expectedErr: `Error response from daemon: unable to find user no-such-user: no matching entries in passwd file`,
|
|
},
|
|
{
|
|
doc: "unknown group",
|
|
user: "1:no-such-group",
|
|
expectedErr: `Error response from daemon: unable to find group no-such-group: no matching entries in group file`,
|
|
},
|
|
{
|
|
doc: "missing etc/group",
|
|
ctrOpts: []func(*container.TestContainerConfig){withoutEtcGroups},
|
|
},
|
|
{
|
|
doc: "uid:gid and missing etc/group",
|
|
user: "0:0",
|
|
ctrOpts: []func(*container.TestContainerConfig){withoutEtcGroups},
|
|
},
|
|
{
|
|
doc: "user and missing etc/group",
|
|
user: "root",
|
|
ctrOpts: []func(*container.TestContainerConfig){withoutEtcGroups},
|
|
},
|
|
{
|
|
doc: "user:gid and missing etc/group",
|
|
user: "root;0",
|
|
ctrOpts: []func(*container.TestContainerConfig){withoutEtcGroups},
|
|
expectedErr: `Error response from daemon: unable to find user root;0: no matching entries in passwd file`,
|
|
},
|
|
{
|
|
doc: "group and missing etc/group",
|
|
user: "0:root",
|
|
ctrOpts: []func(*container.TestContainerConfig){withoutEtcGroups},
|
|
expectedErr: `Error response from daemon: unable to find group root: no matching entries in group file`,
|
|
},
|
|
{
|
|
doc: "missing etc/passwd",
|
|
ctrOpts: []func(*container.TestContainerConfig){withoutEtcPasswd},
|
|
},
|
|
{
|
|
doc: "uid:gid and missing etc/passwd",
|
|
user: "0:0",
|
|
ctrOpts: []func(*container.TestContainerConfig){withoutEtcPasswd},
|
|
},
|
|
{
|
|
doc: "user and missing etc/passwd",
|
|
user: "root",
|
|
ctrOpts: []func(*container.TestContainerConfig){withoutEtcPasswd},
|
|
expectedErr: `Error response from daemon: unable to find user root: no matching entries in passwd file`,
|
|
},
|
|
{
|
|
doc: "user:gid and missing etc/passwd",
|
|
user: "root;0",
|
|
ctrOpts: []func(*container.TestContainerConfig){withoutEtcPasswd},
|
|
expectedErr: `Error response from daemon: unable to find user root;0: no matching entries in passwd file`,
|
|
},
|
|
{
|
|
doc: "group and missing etc/passwd",
|
|
user: "0:root",
|
|
ctrOpts: []func(*container.TestContainerConfig){withoutEtcPasswd},
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.doc, func(t *testing.T) {
|
|
cID := container.Run(ctx, t, apiClient, append(ctrOpts, tc.ctrOpts...)...)
|
|
result, err := container.Exec(ctx, apiClient, cID, []string{"id"}, withUser(tc.user))
|
|
if tc.expectedErr != "" {
|
|
assert.Check(t, is.Error(err, tc.expectedErr))
|
|
assert.Check(t, is.ErrorType(err, cerrdefs.IsInvalidArgument))
|
|
assert.Check(t, is.Equal(result.Stdout(), "<nil>"))
|
|
assert.Check(t, is.Equal(result.Stderr(), "<nil>"))
|
|
} else {
|
|
assert.Check(t, err)
|
|
assert.Check(t, is.Contains(result.Stdout(), tc.expectedOut))
|
|
assert.Check(t, is.Equal(result.Stderr(), ""))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Test that additional groups set with `--group-add` are kept on exec when the container
|
|
// also has a user set.
|
|
// (regression test for https://github.com/moby/moby/issues/46712)
|
|
func TestExecWithGroupAdd(t *testing.T) {
|
|
skip.If(t, testEnv.DaemonInfo.OSType == "windows", "FIXME. Probably needs to wait for container to be in running state.")
|
|
|
|
ctx := setupTest(t)
|
|
apiClient := testEnv.APIClient()
|
|
|
|
cID := container.Run(ctx, t, apiClient, container.WithTty(true), container.WithUser("root:root"), container.WithAdditionalGroups("staff", "wheel", "audio", "777"), container.WithCmd("sleep", "5"))
|
|
|
|
result, err := container.Exec(ctx, apiClient, cID, []string{"id"})
|
|
assert.NilError(t, err)
|
|
|
|
const expected = "uid=0(root) gid=0(root) groups=0(root),10(wheel),29(audio),50(staff),777"
|
|
assert.Check(t, is.Equal(strings.TrimSpace(result.Stdout()), expected), "exec command not keeping additional groups w/ user")
|
|
}
|