Files
moby/integration/container/exec_test.go
T
Sebastiaan van Stijn d3e45f8743 testutil: move back to internal
This package was originally internal, but was moved out when BuildKit
used it for its integration tests. That's no longer the case, so we
can make it internal again.

Signed-off-by: Sebastiaan van Stijn <github@gone.nl>
2025-09-08 10:08:30 +02:00

433 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/moby/moby/api/types/common"
containertypes "github.com/moby/moby/api/types/container"
"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"
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, client.ContainerRemoveOptions{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, client.ContainerResizeOptions{
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.ContainerExecResize(ctx, "no-such-exec-id", client.ContainerResizeOptions{
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, client.ContainerResizeOptions{
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")
}