| 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")), client.ImageBuildOptions{})) |
| withoutEtcPasswd := container.WithImage(build.Do(ctx, t, apiClient, fakecontext.New(t, "", fakecontext.WithDockerfile("FROM busybox\nRUN rm /etc/passwd")), client.ImageBuildOptions{})) |
| |
| 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") |
| } |