mirror of
https://github.com/coder/coder.git
synced 2025-07-12 00:14:10 +00:00
297 lines
12 KiB
Go
297 lines
12 KiB
Go
package agentcontainers_test
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/ory/dockertest/v3"
|
|
"github.com/ory/dockertest/v3/docker"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/coder/coder/v2/agent/agentcontainers"
|
|
"github.com/coder/coder/v2/agent/agentexec"
|
|
"github.com/coder/coder/v2/pty"
|
|
"github.com/coder/coder/v2/testutil"
|
|
)
|
|
|
|
// TestIntegrationDocker tests agentcontainers functionality using a real
|
|
// Docker container. It starts a container with a known
|
|
// label, lists the containers, and verifies that the expected container is
|
|
// returned. It also executes a sample command inside the container.
|
|
// The container is deleted after the test is complete.
|
|
// As this test creates containers, it is skipped by default.
|
|
// It can be run manually as follows:
|
|
//
|
|
// CODER_TEST_USE_DOCKER=1 go test ./agent/agentcontainers -run TestDockerCLIContainerLister
|
|
//
|
|
//nolint:paralleltest // This test tends to flake when lots of containers start and stop in parallel.
|
|
func TestIntegrationDocker(t *testing.T) {
|
|
if ctud, ok := os.LookupEnv("CODER_TEST_USE_DOCKER"); !ok || ctud != "1" {
|
|
t.Skip("Set CODER_TEST_USE_DOCKER=1 to run this test")
|
|
}
|
|
|
|
pool, err := dockertest.NewPool("")
|
|
require.NoError(t, err, "Could not connect to docker")
|
|
testLabelValue := uuid.New().String()
|
|
// Create a temporary directory to validate that we surface mounts correctly.
|
|
testTempDir := t.TempDir()
|
|
// Pick a random port to expose for testing port bindings.
|
|
testRandPort := testutil.RandomPortNoListen(t)
|
|
ct, err := pool.RunWithOptions(&dockertest.RunOptions{
|
|
Repository: "busybox",
|
|
Tag: "latest",
|
|
Cmd: []string{"sleep", "infnity"},
|
|
Labels: map[string]string{
|
|
"com.coder.test": testLabelValue,
|
|
"devcontainer.metadata": `[{"remoteEnv": {"FOO": "bar", "MULTILINE": "foo\nbar\nbaz"}}]`,
|
|
},
|
|
Mounts: []string{testTempDir + ":" + testTempDir},
|
|
ExposedPorts: []string{fmt.Sprintf("%d/tcp", testRandPort)},
|
|
PortBindings: map[docker.Port][]docker.PortBinding{
|
|
docker.Port(fmt.Sprintf("%d/tcp", testRandPort)): {
|
|
{
|
|
HostIP: "0.0.0.0",
|
|
HostPort: strconv.FormatInt(int64(testRandPort), 10),
|
|
},
|
|
},
|
|
},
|
|
}, func(config *docker.HostConfig) {
|
|
config.AutoRemove = true
|
|
config.RestartPolicy = docker.RestartPolicy{Name: "no"}
|
|
})
|
|
require.NoError(t, err, "Could not start test docker container")
|
|
t.Logf("Created container %q", ct.Container.Name)
|
|
t.Cleanup(func() {
|
|
assert.NoError(t, pool.Purge(ct), "Could not purge resource %q", ct.Container.Name)
|
|
t.Logf("Purged container %q", ct.Container.Name)
|
|
})
|
|
// Wait for container to start
|
|
require.Eventually(t, func() bool {
|
|
ct, ok := pool.ContainerByName(ct.Container.Name)
|
|
return ok && ct.Container.State.Running
|
|
}, testutil.WaitShort, testutil.IntervalSlow, "Container did not start in time")
|
|
|
|
dcl := agentcontainers.NewDockerCLI(agentexec.DefaultExecer)
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
actual, err := dcl.List(ctx)
|
|
require.NoError(t, err, "Could not list containers")
|
|
require.Empty(t, actual.Warnings, "Expected no warnings")
|
|
var found bool
|
|
for _, foundContainer := range actual.Containers {
|
|
if foundContainer.ID == ct.Container.ID {
|
|
found = true
|
|
assert.Equal(t, ct.Container.Created, foundContainer.CreatedAt)
|
|
// ory/dockertest pre-pends a forward slash to the container name.
|
|
assert.Equal(t, strings.TrimPrefix(ct.Container.Name, "/"), foundContainer.FriendlyName)
|
|
// ory/dockertest returns the sha256 digest of the image.
|
|
assert.Equal(t, "busybox:latest", foundContainer.Image)
|
|
assert.Equal(t, ct.Container.Config.Labels, foundContainer.Labels)
|
|
assert.True(t, foundContainer.Running)
|
|
assert.Equal(t, "running", foundContainer.Status)
|
|
if assert.Len(t, foundContainer.Ports, 1) {
|
|
assert.Equal(t, testRandPort, foundContainer.Ports[0].Port)
|
|
assert.Equal(t, "tcp", foundContainer.Ports[0].Network)
|
|
}
|
|
if assert.Len(t, foundContainer.Volumes, 1) {
|
|
assert.Equal(t, testTempDir, foundContainer.Volumes[testTempDir])
|
|
}
|
|
// Test that EnvInfo is able to correctly modify a command to be
|
|
// executed inside the container.
|
|
dei, err := agentcontainers.EnvInfo(ctx, agentexec.DefaultExecer, ct.Container.ID, "")
|
|
require.NoError(t, err, "Expected no error from DockerEnvInfo()")
|
|
ptyWrappedCmd, ptyWrappedArgs := dei.ModifyCommand("/bin/sh", "--norc")
|
|
ptyCmd, ptyPs, err := pty.Start(agentexec.DefaultExecer.PTYCommandContext(ctx, ptyWrappedCmd, ptyWrappedArgs...))
|
|
require.NoError(t, err, "failed to start pty command")
|
|
t.Cleanup(func() {
|
|
_ = ptyPs.Kill()
|
|
_ = ptyCmd.Close()
|
|
})
|
|
tr := testutil.NewTerminalReader(t, ptyCmd.OutputReader())
|
|
matchPrompt := func(line string) bool {
|
|
return strings.Contains(line, "#")
|
|
}
|
|
matchHostnameCmd := func(line string) bool {
|
|
return strings.Contains(strings.TrimSpace(line), "hostname")
|
|
}
|
|
matchHostnameOuput := func(line string) bool {
|
|
return strings.Contains(strings.TrimSpace(line), ct.Container.Config.Hostname)
|
|
}
|
|
matchEnvCmd := func(line string) bool {
|
|
return strings.Contains(strings.TrimSpace(line), "env")
|
|
}
|
|
matchEnvOutput := func(line string) bool {
|
|
return strings.Contains(line, "FOO=bar") || strings.Contains(line, "MULTILINE=foo")
|
|
}
|
|
require.NoError(t, tr.ReadUntil(ctx, matchPrompt), "failed to match prompt")
|
|
t.Logf("Matched prompt")
|
|
_, err = ptyCmd.InputWriter().Write([]byte("hostname\r\n"))
|
|
require.NoError(t, err, "failed to write to pty")
|
|
t.Logf("Wrote hostname command")
|
|
require.NoError(t, tr.ReadUntil(ctx, matchHostnameCmd), "failed to match hostname command")
|
|
t.Logf("Matched hostname command")
|
|
require.NoError(t, tr.ReadUntil(ctx, matchHostnameOuput), "failed to match hostname output")
|
|
t.Logf("Matched hostname output")
|
|
_, err = ptyCmd.InputWriter().Write([]byte("env\r\n"))
|
|
require.NoError(t, err, "failed to write to pty")
|
|
t.Logf("Wrote env command")
|
|
require.NoError(t, tr.ReadUntil(ctx, matchEnvCmd), "failed to match env command")
|
|
t.Logf("Matched env command")
|
|
require.NoError(t, tr.ReadUntil(ctx, matchEnvOutput), "failed to match env output")
|
|
t.Logf("Matched env output")
|
|
break
|
|
}
|
|
}
|
|
assert.True(t, found, "Expected to find container with label 'com.coder.test=%s'", testLabelValue)
|
|
}
|
|
|
|
// TestDockerEnvInfoer tests the ability of EnvInfo to extract information from
|
|
// running containers. Containers are deleted after the test is complete.
|
|
// As this test creates containers, it is skipped by default.
|
|
// It can be run manually as follows:
|
|
//
|
|
// CODER_TEST_USE_DOCKER=1 go test ./agent/agentcontainers -run TestDockerEnvInfoer
|
|
//
|
|
//nolint:paralleltest // This test tends to flake when lots of containers start and stop in parallel.
|
|
func TestDockerEnvInfoer(t *testing.T) {
|
|
if ctud, ok := os.LookupEnv("CODER_TEST_USE_DOCKER"); !ok || ctud != "1" {
|
|
t.Skip("Set CODER_TEST_USE_DOCKER=1 to run this test")
|
|
}
|
|
|
|
pool, err := dockertest.NewPool("")
|
|
require.NoError(t, err, "Could not connect to docker")
|
|
// nolint:paralleltest // variable recapture no longer required
|
|
for idx, tt := range []struct {
|
|
image string
|
|
labels map[string]string
|
|
expectedEnv []string
|
|
containerUser string
|
|
expectedUsername string
|
|
expectedUserShell string
|
|
}{
|
|
{
|
|
image: "busybox:latest",
|
|
labels: map[string]string{`devcontainer.metadata`: `[{"remoteEnv": {"FOO": "bar", "MULTILINE": "foo\nbar\nbaz"}}]`},
|
|
|
|
expectedEnv: []string{"FOO=bar", "MULTILINE=foo\nbar\nbaz"},
|
|
expectedUsername: "root",
|
|
expectedUserShell: "/bin/sh",
|
|
},
|
|
{
|
|
image: "busybox:latest",
|
|
labels: map[string]string{`devcontainer.metadata`: `[{"remoteEnv": {"FOO": "bar", "MULTILINE": "foo\nbar\nbaz"}}]`},
|
|
expectedEnv: []string{"FOO=bar", "MULTILINE=foo\nbar\nbaz"},
|
|
containerUser: "root",
|
|
expectedUsername: "root",
|
|
expectedUserShell: "/bin/sh",
|
|
},
|
|
{
|
|
image: "codercom/enterprise-minimal:ubuntu",
|
|
labels: map[string]string{`devcontainer.metadata`: `[{"remoteEnv": {"FOO": "bar", "MULTILINE": "foo\nbar\nbaz"}}]`},
|
|
expectedEnv: []string{"FOO=bar", "MULTILINE=foo\nbar\nbaz"},
|
|
expectedUsername: "coder",
|
|
expectedUserShell: "/bin/bash",
|
|
},
|
|
{
|
|
image: "codercom/enterprise-minimal:ubuntu",
|
|
labels: map[string]string{`devcontainer.metadata`: `[{"remoteEnv": {"FOO": "bar", "MULTILINE": "foo\nbar\nbaz"}}]`},
|
|
expectedEnv: []string{"FOO=bar", "MULTILINE=foo\nbar\nbaz"},
|
|
containerUser: "coder",
|
|
expectedUsername: "coder",
|
|
expectedUserShell: "/bin/bash",
|
|
},
|
|
{
|
|
image: "codercom/enterprise-minimal:ubuntu",
|
|
labels: map[string]string{`devcontainer.metadata`: `[{"remoteEnv": {"FOO": "bar", "MULTILINE": "foo\nbar\nbaz"}}]`},
|
|
expectedEnv: []string{"FOO=bar", "MULTILINE=foo\nbar\nbaz"},
|
|
containerUser: "root",
|
|
expectedUsername: "root",
|
|
expectedUserShell: "/bin/bash",
|
|
},
|
|
{
|
|
image: "codercom/enterprise-minimal:ubuntu",
|
|
labels: map[string]string{`devcontainer.metadata`: `[{"remoteEnv": {"FOO": "bar"}},{"remoteEnv": {"MULTILINE": "foo\nbar\nbaz"}}]`},
|
|
expectedEnv: []string{"FOO=bar", "MULTILINE=foo\nbar\nbaz"},
|
|
containerUser: "root",
|
|
expectedUsername: "root",
|
|
expectedUserShell: "/bin/bash",
|
|
},
|
|
} {
|
|
//nolint:paralleltest // variable recapture no longer required
|
|
t.Run(fmt.Sprintf("#%d", idx), func(t *testing.T) {
|
|
// Start a container with the given image
|
|
// and environment variables
|
|
image := strings.Split(tt.image, ":")[0]
|
|
tag := strings.Split(tt.image, ":")[1]
|
|
ct, err := pool.RunWithOptions(&dockertest.RunOptions{
|
|
Repository: image,
|
|
Tag: tag,
|
|
Cmd: []string{"sleep", "infinity"},
|
|
Labels: tt.labels,
|
|
}, func(config *docker.HostConfig) {
|
|
config.AutoRemove = true
|
|
config.RestartPolicy = docker.RestartPolicy{Name: "no"}
|
|
})
|
|
require.NoError(t, err, "Could not start test docker container")
|
|
t.Logf("Created container %q", ct.Container.Name)
|
|
t.Cleanup(func() {
|
|
assert.NoError(t, pool.Purge(ct), "Could not purge resource %q", ct.Container.Name)
|
|
t.Logf("Purged container %q", ct.Container.Name)
|
|
})
|
|
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
dei, err := agentcontainers.EnvInfo(ctx, agentexec.DefaultExecer, ct.Container.ID, tt.containerUser)
|
|
require.NoError(t, err, "Expected no error from DockerEnvInfo()")
|
|
|
|
u, err := dei.User()
|
|
require.NoError(t, err, "Expected no error from CurrentUser()")
|
|
require.Equal(t, tt.expectedUsername, u.Username, "Expected username to match")
|
|
|
|
hd, err := dei.HomeDir()
|
|
require.NoError(t, err, "Expected no error from UserHomeDir()")
|
|
require.NotEmpty(t, hd, "Expected user homedir to be non-empty")
|
|
|
|
sh, err := dei.Shell(tt.containerUser)
|
|
require.NoError(t, err, "Expected no error from UserShell()")
|
|
require.Equal(t, tt.expectedUserShell, sh, "Expected user shell to match")
|
|
|
|
// We don't need to test the actual environment variables here.
|
|
environ := dei.Environ()
|
|
require.NotEmpty(t, environ, "Expected environ to be non-empty")
|
|
|
|
// Test that the environment variables are present in modified command
|
|
// output.
|
|
envCmd, envArgs := dei.ModifyCommand("env")
|
|
for _, env := range tt.expectedEnv {
|
|
require.Subset(t, envArgs, []string{"--env", env})
|
|
}
|
|
// Run the command in the container and check the output
|
|
// HACK: we remove the --tty argument because we're not running in a tty
|
|
envArgs = slices.DeleteFunc(envArgs, func(s string) bool { return s == "--tty" })
|
|
stdout, stderr, err := run(ctx, agentexec.DefaultExecer, envCmd, envArgs...)
|
|
require.Empty(t, stderr, "Expected no stderr output")
|
|
require.NoError(t, err, "Expected no error from running command")
|
|
for _, env := range tt.expectedEnv {
|
|
require.Contains(t, stdout, env)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func run(ctx context.Context, execer agentexec.Execer, cmd string, args ...string) (stdout, stderr string, err error) {
|
|
var stdoutBuf, stderrBuf strings.Builder
|
|
execCmd := execer.CommandContext(ctx, cmd, args...)
|
|
execCmd.Stdout = &stdoutBuf
|
|
execCmd.Stderr = &stderrBuf
|
|
err = execCmd.Run()
|
|
stdout = strings.TrimSpace(stdoutBuf.String())
|
|
stderr = strings.TrimSpace(stderrBuf.String())
|
|
return stdout, stderr, err
|
|
}
|