| package container | |
| import ( | |
| "encoding/json" | |
| "io" | |
| "net/http" | |
| "net/url" | |
| "runtime" | |
| "strings" | |
| "testing" | |
| "time" | |
| cerrdefs "github.com/containerd/errdefs" | |
| "github.com/moby/moby/api/types/common" | |
| "github.com/moby/moby/client" | |
| "github.com/moby/moby/v2/integration/internal/build" | |
| "github.com/moby/moby/v2/integration/internal/container" | |
| "github.com/moby/moby/v2/internal/testutil/fakecontext" | |
| req "github.com/moby/moby/v2/internal/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" | |
| res, err := apiClient.ExecCreate(ctx, cID, client.ExecCreateOptions{ | |
| AttachStdin: true, | |
| AttachStdout: true, | |
| Cmd: []string{"sh", "-c", "cat && echo " + expected}, | |
| }) | |
| assert.NilError(t, err) | |
| resp, err := apiClient.ExecAttach(ctx, res.ID, client.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")) | |
| res, err := apiClient.ExecCreate(ctx, cID, client.ExecCreateOptions{ | |
| WorkingDir: "/tmp", | |
| Env: []string{"FOO=BAR"}, | |
| AttachStdout: true, | |
| Cmd: []string{"sh", "-c", "env"}, | |
| }) | |
| assert.NilError(t, err) | |
| inspect, err := apiClient.ExecInspect(ctx, res.ID, client.ExecInspectOptions{}) | |
| assert.NilError(t, err) | |
| assert.Check(t, is.Equal(inspect.ID, res.ID)) | |
| resp, err := apiClient.ExecAttach(ctx, res.ID, client.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, client.ContainerRemoveOptions{Force: true}) | |
| cmd := []string{"top"} | |
| if runtime.GOOS == "windows" { | |
| cmd = []string{"sleep", "240"} | |
| } | |
| res, err := apiClient.ExecCreate(ctx, cID, client.ExecCreateOptions{ | |
| 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 := res.ID | |
| assert.NilError(t, err) | |
| _, err = apiClient.ExecStart(ctx, execID, client.ExecStartOptions{ | |
| Detach: true, | |
| }) | |
| assert.NilError(t, err) | |
| t.Run("success", func(t *testing.T) { | |
| _, err := apiClient.ExecResize(ctx, execID, client.ExecResizeOptions{ | |
| Height: 40, | |
| Width: 40, | |
| }) | |
| if runtime.GOOS == "windows" && err != nil { | |
| // FIXME(thaJeztah): temporarily allowing test to fail on Windows: see https://github.com/moby/moby/issues/50402 | |
| t.Log("XFAIL:", err) | |
| t.Skip("XFAIL: flaky test on Windows: see https://github.com/moby/moby/issues/50402") | |
| return | |
| } | |
| 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 common.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.ExecResize(ctx, "no-such-exec-id", client.ExecResizeOptions{ | |
| 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, client.ContainerKillOptions{}) | |
| assert.NilError(t, err) | |
| _, err = apiClient.ExecResize(ctx, execID, client.ExecResizeOptions{ | |
| 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 *client.ExecCreateOptions) { | |
| return func(options *client.ExecCreateOptions) { 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") | |
| } |