Files
coder/agent/agentcontainers/devcontainercli_test.go
Danielle Maywood dd150264bc feat(agent/agentcontainers): support displayApps from devcontainer config (#18342)
Updates the agent injection routine to read the dev container's
configuration so we can add display apps to the sub agent.
2025-06-12 23:36:23 +01:00

632 lines
21 KiB
Go

package agentcontainers_test
import (
"bytes"
"context"
"errors"
"flag"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"github.com/ory/dockertest/v3"
"github.com/ory/dockertest/v3/docker"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"cdr.dev/slog"
"cdr.dev/slog/sloggers/slogtest"
"github.com/coder/coder/v2/agent/agentcontainers"
"github.com/coder/coder/v2/agent/agentexec"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/pty"
"github.com/coder/coder/v2/testutil"
)
func TestDevcontainerCLI_ArgsAndParsing(t *testing.T) {
t.Parallel()
testExePath, err := os.Executable()
require.NoError(t, err, "get test executable path")
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
t.Run("Up", func(t *testing.T) {
t.Parallel()
tests := []struct {
name string
logFile string
workspace string
config string
opts []agentcontainers.DevcontainerCLIUpOptions
wantArgs string
wantError bool
}{
{
name: "success",
logFile: "up.log",
workspace: "/test/workspace",
wantArgs: "up --log-format json --workspace-folder /test/workspace",
wantError: false,
},
{
name: "success with config",
logFile: "up.log",
workspace: "/test/workspace",
config: "/test/config.json",
wantArgs: "up --log-format json --workspace-folder /test/workspace --config /test/config.json",
wantError: false,
},
{
name: "already exists",
logFile: "up-already-exists.log",
workspace: "/test/workspace",
wantArgs: "up --log-format json --workspace-folder /test/workspace",
wantError: false,
},
{
name: "docker error",
logFile: "up-error-docker.log",
workspace: "/test/workspace",
wantArgs: "up --log-format json --workspace-folder /test/workspace",
wantError: true,
},
{
name: "bad outcome",
logFile: "up-error-bad-outcome.log",
workspace: "/test/workspace",
wantArgs: "up --log-format json --workspace-folder /test/workspace",
wantError: true,
},
{
name: "does not exist",
logFile: "up-error-does-not-exist.log",
workspace: "/test/workspace",
wantArgs: "up --log-format json --workspace-folder /test/workspace",
wantError: true,
},
{
name: "with remove existing container",
logFile: "up.log",
workspace: "/test/workspace",
opts: []agentcontainers.DevcontainerCLIUpOptions{
agentcontainers.WithRemoveExistingContainer(),
},
wantArgs: "up --log-format json --workspace-folder /test/workspace --remove-existing-container",
wantError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
testExecer := &testDevcontainerExecer{
testExePath: testExePath,
wantArgs: tt.wantArgs,
wantError: tt.wantError,
logFile: filepath.Join("testdata", "devcontainercli", "parse", tt.logFile),
}
dccli := agentcontainers.NewDevcontainerCLI(logger, testExecer)
containerID, err := dccli.Up(ctx, tt.workspace, tt.config, tt.opts...)
if tt.wantError {
assert.Error(t, err, "want error")
assert.Empty(t, containerID, "expected empty container ID")
} else {
assert.NoError(t, err, "want no error")
assert.NotEmpty(t, containerID, "expected non-empty container ID")
}
})
}
})
t.Run("Exec", func(t *testing.T) {
t.Parallel()
tests := []struct {
name string
workspaceFolder string
configPath string
cmd string
cmdArgs []string
opts []agentcontainers.DevcontainerCLIExecOptions
wantArgs string
wantError bool
}{
{
name: "simple command",
workspaceFolder: "/test/workspace",
configPath: "",
cmd: "echo",
cmdArgs: []string{"hello"},
wantArgs: "exec --workspace-folder /test/workspace echo hello",
wantError: false,
},
{
name: "command with multiple args",
workspaceFolder: "/test/workspace",
configPath: "/test/config.json",
cmd: "ls",
cmdArgs: []string{"-la", "/workspace"},
wantArgs: "exec --workspace-folder /test/workspace --config /test/config.json ls -la /workspace",
wantError: false,
},
{
name: "empty command args",
workspaceFolder: "/test/workspace",
configPath: "",
cmd: "bash",
cmdArgs: nil,
wantArgs: "exec --workspace-folder /test/workspace bash",
wantError: false,
},
{
name: "workspace not found",
workspaceFolder: "/nonexistent/workspace",
configPath: "",
cmd: "echo",
cmdArgs: []string{"test"},
wantArgs: "exec --workspace-folder /nonexistent/workspace echo test",
wantError: true,
},
{
name: "with container ID",
workspaceFolder: "/test/workspace",
configPath: "",
cmd: "echo",
cmdArgs: []string{"hello"},
opts: []agentcontainers.DevcontainerCLIExecOptions{agentcontainers.WithExecContainerID("test-container-123")},
wantArgs: "exec --workspace-folder /test/workspace --container-id test-container-123 echo hello",
wantError: false,
},
{
name: "with container ID and config",
workspaceFolder: "/test/workspace",
configPath: "/test/config.json",
cmd: "bash",
cmdArgs: []string{"-c", "ls -la"},
opts: []agentcontainers.DevcontainerCLIExecOptions{agentcontainers.WithExecContainerID("my-container")},
wantArgs: "exec --workspace-folder /test/workspace --config /test/config.json --container-id my-container bash -c ls -la",
wantError: false,
},
{
name: "with container ID and output capture",
workspaceFolder: "/test/workspace",
configPath: "",
cmd: "cat",
cmdArgs: []string{"/etc/hostname"},
opts: []agentcontainers.DevcontainerCLIExecOptions{
agentcontainers.WithExecContainerID("test-container-789"),
},
wantArgs: "exec --workspace-folder /test/workspace --container-id test-container-789 cat /etc/hostname",
wantError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
testExecer := &testDevcontainerExecer{
testExePath: testExePath,
wantArgs: tt.wantArgs,
wantError: tt.wantError,
logFile: "", // Exec doesn't need log file parsing
}
dccli := agentcontainers.NewDevcontainerCLI(logger, testExecer)
err := dccli.Exec(ctx, tt.workspaceFolder, tt.configPath, tt.cmd, tt.cmdArgs, tt.opts...)
if tt.wantError {
assert.Error(t, err, "want error")
} else {
assert.NoError(t, err, "want no error")
}
})
}
})
t.Run("ReadConfig", func(t *testing.T) {
t.Parallel()
tests := []struct {
name string
logFile string
workspaceFolder string
configPath string
opts []agentcontainers.DevcontainerCLIReadConfigOptions
wantArgs string
wantError bool
wantConfig agentcontainers.DevcontainerConfig
}{
{
name: "WithCoderCustomization",
logFile: "read-config-with-coder-customization.log",
workspaceFolder: "/test/workspace",
configPath: "",
wantArgs: "read-configuration --include-merged-configuration --workspace-folder /test/workspace",
wantError: false,
wantConfig: agentcontainers.DevcontainerConfig{
MergedConfiguration: agentcontainers.DevcontainerConfiguration{
Customizations: agentcontainers.DevcontainerCustomizations{
Coder: &agentcontainers.CoderCustomization{
DisplayApps: []codersdk.DisplayApp{
codersdk.DisplayAppVSCodeDesktop,
codersdk.DisplayAppWebTerminal,
},
},
},
},
},
},
{
name: "WithoutCoderCustomization",
logFile: "read-config-without-coder-customization.log",
workspaceFolder: "/test/workspace",
configPath: "/test/config.json",
wantArgs: "read-configuration --include-merged-configuration --workspace-folder /test/workspace --config /test/config.json",
wantError: false,
wantConfig: agentcontainers.DevcontainerConfig{
MergedConfiguration: agentcontainers.DevcontainerConfiguration{
Customizations: agentcontainers.DevcontainerCustomizations{
Coder: nil,
},
},
},
},
{
name: "FileNotFound",
logFile: "read-config-error-not-found.log",
workspaceFolder: "/nonexistent/workspace",
configPath: "",
wantArgs: "read-configuration --include-merged-configuration --workspace-folder /nonexistent/workspace",
wantError: true,
wantConfig: agentcontainers.DevcontainerConfig{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
testExecer := &testDevcontainerExecer{
testExePath: testExePath,
wantArgs: tt.wantArgs,
wantError: tt.wantError,
logFile: filepath.Join("testdata", "devcontainercli", "readconfig", tt.logFile),
}
dccli := agentcontainers.NewDevcontainerCLI(logger, testExecer)
config, err := dccli.ReadConfig(ctx, tt.workspaceFolder, tt.configPath, tt.opts...)
if tt.wantError {
assert.Error(t, err, "want error")
assert.Equal(t, agentcontainers.DevcontainerConfig{}, config, "expected empty config on error")
} else {
assert.NoError(t, err, "want no error")
assert.Equal(t, tt.wantConfig, config, "expected config to match")
}
})
}
})
}
// TestDevcontainerCLI_WithOutput tests that WithUpOutput and WithExecOutput capture CLI
// logs to provided writers.
func TestDevcontainerCLI_WithOutput(t *testing.T) {
t.Parallel()
// Prepare test executable and logger.
testExePath, err := os.Executable()
require.NoError(t, err, "get test executable path")
t.Run("Up", func(t *testing.T) {
t.Parallel()
// Buffers to capture stdout and stderr.
outBuf := &bytes.Buffer{}
errBuf := &bytes.Buffer{}
// Simulate CLI execution with a standard up.log file.
wantArgs := "up --log-format json --workspace-folder /test/workspace"
testExecer := &testDevcontainerExecer{
testExePath: testExePath,
wantArgs: wantArgs,
wantError: false,
logFile: filepath.Join("testdata", "devcontainercli", "parse", "up.log"),
}
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
dccli := agentcontainers.NewDevcontainerCLI(logger, testExecer)
// Call Up with WithUpOutput to capture CLI logs.
ctx := testutil.Context(t, testutil.WaitMedium)
containerID, err := dccli.Up(ctx, "/test/workspace", "", agentcontainers.WithUpOutput(outBuf, errBuf))
require.NoError(t, err, "Up should succeed")
require.NotEmpty(t, containerID, "expected non-empty container ID")
// Read expected log content.
expLog, err := os.ReadFile(filepath.Join("testdata", "devcontainercli", "parse", "up.log"))
require.NoError(t, err, "reading expected log file")
// Verify stdout buffer contains the CLI logs and stderr is empty.
assert.Equal(t, string(expLog), outBuf.String(), "stdout buffer should match CLI logs")
assert.Empty(t, errBuf.String(), "stderr buffer should be empty on success")
})
t.Run("Exec", func(t *testing.T) {
t.Parallel()
logFile := filepath.Join(t.TempDir(), "exec.log")
f, err := os.Create(logFile)
require.NoError(t, err, "create exec log file")
_, err = f.WriteString("exec command log\n")
require.NoError(t, err, "write to exec log file")
err = f.Close()
require.NoError(t, err, "close exec log file")
// Buffers to capture stdout and stderr.
outBuf := &bytes.Buffer{}
errBuf := &bytes.Buffer{}
// Simulate CLI execution for exec command with container ID.
wantArgs := "exec --workspace-folder /test/workspace --container-id test-container-456 echo hello"
testExecer := &testDevcontainerExecer{
testExePath: testExePath,
wantArgs: wantArgs,
wantError: false,
logFile: logFile,
}
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
dccli := agentcontainers.NewDevcontainerCLI(logger, testExecer)
// Call Exec with WithExecOutput and WithContainerID to capture any command output.
ctx := testutil.Context(t, testutil.WaitMedium)
err = dccli.Exec(ctx, "/test/workspace", "", "echo", []string{"hello"},
agentcontainers.WithExecContainerID("test-container-456"),
agentcontainers.WithExecOutput(outBuf, errBuf),
)
require.NoError(t, err, "Exec should succeed")
assert.NotEmpty(t, outBuf.String(), "stdout buffer should not be empty for exec with log file")
assert.Empty(t, errBuf.String(), "stderr buffer should be empty")
})
}
// testDevcontainerExecer implements the agentexec.Execer interface for testing.
type testDevcontainerExecer struct {
testExePath string
wantArgs string
wantError bool
logFile string
}
// CommandContext returns a test binary command that simulates devcontainer responses.
func (e *testDevcontainerExecer) CommandContext(ctx context.Context, name string, args ...string) *exec.Cmd {
// Only handle "devcontainer" commands.
if name != "devcontainer" {
// For non-devcontainer commands, use a standard execer.
return agentexec.DefaultExecer.CommandContext(ctx, name, args...)
}
// Create a command that runs the test binary with special flags
// that tell it to simulate a devcontainer command.
testArgs := []string{
"-test.run=TestDevcontainerHelperProcess",
"--",
name,
}
testArgs = append(testArgs, args...)
//nolint:gosec // This is a test binary, so we don't need to worry about command injection.
cmd := exec.CommandContext(ctx, e.testExePath, testArgs...)
// Set this environment variable so the child process knows it's the helper.
cmd.Env = append(os.Environ(),
"TEST_DEVCONTAINER_WANT_HELPER_PROCESS=1",
"TEST_DEVCONTAINER_WANT_ARGS="+e.wantArgs,
"TEST_DEVCONTAINER_WANT_ERROR="+fmt.Sprintf("%v", e.wantError),
"TEST_DEVCONTAINER_LOG_FILE="+e.logFile,
)
return cmd
}
// PTYCommandContext returns a PTY command.
func (*testDevcontainerExecer) PTYCommandContext(_ context.Context, name string, args ...string) *pty.Cmd {
// This method shouldn't be called for our devcontainer tests.
panic("PTYCommandContext not expected in devcontainer tests")
}
// This is a special test helper that is executed as a subprocess.
// It simulates the behavior of the devcontainer CLI.
//
//nolint:revive,paralleltest // This is a test helper function.
func TestDevcontainerHelperProcess(t *testing.T) {
// If not called by the test as a helper process, do nothing.
if os.Getenv("TEST_DEVCONTAINER_WANT_HELPER_PROCESS") != "1" {
return
}
helperArgs := flag.Args()
if len(helperArgs) < 1 {
fmt.Fprintf(os.Stderr, "No command\n")
os.Exit(2)
}
if helperArgs[0] != "devcontainer" {
fmt.Fprintf(os.Stderr, "Unknown command: %s\n", helperArgs[0])
os.Exit(2)
}
// Verify arguments against expected arguments and skip
// "devcontainer", it's not included in the input args.
wantArgs := os.Getenv("TEST_DEVCONTAINER_WANT_ARGS")
gotArgs := strings.Join(helperArgs[1:], " ")
if gotArgs != wantArgs {
fmt.Fprintf(os.Stderr, "Arguments don't match.\nWant: %q\nGot: %q\n",
wantArgs, gotArgs)
os.Exit(2)
}
logFilePath := os.Getenv("TEST_DEVCONTAINER_LOG_FILE")
if logFilePath != "" {
// Read and output log file for commands that need it (like "up")
output, err := os.ReadFile(logFilePath)
if err != nil {
fmt.Fprintf(os.Stderr, "Reading log file %s failed: %v\n", logFilePath, err)
os.Exit(2)
}
_, _ = io.Copy(os.Stdout, bytes.NewReader(output))
}
if os.Getenv("TEST_DEVCONTAINER_WANT_ERROR") == "true" {
os.Exit(1)
}
os.Exit(0)
}
// TestDockerDevcontainerCLI tests the DevcontainerCLI component with real Docker containers.
// This test verifies that containers can be created and recreated using the actual
// devcontainer CLI and Docker. It is skipped by default and can be run with:
//
// CODER_TEST_USE_DOCKER=1 go test ./agent/agentcontainers -run TestDockerDevcontainerCLI
//
// The test requires Docker to be installed and running.
func TestDockerDevcontainerCLI(t *testing.T) {
t.Parallel()
if os.Getenv("CODER_TEST_USE_DOCKER") != "1" {
t.Skip("skipping Docker test; set CODER_TEST_USE_DOCKER=1 to run")
}
if _, err := exec.LookPath("devcontainer"); err != nil {
t.Fatal("this test requires the devcontainer CLI: npm install -g @devcontainers/cli")
}
// Connect to Docker.
pool, err := dockertest.NewPool("")
require.NoError(t, err, "connect to Docker")
t.Run("ContainerLifecycle", func(t *testing.T) {
t.Parallel()
// Set up workspace directory with a devcontainer configuration.
workspaceFolder := t.TempDir()
configPath := setupDevcontainerWorkspace(t, workspaceFolder)
// Use a long timeout because container operations are slow.
ctx := testutil.Context(t, testutil.WaitLong)
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
// Create the devcontainer CLI under test.
dccli := agentcontainers.NewDevcontainerCLI(logger, agentexec.DefaultExecer)
// Create a container.
firstID, err := dccli.Up(ctx, workspaceFolder, configPath)
require.NoError(t, err, "create container")
require.NotEmpty(t, firstID, "container ID should not be empty")
defer removeDevcontainerByID(t, pool, firstID)
// Verify container exists.
firstContainer, found := findDevcontainerByID(t, pool, firstID)
require.True(t, found, "container should exist")
// Remember the container creation time.
firstCreated := firstContainer.Created
// Recreate the container.
secondID, err := dccli.Up(ctx, workspaceFolder, configPath, agentcontainers.WithRemoveExistingContainer())
require.NoError(t, err, "recreate container")
require.NotEmpty(t, secondID, "recreated container ID should not be empty")
defer removeDevcontainerByID(t, pool, secondID)
// Verify the new container exists and is different.
secondContainer, found := findDevcontainerByID(t, pool, secondID)
require.True(t, found, "recreated container should exist")
// Verify it's a different container by checking creation time.
secondCreated := secondContainer.Created
assert.NotEqual(t, firstCreated, secondCreated, "recreated container should have different creation time")
// Verify the first container is removed by the recreation.
_, found = findDevcontainerByID(t, pool, firstID)
assert.False(t, found, "first container should be removed")
})
}
// setupDevcontainerWorkspace prepares a test environment with a minimal
// devcontainer.json configuration and returns the path to the config file.
func setupDevcontainerWorkspace(t *testing.T, workspaceFolder string) string {
t.Helper()
// Create the devcontainer directory structure.
devcontainerDir := filepath.Join(workspaceFolder, ".devcontainer")
err := os.MkdirAll(devcontainerDir, 0o755)
require.NoError(t, err, "create .devcontainer directory")
// Write a minimal configuration with test labels for identification.
configPath := filepath.Join(devcontainerDir, "devcontainer.json")
content := `{
"image": "alpine:latest",
"containerEnv": {
"TEST_CONTAINER": "true"
},
"runArgs": ["--label", "com.coder.test=devcontainercli"]
}`
err = os.WriteFile(configPath, []byte(content), 0o600)
require.NoError(t, err, "create devcontainer.json file")
return configPath
}
// findDevcontainerByID locates a container by its ID and verifies it has our
// test label. Returns the container and whether it was found.
func findDevcontainerByID(t *testing.T, pool *dockertest.Pool, id string) (*docker.Container, bool) {
t.Helper()
container, err := pool.Client.InspectContainer(id)
if err != nil {
t.Logf("Inspect container failed: %v", err)
return nil, false
}
require.Equal(t, "devcontainercli", container.Config.Labels["com.coder.test"], "sanity check failed: container should have the test label")
return container, true
}
// removeDevcontainerByID safely cleans up a test container by ID, verifying
// it has our test label before removal to prevent accidental deletion.
func removeDevcontainerByID(t *testing.T, pool *dockertest.Pool, id string) {
t.Helper()
errNoSuchContainer := &docker.NoSuchContainer{}
// Check if the container has the expected label.
container, err := pool.Client.InspectContainer(id)
if err != nil {
if errors.As(err, &errNoSuchContainer) {
t.Logf("Container %s not found, skipping removal", id)
return
}
require.NoError(t, err, "inspect container")
}
require.Equal(t, "devcontainercli", container.Config.Labels["com.coder.test"], "sanity check failed: container should have the test label")
t.Logf("Removing container with ID: %s", id)
err = pool.Client.RemoveContainer(docker.RemoveContainerOptions{
ID: container.ID,
Force: true,
RemoveVolumes: true,
})
if err != nil && !errors.As(err, &errNoSuchContainer) {
assert.NoError(t, err, "remove container failed")
}
}