mirror of
https://github.com/moby/moby.git
synced 2026-07-12 18:45:06 +00:00
Before this patch, and error would be produced when starting the exec,
but the CLI would wait for the exec to complete, timing out after 10
seconds (default). With this change, an error is returned immediately
when creating the exec.
Note that "technically" this check may have some TOCTOU issues, because
'/etc/passwd' and '/etc/groups' may be mutated by the container in between
creating the exec and starting it.
This is very likely a corner-case, but something we can consider changing
in future (either allow creating an invalid exec, and checking before
starting, or checking both before create and before start).
With this patch:
printf 'FROM alpine\nRUN rm -f /etc/group' | docker build -t nogroup -
ID=$(docker run -dit nogroup)
time docker exec -u 0:root $ID echo hello
Error response from daemon: unable to find group root: no matching entries in group file
real 0m0.014s
user 0m0.010s
sys 0m0.003s
# numericc uid/gid (should not require lookup);
time docker exec -u 0:0 $ID echo hello
hello
real 0m0.059s
user 0m0.007s
sys 0m0.008s
# no user specified (should not require lookup);
time docker exec $ID echo hello
hello
real 0m0.057s
user 0m0.013s
sys 0m0.008s
docker rm -fv $ID
# container that does have a valid /etc/groups
ID=$(docker run -dit alpine)
time docker exec -u 0:root $ID echo hello
hello
real 0m0.063s
user 0m0.010s
sys 0m0.009s
# non-existing user or group
time docker exec -u 0:blabla $ID echo hello
Error response from daemon: unable to find group blabla: no matching entries in group file
real 0m0.013s
user 0m0.004s
sys 0m0.009s
docker rm -fv $ID
Signed-off-by: Sebastiaan van Stijn <github@gone.nl>
426 lines
14 KiB
Go
426 lines
14 KiB
Go
package container // import "github.com/docker/docker/integration/container"
|
|
|
|
import (
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/docker/docker/api/types"
|
|
containertypes "github.com/docker/docker/api/types/container"
|
|
"github.com/docker/docker/errdefs"
|
|
"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
|
|
Detach: true,
|
|
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, errdefs.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, errdefs.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, errdefs.IsInvalidParameter))
|
|
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")
|
|
}
|