Files
coder/agent/agentcontainers/api_test.go
Garrett Delfosse 3462c31f43 fix: update directory for terraform-managed subagents (#24220)
When a devcontainer subagent is terraform-managed, the provisioner sets
its directory to the host-side `workspace_folder` path at build time. At
runtime, the agent injection code determines the correct
container-internal
path from `devcontainer read-configuration` and sends it via
`CreateSubAgent`.

However, the `CreateSubAgent` handler only updated `display_apps` for
pre-existing agents, ignoring the `Directory` field. This caused
SSH/terminal
sessions to land in `~` instead of the workspace folder (e.g.
`/workspaces/foo`).

Add `UpdateWorkspaceAgentDirectoryByID` query and call it in the
terraform-managed subagent update path to also persist the directory.

Fixes PLAT-118

<details><summary>Root cause analysis</summary>

Two code paths set the subagent `Directory` field:

1. **Provisioner (build time):** `insertDevcontainerSubagent` in
`provisionerdserver.go`
   stores `dc.GetWorkspaceFolder()` — the **host-side** path from the
   `coder_devcontainer` Terraform resource (e.g. `/home/coder/project`).

2. **Agent injection (runtime):**
`maybeInjectSubAgentIntoContainerLocked` in
`api.go` reads the devcontainer config and gets the correct
**container-internal**
path (e.g. `/workspaces/project`), then calls `client.Create(ctx,
subAgentConfig)`.

For terraform-managed subagents (those with `req.Id != nil`),
`CreateSubAgent`
in `coderd/agentapi/subagent.go` recognized the pre-existing agent and
entered
the update path — but only called `UpdateWorkspaceAgentDisplayAppsByID`,
discarding the `Directory` field from the request. The agent kept the
stale
host-side path, which doesn't exist inside the container, causing
`expandPathToAbs` to fall back to `~`.

</details>

> [!NOTE]
> Generated by Coder Agents
2026-04-10 10:11:22 -04:00

5186 lines
178 KiB
Go

package agentcontainers_test
import (
"context"
"encoding/json"
"fmt"
"math/rand"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"runtime"
"slices"
"strings"
"sync"
"testing"
"time"
"github.com/fsnotify/fsnotify"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/lib/pq"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"cdr.dev/slog/v3/sloggers/sloghuman"
"cdr.dev/slog/v3/sloggers/slogtest"
"github.com/coder/coder/v2/agent/agentcontainers"
"github.com/coder/coder/v2/agent/agentcontainers/acmock"
"github.com/coder/coder/v2/agent/agentcontainers/watcher"
"github.com/coder/coder/v2/agent/usershell"
"github.com/coder/coder/v2/coderd/util/slice"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/pty"
"github.com/coder/coder/v2/testutil"
"github.com/coder/quartz"
"github.com/coder/websocket"
)
// fakeContainerCLI implements the agentcontainers.ContainerCLI interface for
// testing.
type fakeContainerCLI struct {
mu sync.Mutex
containers codersdk.WorkspaceAgentListContainersResponse
listErr error
arch string
archErr error
copyErr error
execErr error
stopErr error
removeErr error
}
func (f *fakeContainerCLI) List(_ context.Context) (codersdk.WorkspaceAgentListContainersResponse, error) {
f.mu.Lock()
defer f.mu.Unlock()
return f.containers, f.listErr
}
func (f *fakeContainerCLI) DetectArchitecture(_ context.Context, _ string) (string, error) {
f.mu.Lock()
defer f.mu.Unlock()
return f.arch, f.archErr
}
func (f *fakeContainerCLI) Copy(ctx context.Context, name, src, dst string) error {
f.mu.Lock()
defer f.mu.Unlock()
return f.copyErr
}
func (f *fakeContainerCLI) ExecAs(ctx context.Context, name, user string, args ...string) ([]byte, error) {
f.mu.Lock()
defer f.mu.Unlock()
return nil, f.execErr
}
func (f *fakeContainerCLI) Stop(ctx context.Context, name string) error {
f.mu.Lock()
defer f.mu.Unlock()
f.containers.Devcontainers = slice.Filter(f.containers.Devcontainers, func(dc codersdk.WorkspaceAgentDevcontainer) bool {
return dc.Container.ID == name
})
for i, container := range f.containers.Containers {
container.Running = false
f.containers.Containers[i] = container
}
return f.stopErr
}
func (f *fakeContainerCLI) Remove(ctx context.Context, name string) error {
f.mu.Lock()
defer f.mu.Unlock()
f.containers.Containers = slice.Filter(f.containers.Containers, func(container codersdk.WorkspaceAgentContainer) bool {
return container.ID == name
})
return f.removeErr
}
// fakeDevcontainerCLI implements the agentcontainers.DevcontainerCLI
// interface for testing.
type fakeDevcontainerCLI struct {
up func(workspaceFolder, configPath string) (string, error)
upID string
upErr error
upErrC chan func() error // If set, send to return err, close to return upErr.
execErr error
execErrC chan func(cmd string, args ...string) error // If set, send fn to return err, nil or close to return execErr.
readConfig agentcontainers.DevcontainerConfig
readConfigErr error
readConfigErrC chan func(envs []string) error
configMap map[string]agentcontainers.DevcontainerConfig // By config path
}
func (f *fakeDevcontainerCLI) Up(ctx context.Context, workspaceFolder, configPath string, _ ...agentcontainers.DevcontainerCLIUpOptions) (string, error) {
if f.up != nil {
return f.up(workspaceFolder, configPath)
}
if f.upErrC != nil {
select {
case <-ctx.Done():
return "", ctx.Err()
case fn, ok := <-f.upErrC:
if ok {
return f.upID, fn()
}
}
}
return f.upID, f.upErr
}
func (f *fakeDevcontainerCLI) Exec(ctx context.Context, _, _ string, cmd string, args []string, _ ...agentcontainers.DevcontainerCLIExecOptions) error {
if f.execErrC != nil {
select {
case <-ctx.Done():
return ctx.Err()
case fn, ok := <-f.execErrC:
if ok && fn != nil {
return fn(cmd, args...)
}
}
}
return f.execErr
}
// newFakeDevcontainerCLI returns a `fakeDevcontainerCLI` with the common
// channel-based controls initialized, plus a cleanup function.
func newFakeDevcontainerCLI(t testing.TB, cfg agentcontainers.DevcontainerConfig) (*fakeDevcontainerCLI, func()) {
t.Helper()
cli := &fakeDevcontainerCLI{
readConfig: cfg,
execErrC: make(chan func(cmd string, args ...string) error, 1),
readConfigErrC: make(chan func(envs []string) error, 1),
}
var once sync.Once
cleanup := func() {
once.Do(func() {
close(cli.execErrC)
close(cli.readConfigErrC)
})
}
return cli, cleanup
}
// requireDevcontainerExec ensures the devcontainer CLI Exec behaves like a
// running process: it signals started by closing `started`, then blocks until
// `stop` is closed or ctx is canceled.
func requireDevcontainerExec(
ctx context.Context,
t testing.TB,
cli *fakeDevcontainerCLI,
started chan struct{},
stop <-chan struct{},
) {
t.Helper()
require.NotNil(t, cli, "developer error: devcontainerCLI is nil")
require.NotNil(t, started, "developer error: started channel is nil")
require.NotNil(t, stop, "developer error: stop channel is nil")
if cli.execErrC == nil {
cli.execErrC = make(chan func(cmd string, args ...string) error, 1)
t.Cleanup(func() {
close(cli.execErrC)
})
}
testutil.RequireSend(ctx, t, cli.execErrC, func(_ string, _ ...string) error {
close(started)
select {
case <-stop:
return nil
case <-ctx.Done():
return ctx.Err()
}
})
}
func (f *fakeDevcontainerCLI) ReadConfig(ctx context.Context, _, configPath string, envs []string, _ ...agentcontainers.DevcontainerCLIReadConfigOptions) (agentcontainers.DevcontainerConfig, error) {
if f.configMap != nil {
if v, found := f.configMap[configPath]; found {
return v, f.readConfigErr
}
}
if f.readConfigErrC != nil {
select {
case <-ctx.Done():
return agentcontainers.DevcontainerConfig{}, ctx.Err()
case fn, ok := <-f.readConfigErrC:
if ok {
return f.readConfig, fn(envs)
}
}
}
return f.readConfig, f.readConfigErr
}
// fakeWatcher implements the watcher.Watcher interface for testing.
// It allows controlling what events are sent and when.
type fakeWatcher struct {
t testing.TB
events chan *fsnotify.Event
closeNotify chan struct{}
addedPaths []string
closed bool
nextCalled chan struct{}
nextErr error
closeErr error
}
func newFakeWatcher(t testing.TB) *fakeWatcher {
return &fakeWatcher{
t: t,
events: make(chan *fsnotify.Event, 10), // Buffered to avoid blocking tests.
closeNotify: make(chan struct{}),
addedPaths: make([]string, 0),
nextCalled: make(chan struct{}, 1),
}
}
func (w *fakeWatcher) Add(file string) error {
w.addedPaths = append(w.addedPaths, file)
return nil
}
func (w *fakeWatcher) Remove(file string) error {
for i, path := range w.addedPaths {
if path == file {
w.addedPaths = append(w.addedPaths[:i], w.addedPaths[i+1:]...)
break
}
}
return nil
}
func (w *fakeWatcher) clearNext() {
select {
case <-w.nextCalled:
default:
}
}
func (w *fakeWatcher) waitNext(ctx context.Context) bool {
select {
case <-w.t.Context().Done():
return false
case <-ctx.Done():
return false
case <-w.closeNotify:
return false
case <-w.nextCalled:
return true
}
}
func (w *fakeWatcher) Next(ctx context.Context) (*fsnotify.Event, error) {
select {
case w.nextCalled <- struct{}{}:
default:
}
if w.nextErr != nil {
err := w.nextErr
w.nextErr = nil
return nil, err
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-w.closeNotify:
return nil, watcher.ErrClosed
case event := <-w.events:
return event, nil
}
}
func (w *fakeWatcher) Close() error {
if w.closed {
return nil
}
w.closed = true
close(w.closeNotify)
return w.closeErr
}
// sendEvent sends a file system event through the fake watcher.
func (w *fakeWatcher) sendEventWaitNextCalled(ctx context.Context, event fsnotify.Event) {
w.clearNext()
w.events <- &event
w.waitNext(ctx)
}
// newFakeSubAgentClient returns a `fakeSubAgentClient` with the common
// channel-based controls initialized, plus a cleanup function.
func newFakeSubAgentClient(t testing.TB, logger slog.Logger) (*fakeSubAgentClient, func()) {
t.Helper()
sac := &fakeSubAgentClient{
logger: logger,
agents: make(map[uuid.UUID]agentcontainers.SubAgent),
createErrC: make(chan error, 1),
deleteErrC: make(chan error, 1),
}
var once sync.Once
cleanup := func() {
once.Do(func() {
close(sac.createErrC)
close(sac.deleteErrC)
})
}
return sac, cleanup
}
func allowSubAgentCreate(ctx context.Context, t testing.TB, sac *fakeSubAgentClient) {
t.Helper()
require.NotNil(t, sac, "developer error: subAgentClient is nil")
require.NotNil(t, sac.createErrC, "developer error: createErrC is nil")
testutil.RequireSend(ctx, t, sac.createErrC, nil)
}
func allowSubAgentDelete(ctx context.Context, t testing.TB, sac *fakeSubAgentClient) {
t.Helper()
require.NotNil(t, sac, "developer error: subAgentClient is nil")
require.NotNil(t, sac.deleteErrC, "developer error: deleteErrC is nil")
testutil.RequireSend(ctx, t, sac.deleteErrC, nil)
}
func expectSubAgentInjection(
mCCLI *acmock.MockContainerCLI,
containerID string,
arch string,
coderBin string,
) {
gomock.InOrder(
mCCLI.EXPECT().DetectArchitecture(gomock.Any(), containerID).Return(arch, nil),
mCCLI.EXPECT().ExecAs(gomock.Any(), containerID, "root", "mkdir", "-p", "/.coder-agent").Return(nil, nil),
mCCLI.EXPECT().Copy(gomock.Any(), containerID, coderBin, "/.coder-agent/coder").Return(nil),
mCCLI.EXPECT().ExecAs(gomock.Any(), containerID, "root", "chmod", "0755", "/.coder-agent", "/.coder-agent/coder").Return(nil, nil),
mCCLI.EXPECT().ExecAs(gomock.Any(), containerID, "root", "/bin/sh", "-c", "chown $(id -u):$(id -g) /.coder-agent/coder").Return(nil, nil),
)
}
// fakeSubAgentClient implements SubAgentClient for testing purposes.
type fakeSubAgentClient struct {
logger slog.Logger
mu sync.Mutex // Protects following.
agents map[uuid.UUID]agentcontainers.SubAgent
listErrC chan error // If set, send to return error, close to return nil.
created []agentcontainers.SubAgent
createErrC chan error // If set, send to return error, close to return nil.
deleted []uuid.UUID
deleteErrC chan error // If set, send to return error, close to return nil.
}
func (m *fakeSubAgentClient) List(ctx context.Context) ([]agentcontainers.SubAgent, error) {
if m.listErrC != nil {
select {
case <-ctx.Done():
return nil, ctx.Err()
case err := <-m.listErrC:
if err != nil {
return nil, err
}
}
}
m.mu.Lock()
defer m.mu.Unlock()
var agents []agentcontainers.SubAgent
for _, agent := range m.agents {
agents = append(agents, agent)
}
return agents, nil
}
func (m *fakeSubAgentClient) Create(ctx context.Context, agent agentcontainers.SubAgent) (agentcontainers.SubAgent, error) {
m.logger.Debug(ctx, "creating sub agent", slog.F("agent", agent))
if m.createErrC != nil {
select {
case <-ctx.Done():
return agentcontainers.SubAgent{}, ctx.Err()
case err := <-m.createErrC:
if err != nil {
return agentcontainers.SubAgent{}, err
}
}
}
if agent.Name == "" {
return agentcontainers.SubAgent{}, xerrors.New("name must be set")
}
if agent.Architecture == "" {
return agentcontainers.SubAgent{}, xerrors.New("architecture must be set")
}
if agent.OperatingSystem == "" {
return agentcontainers.SubAgent{}, xerrors.New("operating system must be set")
}
m.mu.Lock()
defer m.mu.Unlock()
for _, a := range m.agents {
if a.Name == agent.Name {
return agentcontainers.SubAgent{}, &pq.Error{
Code: "23505",
Message: fmt.Sprintf("workspace agent name %q already exists in this workspace build", agent.Name),
}
}
}
// Only generate a new ID if one wasn't provided. Terraform-defined
// subagents have pre-existing IDs that should be preserved.
if agent.ID == uuid.Nil {
agent.ID = uuid.New()
}
agent.AuthToken = uuid.New()
if m.agents == nil {
m.agents = make(map[uuid.UUID]agentcontainers.SubAgent)
}
m.agents[agent.ID] = agent
m.created = append(m.created, agent)
return agent, nil
}
func (m *fakeSubAgentClient) Delete(ctx context.Context, id uuid.UUID) error {
m.logger.Debug(ctx, "deleting sub agent", slog.F("id", id.String()))
if m.deleteErrC != nil {
select {
case <-ctx.Done():
return ctx.Err()
case err := <-m.deleteErrC:
if err != nil {
return err
}
}
}
m.mu.Lock()
defer m.mu.Unlock()
if m.agents == nil {
m.agents = make(map[uuid.UUID]agentcontainers.SubAgent)
}
delete(m.agents, id)
m.deleted = append(m.deleted, id)
return nil
}
// fakeExecer implements agentexec.Execer for testing and tracks execution details.
type fakeExecer struct {
commands [][]string
createdCommands []*exec.Cmd
}
func (f *fakeExecer) CommandContext(ctx context.Context, cmd string, args ...string) *exec.Cmd {
f.commands = append(f.commands, append([]string{cmd}, args...))
// Create a command that returns empty JSON for docker commands.
c := exec.CommandContext(ctx, "echo", "[]")
f.createdCommands = append(f.createdCommands, c)
return c
}
func (f *fakeExecer) PTYCommandContext(ctx context.Context, cmd string, args ...string) *pty.Cmd {
f.commands = append(f.commands, append([]string{cmd}, args...))
return &pty.Cmd{
Context: ctx,
Path: cmd,
Args: append([]string{cmd}, args...),
Env: []string{},
Dir: "",
}
}
func (f *fakeExecer) getLastCommand() *exec.Cmd {
if len(f.createdCommands) == 0 {
return nil
}
return f.createdCommands[len(f.createdCommands)-1]
}
func TestAPI(t *testing.T) {
t.Parallel()
t.Run("NoUpdaterLoopLogspam", func(t *testing.T) {
t.Parallel()
var (
ctx = testutil.Context(t, testutil.WaitShort)
logbuf strings.Builder
logger = slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug).AppendSinks(sloghuman.Sink(&logbuf))
mClock = quartz.NewMock(t)
tickerTrap = mClock.Trap().TickerFunc("updaterLoop")
firstErr = xerrors.New("first error")
secondErr = xerrors.New("second error")
fakeCLI = &fakeContainerCLI{
listErr: firstErr,
}
fWatcher = newFakeWatcher(t)
)
api := agentcontainers.NewAPI(logger,
agentcontainers.WithWatcher(fWatcher),
agentcontainers.WithClock(mClock),
agentcontainers.WithContainerCLI(fakeCLI),
)
api.Start()
defer api.Close()
// The watcherLoop writes a log when it is initialized.
// We want to ensure this has happened before we start
// the test so that it does not intefere.
fWatcher.waitNext(ctx)
// Make sure the ticker function has been registered
// before advancing the clock.
tickerTrap.MustWait(ctx).MustRelease(ctx)
tickerTrap.Close()
logbuf.Reset()
// First tick should handle the error.
_, aw := mClock.AdvanceNext()
aw.MustWait(ctx)
// Verify first error is logged.
got := logbuf.String()
t.Logf("got log: %q", got)
require.Contains(t, got, "updater loop ticker failed", "first error should be logged")
require.Contains(t, got, "first error", "should contain first error message")
logbuf.Reset()
// Second tick should handle the same error without logging it again.
_, aw = mClock.AdvanceNext()
aw.MustWait(ctx)
// Verify same error is not logged again.
got = logbuf.String()
t.Logf("got log: %q", got)
require.Empty(t, got, "same error should not be logged again")
// Change to a different error.
fakeCLI.listErr = secondErr
// Third tick should handle the different error and log it.
_, aw = mClock.AdvanceNext()
aw.MustWait(ctx)
// Verify different error is logged.
got = logbuf.String()
t.Logf("got log: %q", got)
require.Contains(t, got, "updater loop ticker failed", "different error should be logged")
require.Contains(t, got, "second error", "should contain second error message")
logbuf.Reset()
// Clear the error to simulate success.
fakeCLI.listErr = nil
// Fourth tick should succeed.
_, aw = mClock.AdvanceNext()
aw.MustWait(ctx)
// Fifth tick should continue to succeed.
_, aw = mClock.AdvanceNext()
aw.MustWait(ctx)
// Verify successful operations are logged properly.
got = logbuf.String()
t.Logf("got log: %q", got)
gotSuccessCount := strings.Count(got, "containers updated successfully")
require.GreaterOrEqual(t, gotSuccessCount, 2, "should have successful update got")
require.NotContains(t, got, "updater loop ticker failed", "no errors should be logged during success")
logbuf.Reset()
// Reintroduce the original error.
fakeCLI.listErr = firstErr
// Sixth tick should handle the error after success and log it.
_, aw = mClock.AdvanceNext()
aw.MustWait(ctx)
// Verify error after success is logged.
got = logbuf.String()
t.Logf("got log: %q", got)
require.Contains(t, got, "updater loop ticker failed", "error after success should be logged")
require.Contains(t, got, "first error", "should contain first error message")
logbuf.Reset()
})
t.Run("Watch", func(t *testing.T) {
t.Parallel()
fakeContainer1 := fakeContainer(t, func(c *codersdk.WorkspaceAgentContainer) {
c.ID = "container1"
c.FriendlyName = "devcontainer1"
c.Image = "busybox:latest"
c.Labels = map[string]string{
agentcontainers.DevcontainerLocalFolderLabel: "/home/coder/project1",
agentcontainers.DevcontainerConfigFileLabel: "/home/coder/project1/.devcontainer/devcontainer.json",
}
})
fakeContainer2 := fakeContainer(t, func(c *codersdk.WorkspaceAgentContainer) {
c.ID = "container2"
c.FriendlyName = "devcontainer2"
c.Image = "ubuntu:latest"
c.Labels = map[string]string{
agentcontainers.DevcontainerLocalFolderLabel: "/home/coder/project2",
agentcontainers.DevcontainerConfigFileLabel: "/home/coder/project2/.devcontainer/devcontainer.json",
}
})
stages := []struct {
containers []codersdk.WorkspaceAgentContainer
expected codersdk.WorkspaceAgentListContainersResponse
}{
{
containers: []codersdk.WorkspaceAgentContainer{fakeContainer1},
expected: codersdk.WorkspaceAgentListContainersResponse{
Containers: []codersdk.WorkspaceAgentContainer{fakeContainer1},
Devcontainers: []codersdk.WorkspaceAgentDevcontainer{
{
Name: "project1",
WorkspaceFolder: fakeContainer1.Labels[agentcontainers.DevcontainerLocalFolderLabel],
ConfigPath: fakeContainer1.Labels[agentcontainers.DevcontainerConfigFileLabel],
Status: "running",
Container: &fakeContainer1,
},
},
},
},
{
containers: []codersdk.WorkspaceAgentContainer{fakeContainer1, fakeContainer2},
expected: codersdk.WorkspaceAgentListContainersResponse{
Containers: []codersdk.WorkspaceAgentContainer{fakeContainer1, fakeContainer2},
Devcontainers: []codersdk.WorkspaceAgentDevcontainer{
{
Name: "project1",
WorkspaceFolder: fakeContainer1.Labels[agentcontainers.DevcontainerLocalFolderLabel],
ConfigPath: fakeContainer1.Labels[agentcontainers.DevcontainerConfigFileLabel],
Status: "running",
Container: &fakeContainer1,
},
{
Name: "project2",
WorkspaceFolder: fakeContainer2.Labels[agentcontainers.DevcontainerLocalFolderLabel],
ConfigPath: fakeContainer2.Labels[agentcontainers.DevcontainerConfigFileLabel],
Status: "running",
Container: &fakeContainer2,
},
},
},
},
{
containers: []codersdk.WorkspaceAgentContainer{fakeContainer2},
expected: codersdk.WorkspaceAgentListContainersResponse{
Containers: []codersdk.WorkspaceAgentContainer{fakeContainer2},
Devcontainers: []codersdk.WorkspaceAgentDevcontainer{
{
Name: "",
WorkspaceFolder: fakeContainer1.Labels[agentcontainers.DevcontainerLocalFolderLabel],
ConfigPath: fakeContainer1.Labels[agentcontainers.DevcontainerConfigFileLabel],
Status: "stopped",
Container: nil,
},
{
Name: "project2",
WorkspaceFolder: fakeContainer2.Labels[agentcontainers.DevcontainerLocalFolderLabel],
ConfigPath: fakeContainer2.Labels[agentcontainers.DevcontainerConfigFileLabel],
Status: "running",
Container: &fakeContainer2,
},
},
},
},
}
var (
ctx = testutil.Context(t, testutil.WaitShort)
mClock = quartz.NewMock(t)
updaterTickerTrap = mClock.Trap().TickerFunc("updaterLoop")
mCtrl = gomock.NewController(t)
mLister = acmock.NewMockContainerCLI(mCtrl)
logger = slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
)
// Set up initial state for immediate send on connection
mLister.EXPECT().List(gomock.Any()).Return(codersdk.WorkspaceAgentListContainersResponse{Containers: stages[0].containers}, nil)
mLister.EXPECT().DetectArchitecture(gomock.Any(), gomock.Any()).Return("<none>", nil).AnyTimes()
api := agentcontainers.NewAPI(logger,
agentcontainers.WithClock(mClock),
agentcontainers.WithContainerCLI(mLister),
agentcontainers.WithWatcher(watcher.NewNoop()),
)
api.Start()
defer api.Close()
srv := httptest.NewServer(api.Routes())
defer srv.Close()
updaterTickerTrap.MustWait(ctx).MustRelease(ctx)
defer updaterTickerTrap.Close()
client, res, err := websocket.Dial(ctx, srv.URL+"/watch", nil)
require.NoError(t, err)
if res != nil && res.Body != nil {
defer res.Body.Close()
}
// Read initial state sent immediately on connection
mt, msg, err := client.Read(ctx)
require.NoError(t, err)
require.Equal(t, websocket.MessageText, mt)
var got codersdk.WorkspaceAgentListContainersResponse
err = json.Unmarshal(msg, &got)
require.NoError(t, err)
require.Equal(t, stages[0].expected.Containers, got.Containers)
require.Len(t, got.Devcontainers, len(stages[0].expected.Devcontainers))
for j, expectedDev := range stages[0].expected.Devcontainers {
gotDev := got.Devcontainers[j]
require.Equal(t, expectedDev.Name, gotDev.Name)
require.Equal(t, expectedDev.WorkspaceFolder, gotDev.WorkspaceFolder)
require.Equal(t, expectedDev.ConfigPath, gotDev.ConfigPath)
require.Equal(t, expectedDev.Status, gotDev.Status)
require.Equal(t, expectedDev.Container, gotDev.Container)
}
// Process remaining stages through updater loop
for i, stage := range stages[1:] {
mLister.EXPECT().List(gomock.Any()).Return(codersdk.WorkspaceAgentListContainersResponse{Containers: stage.containers}, nil)
// Given: We allow the update loop to progress
_, aw := mClock.AdvanceNext()
aw.MustWait(ctx)
// When: We attempt to read a message from the socket.
mt, msg, err := client.Read(ctx)
require.NoError(t, err)
require.Equal(t, websocket.MessageText, mt)
// Then: We expect the receieved message matches the expected response.
var got codersdk.WorkspaceAgentListContainersResponse
err = json.Unmarshal(msg, &got)
require.NoError(t, err)
require.Equal(t, stages[i+1].expected.Containers, got.Containers)
require.Len(t, got.Devcontainers, len(stages[i+1].expected.Devcontainers))
for j, expectedDev := range stages[i+1].expected.Devcontainers {
gotDev := got.Devcontainers[j]
require.Equal(t, expectedDev.Name, gotDev.Name)
require.Equal(t, expectedDev.WorkspaceFolder, gotDev.WorkspaceFolder)
require.Equal(t, expectedDev.ConfigPath, gotDev.ConfigPath)
require.Equal(t, expectedDev.Status, gotDev.Status)
require.Equal(t, expectedDev.Container, gotDev.Container)
}
}
})
// List tests the API.getContainers method using a mock
// implementation. It specifically tests caching behavior.
t.Run("List", func(t *testing.T) {
t.Parallel()
fakeCt := fakeContainer(t)
fakeCt2 := fakeContainer(t)
makeResponse := func(cts ...codersdk.WorkspaceAgentContainer) codersdk.WorkspaceAgentListContainersResponse {
return codersdk.WorkspaceAgentListContainersResponse{Containers: cts}
}
type initialDataPayload struct {
val codersdk.WorkspaceAgentListContainersResponse
err error
}
// Each test case is called multiple times to ensure idempotency
for _, tc := range []struct {
name string
// initialData to be stored in the handler
initialData initialDataPayload
// function to set up expectations for the mock
setupMock func(mcl *acmock.MockContainerCLI, preReq *gomock.Call)
// expected result
expected codersdk.WorkspaceAgentListContainersResponse
// expected error
expectedErr string
}{
{
name: "no initial data",
initialData: initialDataPayload{makeResponse(), nil},
setupMock: func(mcl *acmock.MockContainerCLI, preReq *gomock.Call) {
mcl.EXPECT().List(gomock.Any()).Return(makeResponse(fakeCt), nil).After(preReq).AnyTimes()
},
expected: makeResponse(fakeCt),
},
{
name: "repeat initial data",
initialData: initialDataPayload{makeResponse(fakeCt), nil},
expected: makeResponse(fakeCt),
},
{
name: "lister error always",
initialData: initialDataPayload{makeResponse(), assert.AnError},
expectedErr: assert.AnError.Error(),
},
{
name: "lister error only during initial data",
initialData: initialDataPayload{makeResponse(), assert.AnError},
setupMock: func(mcl *acmock.MockContainerCLI, preReq *gomock.Call) {
mcl.EXPECT().List(gomock.Any()).Return(makeResponse(fakeCt), nil).After(preReq).AnyTimes()
},
expected: makeResponse(fakeCt),
},
{
name: "lister error after initial data",
initialData: initialDataPayload{makeResponse(fakeCt), nil},
setupMock: func(mcl *acmock.MockContainerCLI, preReq *gomock.Call) {
mcl.EXPECT().List(gomock.Any()).Return(makeResponse(), assert.AnError).After(preReq).AnyTimes()
},
expectedErr: assert.AnError.Error(),
},
{
name: "updated data",
initialData: initialDataPayload{makeResponse(fakeCt), nil},
setupMock: func(mcl *acmock.MockContainerCLI, preReq *gomock.Call) {
mcl.EXPECT().List(gomock.Any()).Return(makeResponse(fakeCt2), nil).After(preReq).AnyTimes()
},
expected: makeResponse(fakeCt2),
},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
var (
ctx = testutil.Context(t, testutil.WaitShort)
mClock = quartz.NewMock(t)
tickerTrap = mClock.Trap().TickerFunc("updaterLoop")
mCtrl = gomock.NewController(t)
mLister = acmock.NewMockContainerCLI(mCtrl)
logger = slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
r = chi.NewRouter()
)
initialDataCall := mLister.EXPECT().List(gomock.Any()).Return(tc.initialData.val, tc.initialData.err)
if tc.setupMock != nil {
tc.setupMock(mLister, initialDataCall.Times(1))
} else {
initialDataCall.AnyTimes()
}
api := agentcontainers.NewAPI(logger,
agentcontainers.WithClock(mClock),
agentcontainers.WithContainerCLI(mLister),
agentcontainers.WithContainerLabelIncludeFilter("this.label.does.not.exist.ignore.devcontainers", "true"),
)
api.Start()
defer api.Close()
r.Mount("/", api.Routes())
// Make sure the ticker function has been registered
// before advancing the clock.
tickerTrap.MustWait(ctx).MustRelease(ctx)
tickerTrap.Close()
// Initial request returns the initial data.
req := httptest.NewRequest(http.MethodGet, "/", nil).
WithContext(ctx)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if tc.initialData.err != nil {
got := &codersdk.Error{}
err := json.NewDecoder(rec.Body).Decode(got)
require.NoError(t, err, "unmarshal response failed")
require.ErrorContains(t, got, tc.initialData.err.Error(), "want error")
} else {
var got codersdk.WorkspaceAgentListContainersResponse
err := json.NewDecoder(rec.Body).Decode(&got)
require.NoError(t, err, "unmarshal response failed")
require.Equal(t, tc.initialData.val, got, "want initial data")
}
// Advance the clock to run updaterLoop.
_, aw := mClock.AdvanceNext()
aw.MustWait(ctx)
// Second request returns the updated data.
req = httptest.NewRequest(http.MethodGet, "/", nil).
WithContext(ctx)
rec = httptest.NewRecorder()
r.ServeHTTP(rec, req)
if tc.expectedErr != "" {
got := &codersdk.Error{}
err := json.NewDecoder(rec.Body).Decode(got)
require.NoError(t, err, "unmarshal response failed")
require.ErrorContains(t, got, tc.expectedErr, "want error")
return
}
var got codersdk.WorkspaceAgentListContainersResponse
err := json.NewDecoder(rec.Body).Decode(&got)
require.NoError(t, err, "unmarshal response failed")
require.Equal(t, tc.expected, got, "want updated data")
})
}
})
t.Run("Recreate", func(t *testing.T) {
t.Parallel()
devcontainerID1 := uuid.New()
devcontainerID2 := uuid.New()
workspaceFolder1 := "/workspace/test1"
workspaceFolder2 := "/workspace/test2"
configPath1 := "/workspace/test1/.devcontainer/devcontainer.json"
configPath2 := "/workspace/test2/.devcontainer/devcontainer.json"
// Create a container that represents an existing devcontainer
devContainer1 := codersdk.WorkspaceAgentContainer{
ID: "container-1",
FriendlyName: "test-container-1",
Running: true,
Labels: map[string]string{
agentcontainers.DevcontainerLocalFolderLabel: workspaceFolder1,
agentcontainers.DevcontainerConfigFileLabel: configPath1,
},
}
devContainer2 := codersdk.WorkspaceAgentContainer{
ID: "container-2",
FriendlyName: "test-container-2",
Running: true,
Labels: map[string]string{
agentcontainers.DevcontainerLocalFolderLabel: workspaceFolder2,
agentcontainers.DevcontainerConfigFileLabel: configPath2,
},
}
tests := []struct {
name string
devcontainerID string
setupDevcontainers []codersdk.WorkspaceAgentDevcontainer
lister *fakeContainerCLI
devcontainerCLI *fakeDevcontainerCLI
wantStatus []int
wantBody []string
}{
{
name: "Missing devcontainer ID",
devcontainerID: "",
lister: &fakeContainerCLI{},
devcontainerCLI: &fakeDevcontainerCLI{},
wantStatus: []int{http.StatusBadRequest},
wantBody: []string{"Missing devcontainer ID"},
},
{
name: "Devcontainer not found",
devcontainerID: uuid.NewString(),
lister: &fakeContainerCLI{
arch: "<none>", // Unsupported architecture, don't inject subagent.
},
devcontainerCLI: &fakeDevcontainerCLI{},
wantStatus: []int{http.StatusNotFound},
wantBody: []string{"Devcontainer not found"},
},
{
name: "Devcontainer CLI error",
devcontainerID: devcontainerID1.String(),
setupDevcontainers: []codersdk.WorkspaceAgentDevcontainer{
{
ID: devcontainerID1,
Name: "test-devcontainer-1",
WorkspaceFolder: workspaceFolder1,
ConfigPath: configPath1,
Status: codersdk.WorkspaceAgentDevcontainerStatusRunning,
Container: &devContainer1,
},
},
lister: &fakeContainerCLI{
containers: codersdk.WorkspaceAgentListContainersResponse{
Containers: []codersdk.WorkspaceAgentContainer{devContainer1},
},
arch: "<none>", // Unsupported architecture, don't inject subagent.
},
devcontainerCLI: &fakeDevcontainerCLI{
upErr: xerrors.New("devcontainer CLI error"),
},
wantStatus: []int{http.StatusAccepted, http.StatusConflict},
wantBody: []string{"Devcontainer recreation initiated", "is currently starting and cannot be restarted"},
},
{
name: "OK",
devcontainerID: devcontainerID2.String(),
setupDevcontainers: []codersdk.WorkspaceAgentDevcontainer{
{
ID: devcontainerID2,
Name: "test-devcontainer-2",
WorkspaceFolder: workspaceFolder2,
ConfigPath: configPath2,
Status: codersdk.WorkspaceAgentDevcontainerStatusRunning,
Container: &devContainer2,
},
},
lister: &fakeContainerCLI{
containers: codersdk.WorkspaceAgentListContainersResponse{
Containers: []codersdk.WorkspaceAgentContainer{devContainer2},
},
arch: "<none>", // Unsupported architecture, don't inject subagent.
},
devcontainerCLI: &fakeDevcontainerCLI{},
wantStatus: []int{http.StatusAccepted, http.StatusConflict},
wantBody: []string{"Devcontainer recreation initiated", "is currently starting and cannot be restarted"},
},
{
name: "Terraform-defined devcontainer can be rebuilt",
devcontainerID: devcontainerID1.String(),
setupDevcontainers: []codersdk.WorkspaceAgentDevcontainer{
{
ID: devcontainerID1,
Name: "test-devcontainer-terraform",
WorkspaceFolder: workspaceFolder1,
ConfigPath: configPath1,
Status: codersdk.WorkspaceAgentDevcontainerStatusRunning,
Container: &devContainer1,
SubagentID: uuid.NullUUID{UUID: uuid.New(), Valid: true},
},
},
lister: &fakeContainerCLI{
containers: codersdk.WorkspaceAgentListContainersResponse{
Containers: []codersdk.WorkspaceAgentContainer{devContainer1},
},
arch: "<none>",
},
devcontainerCLI: &fakeDevcontainerCLI{},
wantStatus: []int{http.StatusAccepted, http.StatusConflict},
wantBody: []string{"Devcontainer recreation initiated", "is currently starting and cannot be restarted"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
require.GreaterOrEqual(t, len(tt.wantStatus), 1, "developer error: at least one status code expected")
require.Len(t, tt.wantStatus, len(tt.wantBody), "developer error: status and body length mismatch")
ctx := testutil.Context(t, testutil.WaitShort)
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
mClock := quartz.NewMock(t)
mClock.Set(time.Now()).MustWait(ctx)
tickerTrap := mClock.Trap().TickerFunc("updaterLoop")
nowRecreateErrorTrap := mClock.Trap().Now("recreate", "errorTimes")
nowRecreateSuccessTrap := mClock.Trap().Now("recreate", "successTimes")
tt.devcontainerCLI.upErrC = make(chan func() error)
// Setup router with the handler under test.
r := chi.NewRouter()
api := agentcontainers.NewAPI(
logger,
agentcontainers.WithClock(mClock),
agentcontainers.WithContainerCLI(tt.lister),
agentcontainers.WithDevcontainerCLI(tt.devcontainerCLI),
agentcontainers.WithWatcher(watcher.NewNoop()),
agentcontainers.WithDevcontainers(tt.setupDevcontainers, nil),
)
api.Start()
defer api.Close()
r.Mount("/", api.Routes())
// Make sure the ticker function has been registered
// before advancing the clock.
tickerTrap.MustWait(ctx).MustRelease(ctx)
tickerTrap.Close()
for i := range tt.wantStatus {
// Simulate HTTP request to the recreate endpoint.
req := httptest.NewRequest(http.MethodPost, "/devcontainers/"+tt.devcontainerID+"/recreate", nil).
WithContext(ctx)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
// Check the response status code and body.
require.Equal(t, tt.wantStatus[i], rec.Code, "status code mismatch")
if tt.wantBody[i] != "" {
assert.Contains(t, rec.Body.String(), tt.wantBody[i], "response body mismatch")
}
}
// Error tests are simple, but the remainder of this test is a
// bit more involved, closer to an integration test. That is
// because we must check what state the devcontainer ends up in
// after the recreation process is initiated and finished.
if tt.wantStatus[0] != http.StatusAccepted {
close(tt.devcontainerCLI.upErrC)
nowRecreateSuccessTrap.Close()
nowRecreateErrorTrap.Close()
return
}
_, aw := mClock.AdvanceNext()
aw.MustWait(ctx)
// Verify the devcontainer is in starting state after recreation
// request is made.
req := httptest.NewRequest(http.MethodGet, "/", nil).
WithContext(ctx)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code, "status code mismatch")
var resp codersdk.WorkspaceAgentListContainersResponse
t.Log(rec.Body.String())
err := json.NewDecoder(rec.Body).Decode(&resp)
require.NoError(t, err, "unmarshal response failed")
require.Len(t, resp.Devcontainers, 1, "expected one devcontainer in response")
assert.Equal(t, codersdk.WorkspaceAgentDevcontainerStatusStarting, resp.Devcontainers[0].Status, "devcontainer is not starting")
require.NotNil(t, resp.Devcontainers[0].Container, "devcontainer should have container reference")
// Allow the devcontainer CLI to continue the up process.
close(tt.devcontainerCLI.upErrC)
// Ensure the devcontainer ends up in error state if the up call fails.
if tt.devcontainerCLI.upErr != nil {
nowRecreateSuccessTrap.Close()
// The timestamp for the error will be stored, which gives
// us a good anchor point to know when to do our request.
nowRecreateErrorTrap.MustWait(ctx).MustRelease(ctx)
nowRecreateErrorTrap.Close()
// Advance the clock to run the devcontainer state update routine.
_, aw = mClock.AdvanceNext()
aw.MustWait(ctx)
req = httptest.NewRequest(http.MethodGet, "/", nil).
WithContext(ctx)
rec = httptest.NewRecorder()
r.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code, "status code mismatch after error")
err = json.NewDecoder(rec.Body).Decode(&resp)
require.NoError(t, err, "unmarshal response failed after error")
require.Len(t, resp.Devcontainers, 1, "expected one devcontainer in response after error")
assert.Equal(t, codersdk.WorkspaceAgentDevcontainerStatusError, resp.Devcontainers[0].Status, "devcontainer is not in an error state after up failure")
require.NotNil(t, resp.Devcontainers[0].Container, "devcontainer should have container reference after up failure")
return
}
// Ensure the devcontainer ends up in success state.
nowRecreateSuccessTrap.MustWait(ctx).MustRelease(ctx)
nowRecreateSuccessTrap.Close()
// Advance the clock to run the devcontainer state update routine.
_, aw = mClock.AdvanceNext()
aw.MustWait(ctx)
req = httptest.NewRequest(http.MethodGet, "/", nil).
WithContext(ctx)
rec = httptest.NewRecorder()
r.ServeHTTP(rec, req)
// Check the response status code and body after recreation.
require.Equal(t, http.StatusOK, rec.Code, "status code mismatch after recreation")
t.Log(rec.Body.String())
err = json.NewDecoder(rec.Body).Decode(&resp)
require.NoError(t, err, "unmarshal response failed after recreation")
require.Len(t, resp.Devcontainers, 1, "expected one devcontainer in response after recreation")
assert.Equal(t, codersdk.WorkspaceAgentDevcontainerStatusRunning, resp.Devcontainers[0].Status, "devcontainer is not running after recreation")
require.NotNil(t, resp.Devcontainers[0].Container, "devcontainer should have container reference after recreation")
})
}
})
t.Run("Delete", func(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" {
t.Skip("Dev Container tests are not supported on Windows (this test uses mocks but fails due to Windows paths)")
}
devcontainerID1 := uuid.New()
workspaceFolder1 := "/workspace/test1"
configPath1 := "/workspace/test1/.devcontainer/devcontainer.json"
// Create a container that represents an existing devcontainer.
devContainer1 := codersdk.WorkspaceAgentContainer{
ID: "container-1",
FriendlyName: "test-container-1",
Running: true,
Labels: map[string]string{
agentcontainers.DevcontainerLocalFolderLabel: workspaceFolder1,
agentcontainers.DevcontainerConfigFileLabel: configPath1,
},
}
tests := []struct {
name string
devcontainerID string
setupDevcontainers []codersdk.WorkspaceAgentDevcontainer
lister *fakeContainerCLI
devcontainerCLI *fakeDevcontainerCLI
wantStatus int
wantBody string
wantSubAgentDeleted bool
}{
{
name: "Missing devcontainer ID",
devcontainerID: "",
lister: &fakeContainerCLI{},
devcontainerCLI: &fakeDevcontainerCLI{},
wantStatus: http.StatusBadRequest,
wantBody: "Missing devcontainer ID",
},
{
name: "Devcontainer not found",
devcontainerID: uuid.NewString(),
lister: &fakeContainerCLI{
arch: "<none>",
},
devcontainerCLI: &fakeDevcontainerCLI{},
wantStatus: http.StatusNotFound,
wantBody: "Devcontainer not found",
},
{
name: "Devcontainer is starting",
devcontainerID: devcontainerID1.String(),
setupDevcontainers: []codersdk.WorkspaceAgentDevcontainer{
{
ID: devcontainerID1,
Name: "test-devcontainer-1",
WorkspaceFolder: workspaceFolder1,
ConfigPath: configPath1,
Status: codersdk.WorkspaceAgentDevcontainerStatusStarting,
Container: &devContainer1,
},
},
lister: &fakeContainerCLI{
containers: codersdk.WorkspaceAgentListContainersResponse{
Containers: []codersdk.WorkspaceAgentContainer{devContainer1},
},
arch: "<none>",
},
devcontainerCLI: &fakeDevcontainerCLI{},
wantStatus: http.StatusConflict,
wantBody: "is currently starting and cannot be deleted",
},
{
name: "Devcontainer is stopping",
devcontainerID: devcontainerID1.String(),
setupDevcontainers: []codersdk.WorkspaceAgentDevcontainer{
{
ID: devcontainerID1,
Name: "test-devcontainer-1",
WorkspaceFolder: workspaceFolder1,
ConfigPath: configPath1,
Status: codersdk.WorkspaceAgentDevcontainerStatusDeleting,
Container: &devContainer1,
},
},
lister: &fakeContainerCLI{
containers: codersdk.WorkspaceAgentListContainersResponse{
Containers: []codersdk.WorkspaceAgentContainer{devContainer1},
},
arch: "<none>",
},
devcontainerCLI: &fakeDevcontainerCLI{},
wantStatus: http.StatusConflict,
wantBody: "is currently deleting and cannot be deleted.",
},
{
name: "Container stop fails",
devcontainerID: devcontainerID1.String(),
setupDevcontainers: []codersdk.WorkspaceAgentDevcontainer{
{
ID: devcontainerID1,
Name: "test-devcontainer-1",
WorkspaceFolder: workspaceFolder1,
ConfigPath: configPath1,
Status: codersdk.WorkspaceAgentDevcontainerStatusRunning,
Container: &devContainer1,
},
},
lister: &fakeContainerCLI{
containers: codersdk.WorkspaceAgentListContainersResponse{
Containers: []codersdk.WorkspaceAgentContainer{devContainer1},
},
arch: "<none>",
stopErr: xerrors.New("stop error"),
},
devcontainerCLI: &fakeDevcontainerCLI{},
wantStatus: http.StatusInternalServerError,
wantBody: "An error occurred stopping the container",
},
{
name: "Container remove fails",
devcontainerID: devcontainerID1.String(),
setupDevcontainers: []codersdk.WorkspaceAgentDevcontainer{
{
ID: devcontainerID1,
Name: "test-devcontainer-1",
WorkspaceFolder: workspaceFolder1,
ConfigPath: configPath1,
Status: codersdk.WorkspaceAgentDevcontainerStatusRunning,
Container: &devContainer1,
},
},
lister: &fakeContainerCLI{
containers: codersdk.WorkspaceAgentListContainersResponse{
Containers: []codersdk.WorkspaceAgentContainer{devContainer1},
},
arch: "<none>",
removeErr: xerrors.New("remove error"),
},
devcontainerCLI: &fakeDevcontainerCLI{},
wantStatus: http.StatusInternalServerError,
wantBody: "An error occurred removing the container",
},
{
name: "OK with container",
devcontainerID: devcontainerID1.String(),
setupDevcontainers: []codersdk.WorkspaceAgentDevcontainer{
{
ID: devcontainerID1,
Name: "test-devcontainer-1",
WorkspaceFolder: workspaceFolder1,
ConfigPath: configPath1,
Status: codersdk.WorkspaceAgentDevcontainerStatusRunning,
Container: &devContainer1,
},
},
lister: &fakeContainerCLI{
containers: codersdk.WorkspaceAgentListContainersResponse{
Containers: []codersdk.WorkspaceAgentContainer{devContainer1},
},
arch: "<none>",
},
devcontainerCLI: &fakeDevcontainerCLI{},
wantStatus: http.StatusNoContent,
wantBody: "",
},
{
name: "OK without container",
devcontainerID: devcontainerID1.String(),
setupDevcontainers: []codersdk.WorkspaceAgentDevcontainer{
{
ID: devcontainerID1,
Name: "test-devcontainer-1",
WorkspaceFolder: workspaceFolder1,
ConfigPath: configPath1,
Status: codersdk.WorkspaceAgentDevcontainerStatusStopped,
Container: nil,
},
},
lister: &fakeContainerCLI{
arch: "<none>",
},
devcontainerCLI: &fakeDevcontainerCLI{},
wantStatus: http.StatusNoContent,
wantBody: "",
},
{
name: "OK with container and subagent",
devcontainerID: devcontainerID1.String(),
setupDevcontainers: []codersdk.WorkspaceAgentDevcontainer{
{
ID: devcontainerID1,
Name: "test-devcontainer-1",
WorkspaceFolder: workspaceFolder1,
ConfigPath: configPath1,
Status: codersdk.WorkspaceAgentDevcontainerStatusStopped,
Container: &devContainer1,
},
},
lister: &fakeContainerCLI{
containers: codersdk.WorkspaceAgentListContainersResponse{
Containers: []codersdk.WorkspaceAgentContainer{devContainer1},
},
arch: "amd64",
},
devcontainerCLI: &fakeDevcontainerCLI{
readConfig: agentcontainers.DevcontainerConfig{
Workspace: agentcontainers.DevcontainerWorkspace{
WorkspaceFolder: workspaceFolder1,
},
},
},
wantStatus: http.StatusNoContent,
wantBody: "",
wantSubAgentDeleted: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
var (
ctx = testutil.Context(t, testutil.WaitShort)
logger = slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
mClock = quartz.NewMock(t)
withSubAgent = tt.wantSubAgentDeleted
)
mClock.Set(time.Now()).MustWait(ctx)
tickerTrap := mClock.Trap().TickerFunc("updaterLoop")
var (
fakeSAC *fakeSubAgentClient
mCCLI *acmock.MockContainerCLI
containerCLI agentcontainers.ContainerCLI
)
if withSubAgent {
var cleanupSAC func()
fakeSAC, cleanupSAC = newFakeSubAgentClient(t, logger.Named("fakeSubAgentClient"))
defer cleanupSAC()
mCCLI = acmock.NewMockContainerCLI(gomock.NewController(t))
containerCLI = mCCLI
coderBin, err := os.Executable()
require.NoError(t, err)
coderBin, err = filepath.EvalSymlinks(coderBin)
require.NoError(t, err)
mCCLI.EXPECT().List(gomock.Any()).Return(codersdk.WorkspaceAgentListContainersResponse{
Containers: tt.lister.containers.Containers,
}, nil).AnyTimes()
expectSubAgentInjection(mCCLI, devContainer1.ID, runtime.GOARCH, coderBin)
mCCLI.EXPECT().Stop(gomock.Any(), devContainer1.ID).Return(nil).Times(1)
mCCLI.EXPECT().Remove(gomock.Any(), devContainer1.ID).Return(nil).Times(1)
} else {
containerCLI = tt.lister
}
apiOpts := []agentcontainers.Option{
agentcontainers.WithClock(mClock),
agentcontainers.WithContainerCLI(containerCLI),
agentcontainers.WithDevcontainerCLI(tt.devcontainerCLI),
agentcontainers.WithWatcher(watcher.NewNoop()),
agentcontainers.WithDevcontainers(tt.setupDevcontainers, nil),
}
if withSubAgent {
apiOpts = append(apiOpts,
agentcontainers.WithSubAgentClient(fakeSAC),
agentcontainers.WithSubAgentURL("test-subagent-url"),
)
}
var (
agentRunningCh chan struct{}
stopAgentCh chan struct{}
)
if withSubAgent {
agentRunningCh = make(chan struct{})
stopAgentCh = make(chan struct{})
defer close(stopAgentCh)
allowSubAgentCreate(ctx, t, fakeSAC)
if tt.devcontainerCLI != nil {
requireDevcontainerExec(ctx, t, tt.devcontainerCLI, agentRunningCh, stopAgentCh)
}
}
api := agentcontainers.NewAPI(logger, apiOpts...)
api.Start()
defer api.Close()
r := chi.NewRouter()
r.Mount("/", api.Routes())
tickerTrap.MustWait(ctx).MustRelease(ctx)
tickerTrap.Close()
if tt.wantSubAgentDeleted {
err := api.RefreshContainers(ctx)
require.NoError(t, err, "refresh containers should not fail")
select {
case <-agentRunningCh:
case <-ctx.Done():
t.Fatal("timeout waiting for agent to start")
}
require.Len(t, fakeSAC.created, 1, "subagent should be created")
require.Empty(t, fakeSAC.deleted, "no subagent should be deleted yet")
allowSubAgentDelete(ctx, t, fakeSAC)
}
req := httptest.NewRequest(http.MethodDelete, "/devcontainers/"+tt.devcontainerID+"/", nil).
WithContext(ctx)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
require.Equal(t, tt.wantStatus, rec.Code, "status code mismatch")
if tt.wantBody != "" {
assert.Contains(t, rec.Body.String(), tt.wantBody, "response body mismatch")
}
// For successful deletes, verify the devcontainer is removed from the list.
if tt.wantStatus == http.StatusNoContent {
req = httptest.NewRequest(http.MethodGet, "/", nil).
WithContext(ctx)
rec = httptest.NewRecorder()
r.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code, "status code mismatch on list")
var resp codersdk.WorkspaceAgentListContainersResponse
err := json.NewDecoder(rec.Body).Decode(&resp)
require.NoError(t, err, "unmarshal response failed")
assert.Empty(t, resp.Devcontainers, "devcontainer should be removed after delete")
if tt.wantSubAgentDeleted {
require.Len(t, fakeSAC.deleted, 1, "subagent should be deleted")
assert.Equal(t, fakeSAC.created[0].ID, fakeSAC.deleted[0], "correct subagent should be deleted")
}
}
})
}
})
t.Run("List devcontainers", func(t *testing.T) {
t.Parallel()
knownDevcontainerID1 := uuid.New()
knownDevcontainerID2 := uuid.New()
knownDevcontainers := []codersdk.WorkspaceAgentDevcontainer{
{
ID: knownDevcontainerID1,
Name: "known-devcontainer-1",
WorkspaceFolder: "/workspace/known1",
ConfigPath: "/workspace/known1/.devcontainer/devcontainer.json",
},
{
ID: knownDevcontainerID2,
Name: "known-devcontainer-2",
WorkspaceFolder: "/workspace/known2",
// No config path intentionally.
},
}
tests := []struct {
name string
lister *fakeContainerCLI
knownDevcontainers []codersdk.WorkspaceAgentDevcontainer
wantStatus int
wantCount int
wantTestContainer bool
verify func(t *testing.T, devcontainers []codersdk.WorkspaceAgentDevcontainer)
}{
{
name: "List error",
lister: &fakeContainerCLI{
listErr: xerrors.New("list error"),
},
wantStatus: http.StatusInternalServerError,
},
{
name: "Empty containers",
lister: &fakeContainerCLI{},
wantStatus: http.StatusOK,
wantCount: 0,
},
{
name: "Only known devcontainers, no containers",
lister: &fakeContainerCLI{
containers: codersdk.WorkspaceAgentListContainersResponse{
Containers: []codersdk.WorkspaceAgentContainer{},
},
},
knownDevcontainers: knownDevcontainers,
wantStatus: http.StatusOK,
wantCount: 2,
verify: func(t *testing.T, devcontainers []codersdk.WorkspaceAgentDevcontainer) {
for _, dc := range devcontainers {
assert.Equal(t, codersdk.WorkspaceAgentDevcontainerStatusStopped, dc.Status, "devcontainer should be stopped")
assert.Nil(t, dc.Container, "devcontainer should not have container reference")
}
},
},
{
name: "Runtime-detected devcontainer",
lister: &fakeContainerCLI{
containers: codersdk.WorkspaceAgentListContainersResponse{
Containers: []codersdk.WorkspaceAgentContainer{
{
ID: "runtime-container-1",
FriendlyName: "runtime-container-1",
Running: true,
Labels: map[string]string{
agentcontainers.DevcontainerLocalFolderLabel: "/workspace/runtime1",
agentcontainers.DevcontainerConfigFileLabel: "/workspace/runtime1/.devcontainer/devcontainer.json",
},
},
{
ID: "not-a-devcontainer",
FriendlyName: "not-a-devcontainer",
Running: true,
Labels: map[string]string{},
},
},
},
},
wantStatus: http.StatusOK,
wantCount: 1,
verify: func(t *testing.T, devcontainers []codersdk.WorkspaceAgentDevcontainer) {
dc := devcontainers[0]
assert.Equal(t, "/workspace/runtime1", dc.WorkspaceFolder)
assert.Equal(t, codersdk.WorkspaceAgentDevcontainerStatusRunning, dc.Status)
require.NotNil(t, dc.Container)
assert.Equal(t, "runtime-container-1", dc.Container.ID)
},
},
{
name: "Mixed known and runtime-detected devcontainers",
lister: &fakeContainerCLI{
containers: codersdk.WorkspaceAgentListContainersResponse{
Containers: []codersdk.WorkspaceAgentContainer{
{
ID: "known-container-1",
FriendlyName: "known-container-1",
Running: true,
Labels: map[string]string{
agentcontainers.DevcontainerLocalFolderLabel: "/workspace/known1",
agentcontainers.DevcontainerConfigFileLabel: "/workspace/known1/.devcontainer/devcontainer.json",
},
},
{
ID: "runtime-container-1",
FriendlyName: "runtime-container-1",
Running: true,
Labels: map[string]string{
agentcontainers.DevcontainerLocalFolderLabel: "/workspace/runtime1",
agentcontainers.DevcontainerConfigFileLabel: "/workspace/runtime1/.devcontainer/devcontainer.json",
},
},
},
},
},
knownDevcontainers: knownDevcontainers,
wantStatus: http.StatusOK,
wantCount: 3, // 2 known + 1 runtime
verify: func(t *testing.T, devcontainers []codersdk.WorkspaceAgentDevcontainer) {
known1 := mustFindDevcontainerByPath(t, devcontainers, "/workspace/known1")
known2 := mustFindDevcontainerByPath(t, devcontainers, "/workspace/known2")
runtime1 := mustFindDevcontainerByPath(t, devcontainers, "/workspace/runtime1")
assert.Equal(t, codersdk.WorkspaceAgentDevcontainerStatusRunning, known1.Status)
assert.Equal(t, codersdk.WorkspaceAgentDevcontainerStatusStopped, known2.Status)
assert.Equal(t, codersdk.WorkspaceAgentDevcontainerStatusRunning, runtime1.Status)
assert.Nil(t, known2.Container)
require.NotNil(t, known1.Container)
assert.Equal(t, "known-container-1", known1.Container.ID)
require.NotNil(t, runtime1.Container)
assert.Equal(t, "runtime-container-1", runtime1.Container.ID)
},
},
{
name: "Both running and non-running containers have container references",
lister: &fakeContainerCLI{
containers: codersdk.WorkspaceAgentListContainersResponse{
Containers: []codersdk.WorkspaceAgentContainer{
{
ID: "running-container",
FriendlyName: "running-container",
Running: true,
Labels: map[string]string{
agentcontainers.DevcontainerLocalFolderLabel: "/workspace/running",
agentcontainers.DevcontainerConfigFileLabel: "/workspace/running/.devcontainer/devcontainer.json",
},
},
{
ID: "non-running-container",
FriendlyName: "non-running-container",
Running: false,
Labels: map[string]string{
agentcontainers.DevcontainerLocalFolderLabel: "/workspace/non-running",
agentcontainers.DevcontainerConfigFileLabel: "/workspace/non-running/.devcontainer/devcontainer.json",
},
},
},
},
},
wantStatus: http.StatusOK,
wantCount: 2,
verify: func(t *testing.T, devcontainers []codersdk.WorkspaceAgentDevcontainer) {
running := mustFindDevcontainerByPath(t, devcontainers, "/workspace/running")
nonRunning := mustFindDevcontainerByPath(t, devcontainers, "/workspace/non-running")
assert.Equal(t, codersdk.WorkspaceAgentDevcontainerStatusRunning, running.Status)
assert.Equal(t, codersdk.WorkspaceAgentDevcontainerStatusStopped, nonRunning.Status)
require.NotNil(t, running.Container, "running container should have container reference")
assert.Equal(t, "running-container", running.Container.ID)
require.NotNil(t, nonRunning.Container, "non-running container should have container reference")
assert.Equal(t, "non-running-container", nonRunning.Container.ID)
},
},
{
name: "Config path update",
lister: &fakeContainerCLI{
containers: codersdk.WorkspaceAgentListContainersResponse{
Containers: []codersdk.WorkspaceAgentContainer{
{
ID: "known-container-2",
FriendlyName: "known-container-2",
Running: true,
Labels: map[string]string{
agentcontainers.DevcontainerLocalFolderLabel: "/workspace/known2",
agentcontainers.DevcontainerConfigFileLabel: "/workspace/known2/.devcontainer/devcontainer.json",
},
},
},
},
},
knownDevcontainers: knownDevcontainers,
wantStatus: http.StatusOK,
wantCount: 2,
verify: func(t *testing.T, devcontainers []codersdk.WorkspaceAgentDevcontainer) {
var dc2 *codersdk.WorkspaceAgentDevcontainer
for i := range devcontainers {
if devcontainers[i].ID == knownDevcontainerID2 {
dc2 = &devcontainers[i]
break
}
}
require.NotNil(t, dc2, "missing devcontainer with ID %s", knownDevcontainerID2)
assert.Equal(t, codersdk.WorkspaceAgentDevcontainerStatusRunning, dc2.Status)
assert.NotEmpty(t, dc2.ConfigPath)
require.NotNil(t, dc2.Container)
assert.Equal(t, "known-container-2", dc2.Container.ID)
},
},
{
name: "Name generation and uniqueness",
lister: &fakeContainerCLI{
containers: codersdk.WorkspaceAgentListContainersResponse{
Containers: []codersdk.WorkspaceAgentContainer{
{
ID: "project1-container",
FriendlyName: "project1-container",
Running: true,
Labels: map[string]string{
agentcontainers.DevcontainerLocalFolderLabel: "/workspace/project1",
agentcontainers.DevcontainerConfigFileLabel: "/workspace/project1/.devcontainer/devcontainer.json",
},
},
{
ID: "project2-container",
FriendlyName: "project2-container",
Running: true,
Labels: map[string]string{
agentcontainers.DevcontainerLocalFolderLabel: "/home/user/project2",
agentcontainers.DevcontainerConfigFileLabel: "/home/user/project2/.devcontainer/devcontainer.json",
},
},
{
ID: "project3-container",
FriendlyName: "project3-container",
Running: true,
Labels: map[string]string{
agentcontainers.DevcontainerLocalFolderLabel: "/var/lib/project3",
agentcontainers.DevcontainerConfigFileLabel: "/var/lib/project3/.devcontainer/devcontainer.json",
},
},
},
},
},
knownDevcontainers: []codersdk.WorkspaceAgentDevcontainer{
{
ID: uuid.New(),
Name: "project", // This will cause uniqueness conflicts.
WorkspaceFolder: "/usr/local/project",
ConfigPath: "/usr/local/project/.devcontainer/devcontainer.json",
},
},
wantStatus: http.StatusOK,
wantCount: 4, // 1 known + 3 runtime
verify: func(t *testing.T, devcontainers []codersdk.WorkspaceAgentDevcontainer) {
names := make(map[string]int)
for _, dc := range devcontainers {
names[dc.Name]++
assert.NotEmpty(t, dc.Name, "devcontainer name should not be empty")
}
for name, count := range names {
assert.Equal(t, 1, count, "name '%s' appears %d times, should be unique", name, count)
}
assert.Len(t, names, 4, "should have four unique devcontainer names")
},
},
{
name: "Include test containers",
lister: &fakeContainerCLI{},
wantStatus: http.StatusOK,
wantTestContainer: true,
wantCount: 1, // Will be appended.
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
mClock := quartz.NewMock(t)
mClock.Set(time.Now()).MustWait(testutil.Context(t, testutil.WaitShort))
tickerTrap := mClock.Trap().TickerFunc("updaterLoop")
// This container should be ignored unless explicitly included.
tt.lister.containers.Containers = append(tt.lister.containers.Containers, codersdk.WorkspaceAgentContainer{
ID: "test-container-1",
FriendlyName: "test-container-1",
Running: true,
Labels: map[string]string{
agentcontainers.DevcontainerLocalFolderLabel: "/workspace/test1",
agentcontainers.DevcontainerConfigFileLabel: "/workspace/test1/.devcontainer/devcontainer.json",
agentcontainers.DevcontainerIsTestRunLabel: "true",
},
})
// Setup router with the handler under test.
r := chi.NewRouter()
apiOptions := []agentcontainers.Option{
agentcontainers.WithClock(mClock),
agentcontainers.WithContainerCLI(tt.lister),
agentcontainers.WithDevcontainerCLI(&fakeDevcontainerCLI{}),
agentcontainers.WithWatcher(watcher.NewNoop()),
}
if tt.wantTestContainer {
apiOptions = append(apiOptions, agentcontainers.WithContainerLabelIncludeFilter(
agentcontainers.DevcontainerIsTestRunLabel, "true",
))
}
// Generate matching scripts for the known devcontainers
// (required to extract log source ID).
var scripts []codersdk.WorkspaceAgentScript
for i := range tt.knownDevcontainers {
scripts = append(scripts, codersdk.WorkspaceAgentScript{
ID: tt.knownDevcontainers[i].ID,
LogSourceID: uuid.New(),
})
}
if len(tt.knownDevcontainers) > 0 {
apiOptions = append(apiOptions, agentcontainers.WithDevcontainers(tt.knownDevcontainers, scripts))
}
api := agentcontainers.NewAPI(logger, apiOptions...)
api.Start()
defer api.Close()
r.Mount("/", api.Routes())
ctx := testutil.Context(t, testutil.WaitShort)
// Make sure the ticker function has been registered
// before advancing the clock.
tickerTrap.MustWait(ctx).MustRelease(ctx)
tickerTrap.Close()
for _, dc := range tt.knownDevcontainers {
err := api.CreateDevcontainer(dc.WorkspaceFolder, dc.ConfigPath)
require.NoError(t, err)
}
// Advance the clock to run the updater loop.
_, aw := mClock.AdvanceNext()
aw.MustWait(ctx)
req := httptest.NewRequest(http.MethodGet, "/", nil).
WithContext(ctx)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
// Check the response status code.
require.Equal(t, tt.wantStatus, rec.Code, "status code mismatch")
if tt.wantStatus != http.StatusOK {
return
}
var response codersdk.WorkspaceAgentListContainersResponse
err := json.NewDecoder(rec.Body).Decode(&response)
require.NoError(t, err, "unmarshal response failed")
// Verify the number of devcontainers in the response.
assert.Len(t, response.Devcontainers, tt.wantCount, "wrong number of devcontainers")
// Run custom verification if provided.
if tt.verify != nil && len(response.Devcontainers) > 0 {
tt.verify(t, response.Devcontainers)
}
})
}
})
t.Run("List devcontainers running then not running", func(t *testing.T) {
t.Parallel()
container := codersdk.WorkspaceAgentContainer{
ID: "container-id",
FriendlyName: "container-name",
Running: true,
CreatedAt: time.Now().Add(-1 * time.Minute),
Labels: map[string]string{
agentcontainers.DevcontainerLocalFolderLabel: "/home/coder/project",
agentcontainers.DevcontainerConfigFileLabel: "/home/coder/project/.devcontainer/devcontainer.json",
},
}
dc := codersdk.WorkspaceAgentDevcontainer{
ID: uuid.New(),
Name: "test-devcontainer",
WorkspaceFolder: "/home/coder/project",
ConfigPath: "/home/coder/project/.devcontainer/devcontainer.json",
Status: codersdk.WorkspaceAgentDevcontainerStatusRunning, // Corrected enum
}
ctx := testutil.Context(t, testutil.WaitShort)
logger := slogtest.Make(t, nil).Leveled(slog.LevelDebug)
fLister := &fakeContainerCLI{
containers: codersdk.WorkspaceAgentListContainersResponse{
Containers: []codersdk.WorkspaceAgentContainer{container},
},
}
fWatcher := newFakeWatcher(t)
mClock := quartz.NewMock(t)
mClock.Set(time.Now()).MustWait(ctx)
tickerTrap := mClock.Trap().TickerFunc("updaterLoop")
api := agentcontainers.NewAPI(logger,
agentcontainers.WithClock(mClock),
agentcontainers.WithContainerCLI(fLister),
agentcontainers.WithWatcher(fWatcher),
agentcontainers.WithDevcontainers(
[]codersdk.WorkspaceAgentDevcontainer{dc},
[]codersdk.WorkspaceAgentScript{{LogSourceID: uuid.New(), ID: dc.ID}},
),
)
api.Start()
defer api.Close()
// Make sure the ticker function has been registered
// before advancing any use of mClock.Advance.
tickerTrap.MustWait(ctx).MustRelease(ctx)
tickerTrap.Close()
// Make sure the start loop has been called.
fWatcher.waitNext(ctx)
// Simulate a file modification event to make the devcontainer dirty.
fWatcher.sendEventWaitNextCalled(ctx, fsnotify.Event{
Name: "/home/coder/project/.devcontainer/devcontainer.json",
Op: fsnotify.Write,
})
// Initially the devcontainer should be running and dirty.
req := httptest.NewRequest(http.MethodGet, "/", nil).
WithContext(ctx)
rec := httptest.NewRecorder()
api.Routes().ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
var resp1 codersdk.WorkspaceAgentListContainersResponse
err := json.NewDecoder(rec.Body).Decode(&resp1)
require.NoError(t, err)
require.Len(t, resp1.Devcontainers, 1)
require.Equal(t, codersdk.WorkspaceAgentDevcontainerStatusRunning, resp1.Devcontainers[0].Status, "devcontainer should be running initially")
require.True(t, resp1.Devcontainers[0].Dirty, "devcontainer should be dirty initially")
require.NotNil(t, resp1.Devcontainers[0].Container, "devcontainer should have a container initially")
// Next, simulate a situation where the container is no longer
// running.
fLister.containers.Containers = []codersdk.WorkspaceAgentContainer{}
// Trigger a refresh which will use the second response from mock
// lister (no containers).
_, aw := mClock.AdvanceNext()
aw.MustWait(ctx)
// Afterwards the devcontainer should not be running and not dirty.
req = httptest.NewRequest(http.MethodGet, "/", nil).
WithContext(ctx)
rec = httptest.NewRecorder()
api.Routes().ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
var resp2 codersdk.WorkspaceAgentListContainersResponse
err = json.NewDecoder(rec.Body).Decode(&resp2)
require.NoError(t, err)
require.Len(t, resp2.Devcontainers, 1)
require.Equal(t, codersdk.WorkspaceAgentDevcontainerStatusStopped, resp2.Devcontainers[0].Status, "devcontainer should not be running after empty list")
require.False(t, resp2.Devcontainers[0].Dirty, "devcontainer should not be dirty after empty list")
require.Nil(t, resp2.Devcontainers[0].Container, "devcontainer should not have a container after empty list")
})
t.Run("FileWatcher", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
startTime := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC)
// Create a fake container with a config file.
configPath := "/workspace/project/.devcontainer/devcontainer.json"
container := codersdk.WorkspaceAgentContainer{
ID: "container-id",
FriendlyName: "container-name",
Running: true,
CreatedAt: startTime.Add(-1 * time.Hour), // Created 1 hour before test start.
Labels: map[string]string{
agentcontainers.DevcontainerLocalFolderLabel: "/workspace/project",
agentcontainers.DevcontainerConfigFileLabel: configPath,
},
}
mClock := quartz.NewMock(t)
mClock.Set(startTime)
tickerTrap := mClock.Trap().TickerFunc("updaterLoop")
fWatcher := newFakeWatcher(t)
fLister := &fakeContainerCLI{
containers: codersdk.WorkspaceAgentListContainersResponse{
Containers: []codersdk.WorkspaceAgentContainer{container},
},
}
fDCCLI := &fakeDevcontainerCLI{}
logger := slogtest.Make(t, nil).Leveled(slog.LevelDebug)
api := agentcontainers.NewAPI(
logger,
agentcontainers.WithDevcontainerCLI(fDCCLI),
agentcontainers.WithContainerCLI(fLister),
agentcontainers.WithWatcher(fWatcher),
agentcontainers.WithClock(mClock),
)
api.Start()
defer api.Close()
r := chi.NewRouter()
r.Mount("/", api.Routes())
// Make sure the ticker function has been registered
// before advancing any use of mClock.Advance.
tickerTrap.MustWait(ctx).MustRelease(ctx)
tickerTrap.Close()
// Call the list endpoint first to ensure config files are
// detected and watched.
req := httptest.NewRequest(http.MethodGet, "/", nil).
WithContext(ctx)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
var response codersdk.WorkspaceAgentListContainersResponse
err := json.NewDecoder(rec.Body).Decode(&response)
require.NoError(t, err)
require.Len(t, response.Devcontainers, 1)
assert.False(t, response.Devcontainers[0].Dirty,
"devcontainer should not be marked as dirty initially")
assert.Equal(t, codersdk.WorkspaceAgentDevcontainerStatusRunning, response.Devcontainers[0].Status, "devcontainer should be running initially")
require.NotNil(t, response.Devcontainers[0].Container, "container should not be nil")
// Verify the watcher is watching the config file.
assert.Contains(t, fWatcher.addedPaths, configPath,
"watcher should be watching the container's config file")
// Make sure the start loop has been called.
fWatcher.waitNext(ctx)
// Send a file modification event and check if the container is
// marked dirty.
fWatcher.sendEventWaitNextCalled(ctx, fsnotify.Event{
Name: configPath,
Op: fsnotify.Write,
})
// Advance the clock to run updaterLoop.
_, aw := mClock.AdvanceNext()
aw.MustWait(ctx)
// Check if the container is marked as dirty.
req = httptest.NewRequest(http.MethodGet, "/", nil).
WithContext(ctx)
rec = httptest.NewRecorder()
r.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
err = json.NewDecoder(rec.Body).Decode(&response)
require.NoError(t, err)
require.Len(t, response.Devcontainers, 1)
assert.True(t, response.Devcontainers[0].Dirty,
"container should be marked as dirty after config file was modified")
assert.Equal(t, codersdk.WorkspaceAgentDevcontainerStatusRunning, response.Devcontainers[0].Status, "devcontainer should be running after config file was modified")
require.NotNil(t, response.Devcontainers[0].Container, "container should not be nil")
container.ID = "new-container-id" // Simulate a new container ID after recreation.
container.FriendlyName = "new-container-name"
container.CreatedAt = mClock.Now() // Update the creation time.
fLister.containers.Containers = []codersdk.WorkspaceAgentContainer{container}
// Advance the clock to run updaterLoop.
_, aw = mClock.AdvanceNext()
aw.MustWait(ctx)
// Check if dirty flag is cleared.
req = httptest.NewRequest(http.MethodGet, "/", nil).
WithContext(ctx)
rec = httptest.NewRecorder()
r.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
err = json.NewDecoder(rec.Body).Decode(&response)
require.NoError(t, err)
require.Len(t, response.Devcontainers, 1)
assert.False(t, response.Devcontainers[0].Dirty,
"dirty flag should be cleared on the devcontainer after container recreation")
assert.Equal(t, codersdk.WorkspaceAgentDevcontainerStatusRunning, response.Devcontainers[0].Status, "devcontainer should be running after recreation")
require.NotNil(t, response.Devcontainers[0].Container, "container should not be nil")
})
// Verify that modifying a config file broadcasts the dirty status
// over websocket immediately.
t.Run("FileWatcherDirtyBroadcast", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
configPath := "/workspace/project/.devcontainer/devcontainer.json"
fWatcher := newFakeWatcher(t)
fLister := &fakeContainerCLI{
containers: codersdk.WorkspaceAgentListContainersResponse{
Containers: []codersdk.WorkspaceAgentContainer{
{
ID: "container-id",
FriendlyName: "container-name",
Running: true,
Labels: map[string]string{
agentcontainers.DevcontainerLocalFolderLabel: "/workspace/project",
agentcontainers.DevcontainerConfigFileLabel: configPath,
},
},
},
},
}
mClock := quartz.NewMock(t)
tickerTrap := mClock.Trap().TickerFunc("updaterLoop")
api := agentcontainers.NewAPI(
slogtest.Make(t, nil).Leveled(slog.LevelDebug),
agentcontainers.WithContainerCLI(fLister),
agentcontainers.WithWatcher(fWatcher),
agentcontainers.WithClock(mClock),
)
api.Start()
defer api.Close()
srv := httptest.NewServer(api.Routes())
defer srv.Close()
tickerTrap.MustWait(ctx).MustRelease(ctx)
tickerTrap.Close()
wsConn, resp, err := websocket.Dial(ctx, "ws"+strings.TrimPrefix(srv.URL, "http")+"/watch", nil)
require.NoError(t, err)
if resp != nil && resp.Body != nil {
defer resp.Body.Close()
}
defer wsConn.Close(websocket.StatusNormalClosure, "")
// Read and discard initial state.
_, _, err = wsConn.Read(ctx)
require.NoError(t, err)
fWatcher.waitNext(ctx)
fWatcher.sendEventWaitNextCalled(ctx, fsnotify.Event{
Name: configPath,
Op: fsnotify.Write,
})
// Verify dirty status is broadcast without advancing the clock.
_, msg, err := wsConn.Read(ctx)
require.NoError(t, err)
var response codersdk.WorkspaceAgentListContainersResponse
err = json.Unmarshal(msg, &response)
require.NoError(t, err)
require.Len(t, response.Devcontainers, 1)
assert.True(t, response.Devcontainers[0].Dirty,
"devcontainer should be marked as dirty after config file modification")
})
t.Run("SubAgentLifecycle", func(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" {
t.Skip("Dev Container tests are not supported on Windows (this test uses mocks but fails due to Windows paths)")
}
var (
ctx = testutil.Context(t, testutil.WaitMedium)
errTestTermination = xerrors.New("test termination")
logger = slogtest.Make(t, &slogtest.Options{IgnoredErrorIs: []error{errTestTermination}}).Leveled(slog.LevelDebug)
mClock = quartz.NewMock(t)
mCCLI = acmock.NewMockContainerCLI(gomock.NewController(t))
fakeSAC, cleanupSAC = newFakeSubAgentClient(t, logger.Named("fakeSubAgentClient"))
fakeDCCLI, cleanupDCCLI = newFakeDevcontainerCLI(t, agentcontainers.DevcontainerConfig{
Workspace: agentcontainers.DevcontainerWorkspace{
WorkspaceFolder: "/workspaces/coder",
},
})
testContainer = codersdk.WorkspaceAgentContainer{
ID: "test-container-id",
FriendlyName: "test-container",
Image: "test-image",
Running: true,
CreatedAt: time.Now(),
Labels: map[string]string{
agentcontainers.DevcontainerLocalFolderLabel: "/home/coder/coder",
agentcontainers.DevcontainerConfigFileLabel: "/home/coder/coder/.devcontainer/devcontainer.json",
},
}
)
coderBin, err := os.Executable()
require.NoError(t, err)
coderBin, err = filepath.EvalSymlinks(coderBin)
require.NoError(t, err)
mCCLI.EXPECT().List(gomock.Any()).Return(codersdk.WorkspaceAgentListContainersResponse{
Containers: []codersdk.WorkspaceAgentContainer{testContainer},
}, nil).Times(3) // 1 initial call + 2 updates.
expectSubAgentInjection(mCCLI, "test-container-id", runtime.GOARCH, coderBin)
mClock.Set(time.Now()).MustWait(ctx)
tickerTrap := mClock.Trap().TickerFunc("updaterLoop")
api := agentcontainers.NewAPI(logger,
agentcontainers.WithClock(mClock),
agentcontainers.WithContainerCLI(mCCLI),
agentcontainers.WithWatcher(watcher.NewNoop()),
agentcontainers.WithSubAgentClient(fakeSAC),
agentcontainers.WithSubAgentURL("test-subagent-url"),
agentcontainers.WithDevcontainerCLI(fakeDCCLI),
agentcontainers.WithManifestInfo("test-user", "test-workspace", "test-parent-agent", "/parent-agent"),
)
api.Start()
defer func() {
cleanupSAC()
cleanupDCCLI()
_ = api.Close()
}()
// Allow initial agent creation and injection to succeed.
allowSubAgentCreate(ctx, t, fakeSAC)
testutil.RequireSend(ctx, t, fakeDCCLI.readConfigErrC, func(envs []string) error {
assert.Contains(t, envs, "CODER_WORKSPACE_AGENT_NAME=coder")
assert.Contains(t, envs, "CODER_WORKSPACE_NAME=test-workspace")
assert.Contains(t, envs, "CODER_WORKSPACE_OWNER_NAME=test-user")
assert.Contains(t, envs, "CODER_WORKSPACE_PARENT_AGENT_NAME=test-parent-agent")
assert.Contains(t, envs, "CODER_URL=test-subagent-url")
assert.Contains(t, envs, "CONTAINER_ID=test-container-id")
return nil
})
// Make sure the ticker function has been registered
// before advancing the clock.
tickerTrap.MustWait(ctx).MustRelease(ctx)
tickerTrap.Close()
// Refresh twice to ensure idempotency of agent creation.
err = api.RefreshContainers(ctx)
require.NoError(t, err, "refresh containers should not fail")
t.Logf("Agents created: %d, deleted: %d", len(fakeSAC.created), len(fakeSAC.deleted))
err = api.RefreshContainers(ctx)
require.NoError(t, err, "refresh containers should not fail")
t.Logf("Agents created: %d, deleted: %d", len(fakeSAC.created), len(fakeSAC.deleted))
// Verify agent was created.
require.Len(t, fakeSAC.created, 1)
assert.Equal(t, "coder", fakeSAC.created[0].Name)
assert.Equal(t, "/workspaces/coder", fakeSAC.created[0].Directory)
assert.Len(t, fakeSAC.deleted, 0)
t.Log("Agent injected successfully, now testing reinjection into the same container...")
// Terminate the agent and verify it can be reinjected.
terminated := make(chan struct{})
testutil.RequireSend(ctx, t, fakeDCCLI.execErrC, func(_ string, args ...string) error {
defer close(terminated)
if len(args) > 0 {
assert.Equal(t, "agent", args[0])
} else {
assert.Fail(t, `want "agent" command argument`)
}
return errTestTermination
})
select {
case <-ctx.Done():
t.Fatal("timeout waiting for agent termination")
case <-terminated:
}
t.Log("Waiting for agent reinjection...")
// Expect the agent to be reinjected.
expectSubAgentInjection(mCCLI, "test-container-id", runtime.GOARCH, coderBin)
// Verify that the agent has started.
agentStarted := make(chan struct{})
continueTerminate := make(chan struct{})
terminated = make(chan struct{})
testutil.RequireSend(ctx, t, fakeDCCLI.execErrC, func(_ string, args ...string) error {
defer close(terminated)
if len(args) > 0 {
assert.Equal(t, "agent", args[0])
} else {
assert.Fail(t, `want "agent" command argument`)
}
close(agentStarted)
select {
case <-ctx.Done():
t.Error("timeout waiting for agent continueTerminate")
case <-continueTerminate:
}
return errTestTermination
})
WaitStartLoop:
for {
// Agent reinjection will succeed and we will not re-create the
// agent.
mCCLI.EXPECT().List(gomock.Any()).Return(codersdk.WorkspaceAgentListContainersResponse{
Containers: []codersdk.WorkspaceAgentContainer{testContainer},
}, nil).Times(1) // 1 update.
err = api.RefreshContainers(ctx)
require.NoError(t, err, "refresh containers should not fail")
t.Logf("Agents created: %d, deleted: %d", len(fakeSAC.created), len(fakeSAC.deleted))
select {
case <-agentStarted:
break WaitStartLoop
case <-ctx.Done():
t.Fatal("timeout waiting for agent to start")
default:
}
}
// Verify that the agent was reused.
require.Len(t, fakeSAC.created, 1)
assert.Len(t, fakeSAC.deleted, 0)
t.Log("Agent reinjected successfully, now testing agent deletion and recreation...")
// New container ID means the agent will be recreated.
testContainer.ID = "new-test-container-id" // Simulate a new container ID after recreation.
// Expect the agent to be injected.
mCCLI.EXPECT().List(gomock.Any()).Return(codersdk.WorkspaceAgentListContainersResponse{
Containers: []codersdk.WorkspaceAgentContainer{testContainer},
}, nil).Times(1) // 1 update.
gomock.InOrder(
mCCLI.EXPECT().DetectArchitecture(gomock.Any(), "new-test-container-id").Return(runtime.GOARCH, nil),
mCCLI.EXPECT().ExecAs(gomock.Any(), "new-test-container-id", "root", "mkdir", "-p", "/.coder-agent").Return(nil, nil),
mCCLI.EXPECT().Copy(gomock.Any(), "new-test-container-id", coderBin, "/.coder-agent/coder").Return(nil),
mCCLI.EXPECT().ExecAs(gomock.Any(), "new-test-container-id", "root", "chmod", "0755", "/.coder-agent", "/.coder-agent/coder").Return(nil, nil),
mCCLI.EXPECT().ExecAs(gomock.Any(), "new-test-container-id", "root", "/bin/sh", "-c", "chown $(id -u):$(id -g) /.coder-agent/coder").Return(nil, nil),
)
fakeDCCLI.readConfig.MergedConfiguration.Customizations.Coder = []agentcontainers.CoderCustomization{
{
DisplayApps: map[codersdk.DisplayApp]bool{
codersdk.DisplayAppSSH: true,
codersdk.DisplayAppWebTerminal: true,
codersdk.DisplayAppVSCodeDesktop: true,
codersdk.DisplayAppVSCodeInsiders: true,
codersdk.DisplayAppPortForward: true,
},
},
}
// Terminate the running agent.
close(continueTerminate)
select {
case <-ctx.Done():
t.Fatal("timeout waiting for agent termination")
case <-terminated:
}
// Simulate the agent deletion (this happens because the
// devcontainer configuration changed).
testutil.RequireSend(ctx, t, fakeSAC.deleteErrC, nil)
// Expect the agent to be recreated.
testutil.RequireSend(ctx, t, fakeSAC.createErrC, nil)
testutil.RequireSend(ctx, t, fakeDCCLI.readConfigErrC, func(envs []string) error {
assert.Contains(t, envs, "CODER_WORKSPACE_AGENT_NAME=coder")
assert.Contains(t, envs, "CODER_WORKSPACE_NAME=test-workspace")
assert.Contains(t, envs, "CODER_WORKSPACE_OWNER_NAME=test-user")
assert.Contains(t, envs, "CODER_WORKSPACE_PARENT_AGENT_NAME=test-parent-agent")
assert.Contains(t, envs, "CODER_URL=test-subagent-url")
assert.NotContains(t, envs, "CONTAINER_ID=test-container-id")
return nil
})
err = api.RefreshContainers(ctx)
require.NoError(t, err, "refresh containers should not fail")
t.Logf("Agents created: %d, deleted: %d", len(fakeSAC.created), len(fakeSAC.deleted))
// Verify the agent was deleted and recreated.
require.Len(t, fakeSAC.deleted, 1, "there should be one deleted agent after recreation")
assert.Len(t, fakeSAC.created, 2, "there should be two created agents after recreation")
assert.Equal(t, fakeSAC.created[0].ID, fakeSAC.deleted[0], "the deleted agent should match the first created agent")
t.Log("Agent deleted and recreated successfully.")
// Allow API shutdown to delete the currently active agent record.
allowSubAgentDelete(ctx, t, fakeSAC)
err = api.Close()
require.NoError(t, err)
require.Len(t, fakeSAC.created, 2, "API close should not create more agents")
require.Len(t, fakeSAC.deleted, 2, "API close should delete the agent")
assert.Equal(t, fakeSAC.created[1].ID, fakeSAC.deleted[1], "the second created agent should be deleted on API close")
})
t.Run("SubAgentCleanup", func(t *testing.T) {
t.Parallel()
var (
existingAgentID = uuid.New()
existingAgentToken = uuid.New()
existingAgent = agentcontainers.SubAgent{
ID: existingAgentID,
Name: "stopped-container",
Directory: "/tmp",
AuthToken: existingAgentToken,
}
ctx = testutil.Context(t, testutil.WaitMedium)
logger = slog.Make()
mClock = quartz.NewMock(t)
mCCLI = acmock.NewMockContainerCLI(gomock.NewController(t))
fakeSAC = &fakeSubAgentClient{
logger: logger.Named("fakeSubAgentClient"),
agents: map[uuid.UUID]agentcontainers.SubAgent{
existingAgentID: existingAgent,
},
}
)
mCCLI.EXPECT().List(gomock.Any()).Return(codersdk.WorkspaceAgentListContainersResponse{
Containers: []codersdk.WorkspaceAgentContainer{},
}, nil).AnyTimes()
mClock.Set(time.Now()).MustWait(ctx)
tickerTrap := mClock.Trap().TickerFunc("updaterLoop")
api := agentcontainers.NewAPI(logger,
agentcontainers.WithClock(mClock),
agentcontainers.WithContainerCLI(mCCLI),
agentcontainers.WithSubAgentClient(fakeSAC),
agentcontainers.WithDevcontainerCLI(&fakeDevcontainerCLI{}),
)
api.Start()
defer api.Close()
tickerTrap.MustWait(ctx).MustRelease(ctx)
tickerTrap.Close()
_, aw := mClock.AdvanceNext()
aw.MustWait(ctx)
// Verify agent was deleted.
assert.Contains(t, fakeSAC.deleted, existingAgentID)
assert.Empty(t, fakeSAC.agents)
})
t.Run("SubAgentCleanupPreservesTerraformDefined", func(t *testing.T) {
t.Parallel()
var (
// Given: A terraform-defined agent and devcontainer that should be preserved
terraformAgentID = uuid.New()
terraformAgentToken = uuid.New()
terraformAgent = agentcontainers.SubAgent{
ID: terraformAgentID,
Name: "terraform-defined-agent",
Directory: "/workspace",
AuthToken: terraformAgentToken,
}
terraformDevcontainer = codersdk.WorkspaceAgentDevcontainer{
ID: uuid.New(),
Name: "terraform-devcontainer",
WorkspaceFolder: "/workspace/project",
SubagentID: uuid.NullUUID{UUID: terraformAgentID, Valid: true},
}
// Given: An orphaned agent that should be cleaned up
orphanedAgentID = uuid.New()
orphanedAgentToken = uuid.New()
orphanedAgent = agentcontainers.SubAgent{
ID: orphanedAgentID,
Name: "orphaned-agent",
Directory: "/tmp",
AuthToken: orphanedAgentToken,
}
ctx = testutil.Context(t, testutil.WaitMedium)
logger = slog.Make()
mClock = quartz.NewMock(t)
mCCLI = acmock.NewMockContainerCLI(gomock.NewController(t))
fakeSAC = &fakeSubAgentClient{
logger: logger.Named("fakeSubAgentClient"),
agents: map[uuid.UUID]agentcontainers.SubAgent{
terraformAgentID: terraformAgent,
orphanedAgentID: orphanedAgent,
},
}
)
mCCLI.EXPECT().List(gomock.Any()).Return(codersdk.WorkspaceAgentListContainersResponse{
Containers: []codersdk.WorkspaceAgentContainer{},
}, nil).AnyTimes()
mClock.Set(time.Now()).MustWait(ctx)
tickerTrap := mClock.Trap().TickerFunc("updaterLoop")
api := agentcontainers.NewAPI(logger,
agentcontainers.WithClock(mClock),
agentcontainers.WithContainerCLI(mCCLI),
agentcontainers.WithSubAgentClient(fakeSAC),
agentcontainers.WithDevcontainerCLI(&fakeDevcontainerCLI{}),
agentcontainers.WithDevcontainers([]codersdk.WorkspaceAgentDevcontainer{terraformDevcontainer}, nil),
)
api.Start()
defer api.Close()
tickerTrap.MustWait(ctx).MustRelease(ctx)
tickerTrap.Close()
// When: We advance the clock, allowing cleanup to occur
_, aw := mClock.AdvanceNext()
aw.MustWait(ctx)
// Then: The orphaned agent should be deleted
assert.Contains(t, fakeSAC.deleted, orphanedAgentID, "orphaned agent should be deleted")
// And: The terraform-defined agent should not be deleted
assert.NotContains(t, fakeSAC.deleted, terraformAgentID, "terraform-defined agent should be preserved")
assert.Len(t, fakeSAC.agents, 1, "only terraform agent should remain")
assert.Contains(t, fakeSAC.agents, terraformAgentID, "terraform agent should still exist")
})
t.Run("TerraformDefinedSubAgentNotRecreatedOnConfigChange", func(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" {
t.Skip("Dev Container tests are not supported on Windows (this test uses mocks but fails due to Windows paths)")
}
var (
logger = slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
mCtrl = gomock.NewController(t)
// Given: A terraform-defined devcontainer with a pre-assigned subagent ID.
terraformAgentID = uuid.New()
terraformContainer = codersdk.WorkspaceAgentContainer{
ID: "test-container-id",
FriendlyName: "test-container",
Image: "test-image",
Running: true,
CreatedAt: time.Now(),
Labels: map[string]string{
agentcontainers.DevcontainerLocalFolderLabel: "/workspace/project",
agentcontainers.DevcontainerConfigFileLabel: "/workspace/project/.devcontainer/devcontainer.json",
},
}
terraformDevcontainer = codersdk.WorkspaceAgentDevcontainer{
ID: uuid.New(),
Name: "terraform-devcontainer",
WorkspaceFolder: "/workspace/project",
ConfigPath: "/workspace/project/.devcontainer/devcontainer.json",
SubagentID: uuid.NullUUID{UUID: terraformAgentID, Valid: true},
}
fCCLI = &fakeContainerCLI{
containers: codersdk.WorkspaceAgentListContainersResponse{
Containers: []codersdk.WorkspaceAgentContainer{terraformContainer},
},
arch: runtime.GOARCH,
}
fDCCLI = &fakeDevcontainerCLI{
upID: terraformContainer.ID,
readConfig: agentcontainers.DevcontainerConfig{
MergedConfiguration: agentcontainers.DevcontainerMergedConfiguration{
Customizations: agentcontainers.DevcontainerMergedCustomizations{
Coder: []agentcontainers.CoderCustomization{{
Apps: []agentcontainers.SubAgentApp{{Slug: "app1"}},
}},
},
},
},
}
mSAC = acmock.NewMockSubAgentClient(mCtrl)
closed bool
)
mSAC.EXPECT().List(gomock.Any()).Return([]agentcontainers.SubAgent{}, nil).AnyTimes()
// EXPECT: Create is called twice with the terraform-defined ID:
// once for the initial creation and once after the rebuild with
// config changes (upsert).
mSAC.EXPECT().Create(gomock.Any(), gomock.Any()).DoAndReturn(
func(_ context.Context, agent agentcontainers.SubAgent) (agentcontainers.SubAgent, error) {
assert.Equal(t, terraformAgentID, agent.ID, "agent should have terraform-defined ID")
agent.AuthToken = uuid.New()
return agent, nil
},
).Times(2)
// EXPECT: Delete may be called during Close, but not before.
mSAC.EXPECT().Delete(gomock.Any(), gomock.Any()).DoAndReturn(func(_ context.Context, _ uuid.UUID) error {
assert.True(t, closed, "Delete should only be called after Close, not during recreation")
return nil
}).AnyTimes()
api := agentcontainers.NewAPI(logger,
agentcontainers.WithContainerCLI(fCCLI),
agentcontainers.WithDevcontainerCLI(fDCCLI),
agentcontainers.WithDevcontainers(
[]codersdk.WorkspaceAgentDevcontainer{terraformDevcontainer},
[]codersdk.WorkspaceAgentScript{{ID: terraformDevcontainer.ID, LogSourceID: uuid.New()}},
),
agentcontainers.WithSubAgentClient(mSAC),
agentcontainers.WithSubAgentURL("test-subagent-url"),
agentcontainers.WithWatcher(watcher.NewNoop()),
)
api.Start()
// Given: We create the devcontainer for the first time.
err := api.CreateDevcontainer(terraformDevcontainer.WorkspaceFolder, terraformDevcontainer.ConfigPath)
require.NoError(t, err)
// When: The container is recreated (new container ID) with config changes.
terraformContainer.ID = "new-container-id"
fCCLI.mu.Lock()
fCCLI.containers.Containers = []codersdk.WorkspaceAgentContainer{terraformContainer}
fCCLI.mu.Unlock()
fDCCLI.upID = terraformContainer.ID
fDCCLI.readConfig.MergedConfiguration.Customizations.Coder = []agentcontainers.CoderCustomization{{
Apps: []agentcontainers.SubAgentApp{{Slug: "app2"}}, // Changed app triggers recreation logic.
}}
err = api.CreateDevcontainer(terraformDevcontainer.WorkspaceFolder, terraformDevcontainer.ConfigPath, agentcontainers.WithRemoveExistingContainer())
require.NoError(t, err)
// Then: Mock expectations verify that Create was called once and Delete was not called during recreation.
closed = true
api.Close()
})
// Verify that rebuilding a terraform-defined devcontainer via the
// HTTP API does not delete the sub agent. The sub agent should be
// preserved (Create called again with the same terraform ID) and
// display app changes should be picked up.
t.Run("TerraformDefinedSubAgentRebuildViaHTTP", func(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" {
t.Skip("Dev Container tests are not supported on Windows (this test uses mocks but fails due to Windows paths)")
}
var (
ctx = testutil.Context(t, testutil.WaitMedium)
logger = slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
mCtrl = gomock.NewController(t)
terraformAgentID = uuid.New()
containerID = "test-container-id"
terraformContainer = codersdk.WorkspaceAgentContainer{
ID: containerID,
FriendlyName: "test-container",
Image: "test-image",
Running: true,
CreatedAt: time.Now(),
Labels: map[string]string{
agentcontainers.DevcontainerLocalFolderLabel: "/workspace/project",
agentcontainers.DevcontainerConfigFileLabel: "/workspace/project/.devcontainer/devcontainer.json",
},
}
terraformDevcontainer = codersdk.WorkspaceAgentDevcontainer{
ID: uuid.New(),
Name: "terraform-devcontainer",
WorkspaceFolder: "/workspace/project",
ConfigPath: "/workspace/project/.devcontainer/devcontainer.json",
SubagentID: uuid.NullUUID{UUID: terraformAgentID, Valid: true},
}
fCCLI = &fakeContainerCLI{
containers: codersdk.WorkspaceAgentListContainersResponse{
Containers: []codersdk.WorkspaceAgentContainer{terraformContainer},
},
arch: runtime.GOARCH,
}
fDCCLI = &fakeDevcontainerCLI{
upID: containerID,
readConfig: agentcontainers.DevcontainerConfig{
MergedConfiguration: agentcontainers.DevcontainerMergedConfiguration{
Customizations: agentcontainers.DevcontainerMergedCustomizations{
Coder: []agentcontainers.CoderCustomization{{
DisplayApps: map[codersdk.DisplayApp]bool{
codersdk.DisplayAppSSH: true,
codersdk.DisplayAppWebTerminal: true,
},
}},
},
},
},
}
mSAC = acmock.NewMockSubAgentClient(mCtrl)
closed bool
createCalled = make(chan agentcontainers.SubAgent, 2)
)
mSAC.EXPECT().List(gomock.Any()).Return([]agentcontainers.SubAgent{}, nil).AnyTimes()
// Create should be called twice: once for the initial injection
// and once after the rebuild picks up the new container.
mSAC.EXPECT().Create(gomock.Any(), gomock.Any()).DoAndReturn(
func(_ context.Context, agent agentcontainers.SubAgent) (agentcontainers.SubAgent, error) {
assert.Equal(t, terraformAgentID, agent.ID, "agent should always use terraform-defined ID")
agent.AuthToken = uuid.New()
createCalled <- agent
return agent, nil
},
).Times(2)
// Delete must only be called during Close, never during rebuild.
mSAC.EXPECT().Delete(gomock.Any(), gomock.Any()).DoAndReturn(func(_ context.Context, _ uuid.UUID) error {
assert.True(t, closed, "Delete should only be called after Close, not during rebuild")
return nil
}).AnyTimes()
api := agentcontainers.NewAPI(logger,
agentcontainers.WithContainerCLI(fCCLI),
agentcontainers.WithDevcontainerCLI(fDCCLI),
agentcontainers.WithDevcontainers(
[]codersdk.WorkspaceAgentDevcontainer{terraformDevcontainer},
[]codersdk.WorkspaceAgentScript{{ID: terraformDevcontainer.ID, LogSourceID: uuid.New()}},
),
agentcontainers.WithSubAgentClient(mSAC),
agentcontainers.WithSubAgentURL("test-subagent-url"),
agentcontainers.WithWatcher(watcher.NewNoop()),
)
api.Start()
defer func() {
closed = true
api.Close()
}()
r := chi.NewRouter()
r.Mount("/", api.Routes())
// Perform the initial devcontainer creation directly to set up
// the subagent (mirrors the TerraformDefinedSubAgentNotRecreatedOnConfigChange
// test pattern).
err := api.CreateDevcontainer(terraformDevcontainer.WorkspaceFolder, terraformDevcontainer.ConfigPath)
require.NoError(t, err)
initialAgent := testutil.RequireReceive(ctx, t, createCalled)
assert.Equal(t, terraformAgentID, initialAgent.ID)
// Simulate container rebuild: new container ID, changed display apps.
newContainerID := "new-container-id"
terraformContainer.ID = newContainerID
fCCLI.mu.Lock()
fCCLI.containers.Containers = []codersdk.WorkspaceAgentContainer{terraformContainer}
fCCLI.mu.Unlock()
fDCCLI.upID = newContainerID
fDCCLI.readConfig.MergedConfiguration.Customizations.Coder = []agentcontainers.CoderCustomization{{
DisplayApps: map[codersdk.DisplayApp]bool{
codersdk.DisplayAppSSH: true,
codersdk.DisplayAppWebTerminal: true,
codersdk.DisplayAppVSCodeDesktop: true,
codersdk.DisplayAppVSCodeInsiders: true,
},
}}
// Issue the rebuild request via the HTTP API.
req := httptest.NewRequest(http.MethodPost, "/devcontainers/"+terraformDevcontainer.ID.String()+"/recreate", nil).
WithContext(ctx)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
require.Equal(t, http.StatusAccepted, rec.Code)
// Wait for the post-rebuild injection to complete.
rebuiltAgent := testutil.RequireReceive(ctx, t, createCalled)
assert.Equal(t, terraformAgentID, rebuiltAgent.ID, "rebuilt agent should preserve terraform ID")
// Verify that the display apps were updated.
assert.Contains(t, rebuiltAgent.DisplayApps, codersdk.DisplayAppVSCodeDesktop,
"rebuilt agent should include updated display apps")
assert.Contains(t, rebuiltAgent.DisplayApps, codersdk.DisplayAppVSCodeInsiders,
"rebuilt agent should include updated display apps")
})
// Verify that when a terraform-managed subagent is injected into
// a devcontainer, the Directory field sent to Create reflects
// the container-internal workspaceFolder from devcontainer
// read-configuration, not the host-side workspace_folder from
// the terraform resource. This is the scenario described in
// https://linear.app/codercom/issue/PRODUCT-259:
// 1. Non-terraform subagent → directory = /workspaces/foo (correct)
// 2. Terraform subagent → directory was stuck on host path (bug)
t.Run("TerraformDefinedSubAgentUsesContainerInternalDirectory", func(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" {
t.Skip("Dev Container tests are not supported on Windows (this test uses mocks but fails due to Windows paths)")
}
var (
ctx = testutil.Context(t, testutil.WaitMedium)
logger = slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
mCtrl = gomock.NewController(t)
terraformAgentID = uuid.New()
containerID = "test-container-id"
// Given: A container with a host-side workspace folder.
terraformContainer = codersdk.WorkspaceAgentContainer{
ID: containerID,
FriendlyName: "test-container",
Image: "test-image",
Running: true,
CreatedAt: time.Now(),
Labels: map[string]string{
agentcontainers.DevcontainerLocalFolderLabel: "/home/coder/project",
agentcontainers.DevcontainerConfigFileLabel: "/home/coder/project/.devcontainer/devcontainer.json",
},
}
// Given: A terraform-defined devcontainer whose
// workspace_folder is the HOST-side path (set by provisioner).
terraformDevcontainer = codersdk.WorkspaceAgentDevcontainer{
ID: uuid.New(),
Name: "terraform-devcontainer",
WorkspaceFolder: "/home/coder/project",
ConfigPath: "/home/coder/project/.devcontainer/devcontainer.json",
SubagentID: uuid.NullUUID{UUID: terraformAgentID, Valid: true},
}
fCCLI = &fakeContainerCLI{
containers: codersdk.WorkspaceAgentListContainersResponse{
Containers: []codersdk.WorkspaceAgentContainer{terraformContainer},
},
arch: runtime.GOARCH,
}
// Given: devcontainer read-configuration returns the
// CONTAINER-INTERNAL workspace folder.
fDCCLI = &fakeDevcontainerCLI{
upID: containerID,
readConfig: agentcontainers.DevcontainerConfig{
Workspace: agentcontainers.DevcontainerWorkspace{
WorkspaceFolder: "/workspaces/project",
},
MergedConfiguration: agentcontainers.DevcontainerMergedConfiguration{
Customizations: agentcontainers.DevcontainerMergedCustomizations{
Coder: []agentcontainers.CoderCustomization{{}},
},
},
},
}
mSAC = acmock.NewMockSubAgentClient(mCtrl)
createCalls = make(chan agentcontainers.SubAgent, 1)
closed bool
)
mSAC.EXPECT().List(gomock.Any()).Return([]agentcontainers.SubAgent{}, nil).AnyTimes()
mSAC.EXPECT().Create(gomock.Any(), gomock.Any()).DoAndReturn(
func(_ context.Context, agent agentcontainers.SubAgent) (agentcontainers.SubAgent, error) {
agent.AuthToken = uuid.New()
createCalls <- agent
return agent, nil
},
).Times(1)
mSAC.EXPECT().Delete(gomock.Any(), gomock.Any()).DoAndReturn(func(_ context.Context, _ uuid.UUID) error {
assert.True(t, closed, "Delete should only be called after Close")
return nil
}).AnyTimes()
api := agentcontainers.NewAPI(logger,
agentcontainers.WithContainerCLI(fCCLI),
agentcontainers.WithDevcontainerCLI(fDCCLI),
agentcontainers.WithDevcontainers(
[]codersdk.WorkspaceAgentDevcontainer{terraformDevcontainer},
[]codersdk.WorkspaceAgentScript{{ID: terraformDevcontainer.ID, LogSourceID: uuid.New()}},
),
agentcontainers.WithSubAgentClient(mSAC),
agentcontainers.WithSubAgentURL("test-subagent-url"),
agentcontainers.WithWatcher(watcher.NewNoop()),
)
api.Start()
defer func() {
closed = true
api.Close()
}()
// When: The devcontainer is created (triggering injection).
err := api.CreateDevcontainer(terraformDevcontainer.WorkspaceFolder, terraformDevcontainer.ConfigPath)
require.NoError(t, err)
// Then: The subagent sent to Create has the correct
// container-internal directory, not the host path.
createdAgent := testutil.RequireReceive(ctx, t, createCalls)
assert.Equal(t, terraformAgentID, createdAgent.ID,
"agent should use terraform-defined ID")
assert.Equal(t, "/workspaces/project", createdAgent.Directory,
"directory should be the container-internal path from devcontainer "+
"read-configuration, not the host-side workspace_folder")
})
t.Run("Error", func(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" {
t.Skip("Dev Container tests are not supported on Windows (this test uses mocks but fails due to Windows paths)")
}
t.Run("DuringUp", func(t *testing.T) {
t.Parallel()
var (
ctx = testutil.Context(t, testutil.WaitMedium)
logger = slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
mClock = quartz.NewMock(t)
fCCLI = &fakeContainerCLI{arch: "<none>"}
fDCCLI = &fakeDevcontainerCLI{
upErrC: make(chan func() error, 1),
}
fSAC = &fakeSubAgentClient{
logger: logger.Named("fakeSubAgentClient"),
}
testDevcontainer = codersdk.WorkspaceAgentDevcontainer{
ID: uuid.New(),
Name: "test-devcontainer",
WorkspaceFolder: "/workspaces/project",
ConfigPath: "/workspaces/project/.devcontainer/devcontainer.json",
Status: codersdk.WorkspaceAgentDevcontainerStatusStopped,
}
)
mClock.Set(time.Now()).MustWait(ctx)
tickerTrap := mClock.Trap().TickerFunc("updaterLoop")
nowRecreateErrorTrap := mClock.Trap().Now("recreate", "errorTimes")
nowRecreateSuccessTrap := mClock.Trap().Now("recreate", "successTimes")
api := agentcontainers.NewAPI(logger,
agentcontainers.WithClock(mClock),
agentcontainers.WithContainerCLI(fCCLI),
agentcontainers.WithDevcontainerCLI(fDCCLI),
agentcontainers.WithDevcontainers(
[]codersdk.WorkspaceAgentDevcontainer{testDevcontainer},
[]codersdk.WorkspaceAgentScript{{ID: testDevcontainer.ID, LogSourceID: uuid.New()}},
),
agentcontainers.WithSubAgentClient(fSAC),
agentcontainers.WithSubAgentURL("test-subagent-url"),
agentcontainers.WithWatcher(watcher.NewNoop()),
)
api.Start()
defer func() {
close(fDCCLI.upErrC)
api.Close()
}()
r := chi.NewRouter()
r.Mount("/", api.Routes())
tickerTrap.MustWait(ctx).MustRelease(ctx)
tickerTrap.Close()
// Given: We send a 'recreate' request.
req := httptest.NewRequest(http.MethodPost, "/devcontainers/"+testDevcontainer.ID.String()+"/recreate", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
require.Equal(t, http.StatusAccepted, rec.Code)
// Given: We simulate an error running `devcontainer up`
simulatedError := xerrors.New("simulated error")
testutil.RequireSend(ctx, t, fDCCLI.upErrC, func() error { return simulatedError })
nowRecreateErrorTrap.MustWait(ctx).MustRelease(ctx)
nowRecreateErrorTrap.Close()
req = httptest.NewRequest(http.MethodGet, "/", nil)
rec = httptest.NewRecorder()
r.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
var response codersdk.WorkspaceAgentListContainersResponse
err := json.NewDecoder(rec.Body).Decode(&response)
require.NoError(t, err)
// Then: We expect that there will be an error associated with the devcontainer.
require.Len(t, response.Devcontainers, 1)
require.Equal(t, "simulated error", response.Devcontainers[0].Error)
// Given: We send another 'recreate' request.
req = httptest.NewRequest(http.MethodPost, "/devcontainers/"+testDevcontainer.ID.String()+"/recreate", nil)
rec = httptest.NewRecorder()
r.ServeHTTP(rec, req)
require.Equal(t, http.StatusAccepted, rec.Code)
// Given: We allow `devcontainer up` to succeed.
testutil.RequireSend(ctx, t, fDCCLI.upErrC, func() error {
req = httptest.NewRequest(http.MethodGet, "/", nil)
rec = httptest.NewRecorder()
r.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
response = codersdk.WorkspaceAgentListContainersResponse{}
err = json.NewDecoder(rec.Body).Decode(&response)
require.NoError(t, err)
// Then: We make sure that the error has been cleared before running up.
require.Len(t, response.Devcontainers, 1)
require.Equal(t, "", response.Devcontainers[0].Error)
return nil
})
nowRecreateSuccessTrap.MustWait(ctx).MustRelease(ctx)
nowRecreateSuccessTrap.Close()
req = httptest.NewRequest(http.MethodGet, "/", nil)
rec = httptest.NewRecorder()
r.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
response = codersdk.WorkspaceAgentListContainersResponse{}
err = json.NewDecoder(rec.Body).Decode(&response)
require.NoError(t, err)
// Then: We also expect no error after running up..
require.Len(t, response.Devcontainers, 1)
require.Equal(t, "", response.Devcontainers[0].Error)
})
// This test verifies that when devcontainer up fails due to a
// lifecycle script error (such as postCreateCommand failing) but the
// container was successfully created, we still proceed with the
// devcontainer. The container should be available for use and the
// agent should be injected.
t.Run("DuringUpWithContainerID", func(t *testing.T) {
t.Parallel()
var (
ctx = testutil.Context(t, testutil.WaitMedium)
logger = slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
mClock = quartz.NewMock(t)
testContainer = codersdk.WorkspaceAgentContainer{
ID: "test-container-id",
FriendlyName: "test-container",
Image: "test-image",
Running: true,
CreatedAt: time.Now(),
Labels: map[string]string{
agentcontainers.DevcontainerLocalFolderLabel: "/workspaces/project",
agentcontainers.DevcontainerConfigFileLabel: "/workspaces/project/.devcontainer/devcontainer.json",
},
}
fCCLI = &fakeContainerCLI{
containers: codersdk.WorkspaceAgentListContainersResponse{
Containers: []codersdk.WorkspaceAgentContainer{testContainer},
},
arch: "amd64",
}
fDCCLI = &fakeDevcontainerCLI{
upID: testContainer.ID,
upErrC: make(chan func() error, 1),
}
fSAC = &fakeSubAgentClient{
logger: logger.Named("fakeSubAgentClient"),
}
testDevcontainer = codersdk.WorkspaceAgentDevcontainer{
ID: uuid.New(),
Name: "test-devcontainer",
WorkspaceFolder: "/workspaces/project",
ConfigPath: "/workspaces/project/.devcontainer/devcontainer.json",
Status: codersdk.WorkspaceAgentDevcontainerStatusStopped,
}
)
mClock.Set(time.Now()).MustWait(ctx)
tickerTrap := mClock.Trap().TickerFunc("updaterLoop")
nowRecreateSuccessTrap := mClock.Trap().Now("recreate", "successTimes")
api := agentcontainers.NewAPI(logger,
agentcontainers.WithClock(mClock),
agentcontainers.WithContainerCLI(fCCLI),
agentcontainers.WithDevcontainerCLI(fDCCLI),
agentcontainers.WithDevcontainers(
[]codersdk.WorkspaceAgentDevcontainer{testDevcontainer},
[]codersdk.WorkspaceAgentScript{{ID: testDevcontainer.ID, LogSourceID: uuid.New()}},
),
agentcontainers.WithSubAgentClient(fSAC),
agentcontainers.WithSubAgentURL("test-subagent-url"),
agentcontainers.WithWatcher(watcher.NewNoop()),
)
api.Start()
defer func() {
close(fDCCLI.upErrC)
api.Close()
}()
r := chi.NewRouter()
r.Mount("/", api.Routes())
tickerTrap.MustWait(ctx).MustRelease(ctx)
tickerTrap.Close()
// Send a recreate request to trigger devcontainer up.
req := httptest.NewRequest(http.MethodPost, "/devcontainers/"+testDevcontainer.ID.String()+"/recreate", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
require.Equal(t, http.StatusAccepted, rec.Code)
// Simulate a lifecycle script failure. The devcontainer CLI
// will return an error but also provide a container ID since
// the container was created before the script failed.
simulatedError := xerrors.New("postCreateCommand failed with exit code 1")
testutil.RequireSend(ctx, t, fDCCLI.upErrC, func() error { return simulatedError })
// Wait for the recreate operation to complete. We expect it to
// record a success time because the container was created.
nowRecreateSuccessTrap.MustWait(ctx).MustRelease(ctx)
nowRecreateSuccessTrap.Close()
// Advance the clock to run the devcontainer state update routine.
_, aw := mClock.AdvanceNext()
aw.MustWait(ctx)
req = httptest.NewRequest(http.MethodGet, "/", nil)
rec = httptest.NewRecorder()
r.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
var response codersdk.WorkspaceAgentListContainersResponse
err := json.NewDecoder(rec.Body).Decode(&response)
require.NoError(t, err)
// Verify that the devcontainer is running and has the container
// associated with it despite the lifecycle script error. The
// error may be cleared during refresh if agent injection
// succeeds, but the important thing is that the container is
// available for use.
require.Len(t, response.Devcontainers, 1)
assert.Equal(t, codersdk.WorkspaceAgentDevcontainerStatusRunning, response.Devcontainers[0].Status)
require.NotNil(t, response.Devcontainers[0].Container)
assert.Equal(t, testContainer.ID, response.Devcontainers[0].Container.ID)
})
t.Run("DuringInjection", func(t *testing.T) {
t.Parallel()
var (
ctx = testutil.Context(t, testutil.WaitMedium)
logger = slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
mClock = quartz.NewMock(t)
mCCLI = acmock.NewMockContainerCLI(gomock.NewController(t))
fDCCLI = &fakeDevcontainerCLI{}
fSAC = &fakeSubAgentClient{
logger: logger.Named("fakeSubAgentClient"),
createErrC: make(chan error, 1),
}
containerCreatedAt = time.Now()
testContainer = codersdk.WorkspaceAgentContainer{
ID: "test-container-id",
FriendlyName: "test-container",
Image: "test-image",
Running: true,
CreatedAt: containerCreatedAt,
Labels: map[string]string{
agentcontainers.DevcontainerLocalFolderLabel: "/workspaces",
agentcontainers.DevcontainerConfigFileLabel: "/workspace/.devcontainer/devcontainer.json",
},
}
)
// Mock the `List` function to always return the test container.
mCCLI.EXPECT().List(gomock.Any()).Return(codersdk.WorkspaceAgentListContainersResponse{
Containers: []codersdk.WorkspaceAgentContainer{testContainer},
}, nil).AnyTimes()
// We're going to force the container CLI to fail, which will allow us to test the
// error handling.
simulatedError := xerrors.New("simulated error")
mCCLI.EXPECT().DetectArchitecture(gomock.Any(), testContainer.ID).Return("", simulatedError).Times(1)
mClock.Set(containerCreatedAt).MustWait(ctx)
tickerTrap := mClock.Trap().TickerFunc("updaterLoop")
api := agentcontainers.NewAPI(logger,
agentcontainers.WithClock(mClock),
agentcontainers.WithContainerCLI(mCCLI),
agentcontainers.WithDevcontainerCLI(fDCCLI),
agentcontainers.WithSubAgentClient(fSAC),
agentcontainers.WithSubAgentURL("test-subagent-url"),
agentcontainers.WithWatcher(watcher.NewNoop()),
)
api.Start()
defer func() {
close(fSAC.createErrC)
api.Close()
}()
r := chi.NewRouter()
r.Mount("/", api.Routes())
// Given: We allow an attempt at creation to occur.
tickerTrap.MustWait(ctx).MustRelease(ctx)
tickerTrap.Close()
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
var response codersdk.WorkspaceAgentListContainersResponse
err := json.NewDecoder(rec.Body).Decode(&response)
require.NoError(t, err)
// Then: We expect that there will be an error associated with the devcontainer.
require.Len(t, response.Devcontainers, 1)
require.Equal(t, "detect architecture: simulated error", response.Devcontainers[0].Error)
gomock.InOrder(
mCCLI.EXPECT().DetectArchitecture(gomock.Any(), testContainer.ID).Return(runtime.GOARCH, nil),
mCCLI.EXPECT().ExecAs(gomock.Any(), testContainer.ID, "root", "mkdir", "-p", "/.coder-agent").Return(nil, nil),
mCCLI.EXPECT().Copy(gomock.Any(), testContainer.ID, gomock.Any(), "/.coder-agent/coder").Return(nil),
mCCLI.EXPECT().ExecAs(gomock.Any(), testContainer.ID, "root", "chmod", "0755", "/.coder-agent", "/.coder-agent/coder").Return(nil, nil),
mCCLI.EXPECT().ExecAs(gomock.Any(), testContainer.ID, "root", "/bin/sh", "-c", "chown $(id -u):$(id -g) /.coder-agent/coder").Return(nil, nil),
)
// Given: We allow creation to succeed.
testutil.RequireSend(ctx, t, fSAC.createErrC, nil)
err = api.RefreshContainers(ctx)
require.NoError(t, err)
req = httptest.NewRequest(http.MethodGet, "/", nil)
rec = httptest.NewRecorder()
r.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
response = codersdk.WorkspaceAgentListContainersResponse{}
err = json.NewDecoder(rec.Body).Decode(&response)
require.NoError(t, err)
// Then: We expect that the error will be gone
require.Len(t, response.Devcontainers, 1)
require.Equal(t, "", response.Devcontainers[0].Error)
})
})
t.Run("Create", func(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" {
t.Skip("Dev Container tests are not supported on Windows (this test uses mocks but fails due to Windows paths)")
}
tests := []struct {
name string
customization agentcontainers.CoderCustomization
mergedCustomizations []agentcontainers.CoderCustomization
afterCreate func(t *testing.T, subAgent agentcontainers.SubAgent)
}{
{
name: "WithoutCustomization",
mergedCustomizations: nil,
},
{
name: "WithDefaultDisplayApps",
mergedCustomizations: []agentcontainers.CoderCustomization{},
afterCreate: func(t *testing.T, subAgent agentcontainers.SubAgent) {
require.Len(t, subAgent.DisplayApps, 4)
assert.Contains(t, subAgent.DisplayApps, codersdk.DisplayAppVSCodeDesktop)
assert.Contains(t, subAgent.DisplayApps, codersdk.DisplayAppWebTerminal)
assert.Contains(t, subAgent.DisplayApps, codersdk.DisplayAppSSH)
assert.Contains(t, subAgent.DisplayApps, codersdk.DisplayAppPortForward)
},
},
{
name: "WithAllDisplayApps",
mergedCustomizations: []agentcontainers.CoderCustomization{
{
DisplayApps: map[codersdk.DisplayApp]bool{
codersdk.DisplayAppSSH: true,
codersdk.DisplayAppWebTerminal: true,
codersdk.DisplayAppVSCodeDesktop: true,
codersdk.DisplayAppVSCodeInsiders: true,
codersdk.DisplayAppPortForward: true,
},
},
},
afterCreate: func(t *testing.T, subAgent agentcontainers.SubAgent) {
require.Len(t, subAgent.DisplayApps, 5)
assert.Contains(t, subAgent.DisplayApps, codersdk.DisplayAppSSH)
assert.Contains(t, subAgent.DisplayApps, codersdk.DisplayAppWebTerminal)
assert.Contains(t, subAgent.DisplayApps, codersdk.DisplayAppVSCodeDesktop)
assert.Contains(t, subAgent.DisplayApps, codersdk.DisplayAppVSCodeInsiders)
assert.Contains(t, subAgent.DisplayApps, codersdk.DisplayAppPortForward)
},
},
{
name: "WithSomeDisplayAppsDisabled",
mergedCustomizations: []agentcontainers.CoderCustomization{
{
DisplayApps: map[codersdk.DisplayApp]bool{
codersdk.DisplayAppSSH: false,
codersdk.DisplayAppWebTerminal: false,
codersdk.DisplayAppVSCodeInsiders: false,
// We'll enable vscode in this layer, and disable
// it in the next layer to ensure a layer can be
// disabled.
codersdk.DisplayAppVSCodeDesktop: true,
// We disable port-forward in this layer, and
// then re-enable it in the next layer to ensure
// that behavior works.
codersdk.DisplayAppPortForward: false,
},
},
{
DisplayApps: map[codersdk.DisplayApp]bool{
codersdk.DisplayAppVSCodeDesktop: false,
codersdk.DisplayAppPortForward: true,
},
},
},
afterCreate: func(t *testing.T, subAgent agentcontainers.SubAgent) {
require.Len(t, subAgent.DisplayApps, 1)
assert.Contains(t, subAgent.DisplayApps, codersdk.DisplayAppPortForward)
},
},
{
name: "WithApps",
mergedCustomizations: []agentcontainers.CoderCustomization{
{
Apps: []agentcontainers.SubAgentApp{
{
Slug: "web-app",
DisplayName: "Web Application",
URL: "http://localhost:8080",
OpenIn: codersdk.WorkspaceAppOpenInTab,
Share: codersdk.WorkspaceAppSharingLevelOwner,
Icon: "/icons/web.svg",
Order: int32(1),
},
{
Slug: "api-server",
DisplayName: "API Server",
URL: "http://localhost:3000",
OpenIn: codersdk.WorkspaceAppOpenInSlimWindow,
Share: codersdk.WorkspaceAppSharingLevelAuthenticated,
Icon: "/icons/api.svg",
Order: int32(2),
Hidden: true,
},
{
Slug: "docs",
DisplayName: "Documentation",
URL: "http://localhost:4000",
OpenIn: codersdk.WorkspaceAppOpenInTab,
Share: codersdk.WorkspaceAppSharingLevelPublic,
Icon: "/icons/book.svg",
Order: int32(3),
},
},
},
},
afterCreate: func(t *testing.T, subAgent agentcontainers.SubAgent) {
require.Len(t, subAgent.Apps, 3)
// Verify first app
assert.Equal(t, "web-app", subAgent.Apps[0].Slug)
assert.Equal(t, "Web Application", subAgent.Apps[0].DisplayName)
assert.Equal(t, "http://localhost:8080", subAgent.Apps[0].URL)
assert.Equal(t, codersdk.WorkspaceAppOpenInTab, subAgent.Apps[0].OpenIn)
assert.Equal(t, codersdk.WorkspaceAppSharingLevelOwner, subAgent.Apps[0].Share)
assert.Equal(t, "/icons/web.svg", subAgent.Apps[0].Icon)
assert.Equal(t, int32(1), subAgent.Apps[0].Order)
// Verify second app
assert.Equal(t, "api-server", subAgent.Apps[1].Slug)
assert.Equal(t, "API Server", subAgent.Apps[1].DisplayName)
assert.Equal(t, "http://localhost:3000", subAgent.Apps[1].URL)
assert.Equal(t, codersdk.WorkspaceAppOpenInSlimWindow, subAgent.Apps[1].OpenIn)
assert.Equal(t, codersdk.WorkspaceAppSharingLevelAuthenticated, subAgent.Apps[1].Share)
assert.Equal(t, "/icons/api.svg", subAgent.Apps[1].Icon)
assert.Equal(t, int32(2), subAgent.Apps[1].Order)
assert.Equal(t, true, subAgent.Apps[1].Hidden)
// Verify third app
assert.Equal(t, "docs", subAgent.Apps[2].Slug)
assert.Equal(t, "Documentation", subAgent.Apps[2].DisplayName)
assert.Equal(t, "http://localhost:4000", subAgent.Apps[2].URL)
assert.Equal(t, codersdk.WorkspaceAppOpenInTab, subAgent.Apps[2].OpenIn)
assert.Equal(t, codersdk.WorkspaceAppSharingLevelPublic, subAgent.Apps[2].Share)
assert.Equal(t, "/icons/book.svg", subAgent.Apps[2].Icon)
assert.Equal(t, int32(3), subAgent.Apps[2].Order)
},
},
{
name: "AppDeduplication",
mergedCustomizations: []agentcontainers.CoderCustomization{
{
Apps: []agentcontainers.SubAgentApp{
{
Slug: "foo-app",
Hidden: true,
Order: 1,
},
{
Slug: "bar-app",
},
},
},
{
Apps: []agentcontainers.SubAgentApp{
{
Slug: "foo-app",
Order: 2,
},
{
Slug: "baz-app",
},
},
},
},
afterCreate: func(t *testing.T, subAgent agentcontainers.SubAgent) {
require.Len(t, subAgent.Apps, 3)
// As the original "foo-app" gets overridden by the later "foo-app",
// we expect "bar-app" to be first in the order.
assert.Equal(t, "bar-app", subAgent.Apps[0].Slug)
assert.Equal(t, "foo-app", subAgent.Apps[1].Slug)
assert.Equal(t, "baz-app", subAgent.Apps[2].Slug)
// We do not expect the properties from the original "foo-app" to be
// carried over.
assert.Equal(t, false, subAgent.Apps[1].Hidden)
assert.Equal(t, int32(2), subAgent.Apps[1].Order)
},
},
{
name: "Name",
customization: agentcontainers.CoderCustomization{
Name: "this-name",
},
mergedCustomizations: []agentcontainers.CoderCustomization{
{
Name: "not-this-name",
},
{
Name: "or-this-name",
},
},
afterCreate: func(t *testing.T, subAgent agentcontainers.SubAgent) {
require.Equal(t, "this-name", subAgent.Name)
},
},
{
name: "NameIsOnlyUsedFromRoot",
mergedCustomizations: []agentcontainers.CoderCustomization{
{
Name: "custom-name",
},
},
afterCreate: func(t *testing.T, subAgent agentcontainers.SubAgent) {
require.NotEqual(t, "custom-name", subAgent.Name)
},
},
{
name: "EmptyNameIsIgnored",
customization: agentcontainers.CoderCustomization{
Name: "",
},
afterCreate: func(t *testing.T, subAgent agentcontainers.SubAgent) {
require.NotEmpty(t, subAgent.Name)
},
},
{
name: "InvalidNameIsIgnored",
customization: agentcontainers.CoderCustomization{
Name: "This--Is_An_Invalid--Name",
},
afterCreate: func(t *testing.T, subAgent agentcontainers.SubAgent) {
require.NotEqual(t, "This--Is_An_Invalid--Name", subAgent.Name)
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
var (
ctx = testutil.Context(t, testutil.WaitMedium)
logger = testutil.Logger(t)
mClock = quartz.NewMock(t)
mCCLI = acmock.NewMockContainerCLI(gomock.NewController(t))
fSAC = &fakeSubAgentClient{
logger: logger.Named("fakeSubAgentClient"),
createErrC: make(chan error, 1),
}
fDCCLI = &fakeDevcontainerCLI{
readConfig: agentcontainers.DevcontainerConfig{
Configuration: agentcontainers.DevcontainerConfiguration{
Customizations: agentcontainers.DevcontainerCustomizations{
Coder: tt.customization,
},
},
MergedConfiguration: agentcontainers.DevcontainerMergedConfiguration{
Customizations: agentcontainers.DevcontainerMergedCustomizations{
Coder: tt.mergedCustomizations,
},
},
},
}
testContainer = codersdk.WorkspaceAgentContainer{
ID: "test-container-id",
FriendlyName: "test-container",
Image: "test-image",
Running: true,
CreatedAt: time.Now(),
Labels: map[string]string{
agentcontainers.DevcontainerLocalFolderLabel: "/workspaces",
agentcontainers.DevcontainerConfigFileLabel: "/workspace/.devcontainer/devcontainer.json",
},
}
)
coderBin, err := os.Executable()
require.NoError(t, err)
coderBin, err = filepath.EvalSymlinks(coderBin)
require.NoError(t, err)
// Mock the `List` function to always return out test container.
mCCLI.EXPECT().List(gomock.Any()).Return(codersdk.WorkspaceAgentListContainersResponse{
Containers: []codersdk.WorkspaceAgentContainer{testContainer},
}, nil).AnyTimes()
// Mock the steps used for injecting the coder agent.
gomock.InOrder(
mCCLI.EXPECT().DetectArchitecture(gomock.Any(), testContainer.ID).Return(runtime.GOARCH, nil),
mCCLI.EXPECT().ExecAs(gomock.Any(), testContainer.ID, "root", "mkdir", "-p", "/.coder-agent").Return(nil, nil),
mCCLI.EXPECT().Copy(gomock.Any(), testContainer.ID, coderBin, "/.coder-agent/coder").Return(nil),
mCCLI.EXPECT().ExecAs(gomock.Any(), testContainer.ID, "root", "chmod", "0755", "/.coder-agent", "/.coder-agent/coder").Return(nil, nil),
mCCLI.EXPECT().ExecAs(gomock.Any(), testContainer.ID, "root", "/bin/sh", "-c", "chown $(id -u):$(id -g) /.coder-agent/coder").Return(nil, nil),
)
mClock.Set(time.Now()).MustWait(ctx)
tickerTrap := mClock.Trap().TickerFunc("updaterLoop")
api := agentcontainers.NewAPI(logger,
agentcontainers.WithClock(mClock),
agentcontainers.WithContainerCLI(mCCLI),
agentcontainers.WithDevcontainerCLI(fDCCLI),
agentcontainers.WithSubAgentClient(fSAC),
agentcontainers.WithSubAgentURL("test-subagent-url"),
agentcontainers.WithWatcher(watcher.NewNoop()),
)
api.Start()
defer api.Close()
// Close before api.Close() defer to avoid deadlock after test.
defer close(fSAC.createErrC)
// Given: We allow agent creation and injection to succeed.
testutil.RequireSend(ctx, t, fSAC.createErrC, nil)
// Wait until the ticker has been registered.
tickerTrap.MustWait(ctx).MustRelease(ctx)
tickerTrap.Close()
// Then: We expected it to succeed
require.Len(t, fSAC.created, 1)
if tt.afterCreate != nil {
tt.afterCreate(t, fSAC.created[0])
}
})
}
})
t.Run("CreateReadsConfigTwice", func(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" {
t.Skip("Dev Container tests are not supported on Windows (this test uses mocks but fails due to Windows paths)")
}
var (
ctx = testutil.Context(t, testutil.WaitMedium)
logger = testutil.Logger(t)
mClock = quartz.NewMock(t)
mCCLI = acmock.NewMockContainerCLI(gomock.NewController(t))
fSAC = &fakeSubAgentClient{
logger: logger.Named("fakeSubAgentClient"),
createErrC: make(chan error, 1),
}
fDCCLI = &fakeDevcontainerCLI{
readConfig: agentcontainers.DevcontainerConfig{
Configuration: agentcontainers.DevcontainerConfiguration{
Customizations: agentcontainers.DevcontainerCustomizations{
Coder: agentcontainers.CoderCustomization{
// We want to specify a custom name for this agent.
Name: "custom-name",
},
},
},
},
readConfigErrC: make(chan func(envs []string) error, 2),
}
testContainer = codersdk.WorkspaceAgentContainer{
ID: "test-container-id",
FriendlyName: "test-container",
Image: "test-image",
Running: true,
CreatedAt: time.Now(),
Labels: map[string]string{
agentcontainers.DevcontainerLocalFolderLabel: "/workspaces/coder",
agentcontainers.DevcontainerConfigFileLabel: "/workspaces/coder/.devcontainer/devcontainer.json",
},
}
)
coderBin, err := os.Executable()
require.NoError(t, err)
coderBin, err = filepath.EvalSymlinks(coderBin)
require.NoError(t, err)
// Mock the `List` function to always return out test container.
mCCLI.EXPECT().List(gomock.Any()).Return(codersdk.WorkspaceAgentListContainersResponse{
Containers: []codersdk.WorkspaceAgentContainer{testContainer},
}, nil).AnyTimes()
// Mock the steps used for injecting the coder agent.
gomock.InOrder(
mCCLI.EXPECT().DetectArchitecture(gomock.Any(), testContainer.ID).Return(runtime.GOARCH, nil),
mCCLI.EXPECT().ExecAs(gomock.Any(), testContainer.ID, "root", "mkdir", "-p", "/.coder-agent").Return(nil, nil),
mCCLI.EXPECT().Copy(gomock.Any(), testContainer.ID, coderBin, "/.coder-agent/coder").Return(nil),
mCCLI.EXPECT().ExecAs(gomock.Any(), testContainer.ID, "root", "chmod", "0755", "/.coder-agent", "/.coder-agent/coder").Return(nil, nil),
mCCLI.EXPECT().ExecAs(gomock.Any(), testContainer.ID, "root", "/bin/sh", "-c", "chown $(id -u):$(id -g) /.coder-agent/coder").Return(nil, nil),
)
mClock.Set(time.Now()).MustWait(ctx)
tickerTrap := mClock.Trap().TickerFunc("updaterLoop")
api := agentcontainers.NewAPI(logger,
agentcontainers.WithClock(mClock),
agentcontainers.WithContainerCLI(mCCLI),
agentcontainers.WithDevcontainerCLI(fDCCLI),
agentcontainers.WithSubAgentClient(fSAC),
agentcontainers.WithSubAgentURL("test-subagent-url"),
agentcontainers.WithWatcher(watcher.NewNoop()),
)
api.Start()
defer api.Close()
// Close before api.Close() defer to avoid deadlock after test.
defer close(fSAC.createErrC)
defer close(fDCCLI.readConfigErrC)
// Given: We allow agent creation and injection to succeed.
testutil.RequireSend(ctx, t, fSAC.createErrC, nil)
testutil.RequireSend(ctx, t, fDCCLI.readConfigErrC, func(env []string) error {
// We expect the wrong workspace agent name passed in first.
assert.Contains(t, env, "CODER_WORKSPACE_AGENT_NAME=coder")
return nil
})
testutil.RequireSend(ctx, t, fDCCLI.readConfigErrC, func(env []string) error {
// We then expect the agent name passed here to have been read from the config.
assert.Contains(t, env, "CODER_WORKSPACE_AGENT_NAME=custom-name")
assert.NotContains(t, env, "CODER_WORKSPACE_AGENT_NAME=coder")
return nil
})
// Wait until the ticker has been registered.
tickerTrap.MustWait(ctx).MustRelease(ctx)
tickerTrap.Close()
// Then: We expected it to succeed
require.Len(t, fSAC.created, 1)
})
t.Run("ReadConfigWithFeatureOptions", func(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" {
t.Skip("Dev Container tests are not supported on Windows (this test uses mocks but fails due to Windows paths)")
}
var (
ctx = testutil.Context(t, testutil.WaitMedium)
logger = testutil.Logger(t)
mClock = quartz.NewMock(t)
mCCLI = acmock.NewMockContainerCLI(gomock.NewController(t))
fSAC = &fakeSubAgentClient{
logger: logger.Named("fakeSubAgentClient"),
createErrC: make(chan error, 1),
}
fDCCLI = &fakeDevcontainerCLI{
readConfig: agentcontainers.DevcontainerConfig{
MergedConfiguration: agentcontainers.DevcontainerMergedConfiguration{
Features: agentcontainers.DevcontainerFeatures{
"./code-server": map[string]any{
"port": 9090,
},
"ghcr.io/devcontainers/features/docker-in-docker:2": map[string]any{
"moby": "false",
},
},
},
Workspace: agentcontainers.DevcontainerWorkspace{
WorkspaceFolder: "/workspaces/coder",
},
},
readConfigErrC: make(chan func(envs []string) error, 2),
}
testContainer = codersdk.WorkspaceAgentContainer{
ID: "test-container-id",
FriendlyName: "test-container",
Image: "test-image",
Running: true,
CreatedAt: time.Now(),
Labels: map[string]string{
agentcontainers.DevcontainerLocalFolderLabel: "/workspaces/coder",
agentcontainers.DevcontainerConfigFileLabel: "/workspaces/coder/.devcontainer/devcontainer.json",
},
}
)
coderBin, err := os.Executable()
require.NoError(t, err)
coderBin, err = filepath.EvalSymlinks(coderBin)
require.NoError(t, err)
// Mock the `List` function to always return our test container.
mCCLI.EXPECT().List(gomock.Any()).Return(codersdk.WorkspaceAgentListContainersResponse{
Containers: []codersdk.WorkspaceAgentContainer{testContainer},
}, nil).AnyTimes()
// Mock the steps used for injecting the coder agent.
gomock.InOrder(
mCCLI.EXPECT().DetectArchitecture(gomock.Any(), testContainer.ID).Return(runtime.GOARCH, nil),
mCCLI.EXPECT().ExecAs(gomock.Any(), testContainer.ID, "root", "mkdir", "-p", "/.coder-agent").Return(nil, nil),
mCCLI.EXPECT().Copy(gomock.Any(), testContainer.ID, coderBin, "/.coder-agent/coder").Return(nil),
mCCLI.EXPECT().ExecAs(gomock.Any(), testContainer.ID, "root", "chmod", "0755", "/.coder-agent", "/.coder-agent/coder").Return(nil, nil),
mCCLI.EXPECT().ExecAs(gomock.Any(), testContainer.ID, "root", "/bin/sh", "-c", "chown $(id -u):$(id -g) /.coder-agent/coder").Return(nil, nil),
)
mClock.Set(time.Now()).MustWait(ctx)
tickerTrap := mClock.Trap().TickerFunc("updaterLoop")
api := agentcontainers.NewAPI(logger,
agentcontainers.WithClock(mClock),
agentcontainers.WithContainerCLI(mCCLI),
agentcontainers.WithDevcontainerCLI(fDCCLI),
agentcontainers.WithSubAgentClient(fSAC),
agentcontainers.WithSubAgentURL("test-subagent-url"),
agentcontainers.WithWatcher(watcher.NewNoop()),
agentcontainers.WithManifestInfo("test-user", "test-workspace", "test-parent-agent", "/parent-agent"),
)
api.Start()
defer api.Close()
// Close before api.Close() defer to avoid deadlock after test.
defer close(fSAC.createErrC)
defer close(fDCCLI.readConfigErrC)
// Allow agent creation and injection to succeed.
testutil.RequireSend(ctx, t, fSAC.createErrC, nil)
testutil.RequireSend(ctx, t, fDCCLI.readConfigErrC, func(envs []string) error {
assert.Contains(t, envs, "CODER_WORKSPACE_AGENT_NAME=coder")
assert.Contains(t, envs, "CODER_WORKSPACE_NAME=test-workspace")
assert.Contains(t, envs, "CODER_WORKSPACE_OWNER_NAME=test-user")
assert.Contains(t, envs, "CODER_WORKSPACE_PARENT_AGENT_NAME=test-parent-agent")
assert.Contains(t, envs, "CODER_URL=test-subagent-url")
assert.Contains(t, envs, "CONTAINER_ID=test-container-id")
// First call should not have feature envs.
assert.NotContains(t, envs, "FEATURE_CODE_SERVER_OPTION_PORT=9090")
assert.NotContains(t, envs, "FEATURE_DOCKER_IN_DOCKER_OPTION_MOBY=false")
return nil
})
testutil.RequireSend(ctx, t, fDCCLI.readConfigErrC, func(envs []string) error {
assert.Contains(t, envs, "CODER_WORKSPACE_AGENT_NAME=coder")
assert.Contains(t, envs, "CODER_WORKSPACE_NAME=test-workspace")
assert.Contains(t, envs, "CODER_WORKSPACE_OWNER_NAME=test-user")
assert.Contains(t, envs, "CODER_WORKSPACE_PARENT_AGENT_NAME=test-parent-agent")
assert.Contains(t, envs, "CODER_URL=test-subagent-url")
assert.Contains(t, envs, "CONTAINER_ID=test-container-id")
// Second call should have feature envs from the first config read.
assert.Contains(t, envs, "FEATURE_CODE_SERVER_OPTION_PORT=9090")
assert.Contains(t, envs, "FEATURE_DOCKER_IN_DOCKER_OPTION_MOBY=false")
return nil
})
// Wait until the ticker has been registered.
tickerTrap.MustWait(ctx).MustRelease(ctx)
tickerTrap.Close()
// Verify agent was created successfully
require.Len(t, fSAC.created, 1)
})
t.Run("CommandEnv", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
// Create fake execer to track execution details.
fakeExec := &fakeExecer{}
// Custom CommandEnv that returns specific values.
testShell := "/bin/custom-shell"
testDir := t.TempDir()
testEnv := []string{"CUSTOM_VAR=test_value", "PATH=/custom/path"}
commandEnv := func(ei usershell.EnvInfoer, addEnv []string) (shell, dir string, env []string, err error) {
return testShell, testDir, testEnv, nil
}
mClock := quartz.NewMock(t) // Stop time.
// Create API with CommandEnv.
api := agentcontainers.NewAPI(logger,
agentcontainers.WithClock(mClock),
agentcontainers.WithExecer(fakeExec),
agentcontainers.WithCommandEnv(commandEnv),
)
api.Start()
defer api.Close()
// Call RefreshContainers directly to trigger CommandEnv usage.
_ = api.RefreshContainers(ctx) // Ignore error since docker commands will fail.
// Verify commands were executed through the custom shell and environment.
require.NotEmpty(t, fakeExec.commands, "commands should be executed")
// Want: /bin/custom-shell -c '"docker" "ps" "--all" "--quiet" "--no-trunc"'
require.Equal(t, testShell, fakeExec.commands[0][0], "custom shell should be used")
if runtime.GOOS == "windows" {
require.Equal(t, "/c", fakeExec.commands[0][1], "shell should be called with /c on Windows")
} else {
require.Equal(t, "-c", fakeExec.commands[0][1], "shell should be called with -c")
}
require.Len(t, fakeExec.commands[0], 3, "command should have 3 arguments")
require.GreaterOrEqual(t, strings.Count(fakeExec.commands[0][2], " "), 2, "command/script should have multiple arguments")
require.True(t, strings.HasPrefix(fakeExec.commands[0][2], `"docker" "ps"`), "command should start with \"docker\" \"ps\"")
// Verify the environment was set on the command.
lastCmd := fakeExec.getLastCommand()
require.NotNil(t, lastCmd, "command should be created")
require.Equal(t, testDir, lastCmd.Dir, "custom directory should be used")
require.Equal(t, testEnv, lastCmd.Env, "custom environment should be used")
})
t.Run("IgnoreCustomization", func(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" {
t.Skip("Dev Container tests are not supported on Windows (this test uses mocks but fails due to Windows paths)")
}
ctx := testutil.Context(t, testutil.WaitShort)
startTime := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC)
configPath := "/workspace/project/.devcontainer/devcontainer.json"
container := codersdk.WorkspaceAgentContainer{
ID: "container-id",
FriendlyName: "container-name",
Running: true,
CreatedAt: startTime.Add(-1 * time.Hour),
Labels: map[string]string{
agentcontainers.DevcontainerLocalFolderLabel: "/workspace/project",
agentcontainers.DevcontainerConfigFileLabel: configPath,
},
}
fLister := &fakeContainerCLI{
containers: codersdk.WorkspaceAgentListContainersResponse{
Containers: []codersdk.WorkspaceAgentContainer{container},
},
arch: runtime.GOARCH,
}
// Start with ignore=true
fDCCLI := &fakeDevcontainerCLI{
execErrC: make(chan func(string, ...string) error, 1),
readConfig: agentcontainers.DevcontainerConfig{
Configuration: agentcontainers.DevcontainerConfiguration{
Customizations: agentcontainers.DevcontainerCustomizations{
Coder: agentcontainers.CoderCustomization{Ignore: true},
},
},
Workspace: agentcontainers.DevcontainerWorkspace{WorkspaceFolder: "/workspace/project"},
},
}
fakeSAC, cleanupSAC := newFakeSubAgentClient(t, slogtest.Make(t, nil).Named("fakeSubAgentClient"))
defer cleanupSAC()
mClock := quartz.NewMock(t)
mClock.Set(startTime)
fWatcher := newFakeWatcher(t)
logger := slogtest.Make(t, nil).Leveled(slog.LevelDebug)
api := agentcontainers.NewAPI(
logger,
agentcontainers.WithDevcontainerCLI(fDCCLI),
agentcontainers.WithContainerCLI(fLister),
agentcontainers.WithSubAgentClient(fakeSAC),
agentcontainers.WithWatcher(fWatcher),
agentcontainers.WithClock(mClock),
)
api.Start()
defer func() {
_ = api.Close()
}()
err := api.RefreshContainers(ctx)
require.NoError(t, err, "RefreshContainers should not error")
r := chi.NewRouter()
r.Mount("/", api.Routes())
t.Log("Phase 1: Test ignore=true filters out devcontainer")
req := httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
var response codersdk.WorkspaceAgentListContainersResponse
err = json.NewDecoder(rec.Body).Decode(&response)
require.NoError(t, err)
assert.Empty(t, response.Devcontainers, "ignored devcontainer should not be in response when ignore=true")
assert.Len(t, response.Containers, 1, "regular container should still be listed")
t.Log("Phase 2: Change to ignore=false")
fDCCLI.readConfig.Configuration.Customizations.Coder.Ignore = false
var (
exitSubAgent = make(chan struct{})
subAgentExited = make(chan struct{})
exitSubAgentOnce sync.Once
)
defer func() {
exitSubAgentOnce.Do(func() {
close(exitSubAgent)
})
}()
execSubAgent := func(cmd string, args ...string) error {
if len(args) != 1 || args[0] != "agent" {
t.Log("execSubAgent called with unexpected arguments", cmd, args)
return nil
}
defer close(subAgentExited)
select {
case <-exitSubAgent:
case <-ctx.Done():
return ctx.Err()
}
return nil
}
testutil.RequireSend(ctx, t, fDCCLI.execErrC, execSubAgent)
allowSubAgentCreate(ctx, t, fakeSAC)
fWatcher.sendEventWaitNextCalled(ctx, fsnotify.Event{
Name: configPath,
Op: fsnotify.Write,
})
require.Eventuallyf(t, func() bool {
err = api.RefreshContainers(ctx)
require.NoError(t, err)
return len(fakeSAC.agents) == 1
}, testutil.WaitShort, testutil.IntervalFast, "subagent should be created after config change")
t.Log("Phase 2: Cont, waiting for sub agent to exit")
exitSubAgentOnce.Do(func() {
close(exitSubAgent)
})
select {
case <-subAgentExited:
case <-ctx.Done():
t.Fatal("timeout waiting for sub agent to exit")
}
req = httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx)
rec = httptest.NewRecorder()
r.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
err = json.NewDecoder(rec.Body).Decode(&response)
require.NoError(t, err)
assert.Len(t, response.Devcontainers, 1, "devcontainer should be in response when ignore=false")
assert.Len(t, response.Containers, 1, "regular container should still be listed")
assert.Equal(t, "/workspace/project", response.Devcontainers[0].WorkspaceFolder)
require.Len(t, fakeSAC.created, 1, "sub agent should be created when ignore=false")
createdAgentID := fakeSAC.created[0].ID
t.Log("Phase 3: Change back to ignore=true and test sub agent deletion")
fDCCLI.readConfig.Configuration.Customizations.Coder.Ignore = true
allowSubAgentDelete(ctx, t, fakeSAC)
fWatcher.sendEventWaitNextCalled(ctx, fsnotify.Event{
Name: configPath,
Op: fsnotify.Write,
})
require.Eventuallyf(t, func() bool {
err = api.RefreshContainers(ctx)
require.NoError(t, err)
return len(fakeSAC.agents) == 0
}, testutil.WaitShort, testutil.IntervalFast, "subagent should be deleted after config change")
req = httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx)
rec = httptest.NewRecorder()
r.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
err = json.NewDecoder(rec.Body).Decode(&response)
require.NoError(t, err)
assert.Empty(t, response.Devcontainers, "devcontainer should be filtered out when ignore=true again")
assert.Len(t, response.Containers, 1, "regular container should still be listed")
require.Len(t, fakeSAC.deleted, 1, "sub agent should be deleted when ignore=true")
assert.Equal(t, createdAgentID, fakeSAC.deleted[0], "the same sub agent that was created should be deleted")
})
}
// mustFindDevcontainerByPath returns the devcontainer with the given workspace
// folder path. It fails the test if no matching devcontainer is found.
func mustFindDevcontainerByPath(t *testing.T, devcontainers []codersdk.WorkspaceAgentDevcontainer, path string) codersdk.WorkspaceAgentDevcontainer {
t.Helper()
for i := range devcontainers {
if devcontainers[i].WorkspaceFolder == path {
return devcontainers[i]
}
}
require.Failf(t, "no devcontainer found with workspace folder %q", path)
return codersdk.WorkspaceAgentDevcontainer{} // Unreachable, but required for compilation
}
// TestSubAgentCreationWithNameRetry tests the retry logic when unique constraint violations occur
func TestSubAgentCreationWithNameRetry(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" {
t.Skip("Dev Container tests are not supported on Windows")
}
tests := []struct {
name string
workspaceFolders []string
expectedNames []string
takenNames []string
}{
{
name: "SingleCollision",
workspaceFolders: []string{
"/home/coder/foo/project",
"/home/coder/bar/project",
},
expectedNames: []string{
"project",
"bar-project",
},
},
{
name: "MultipleCollisions",
workspaceFolders: []string{
"/home/coder/foo/x/project",
"/home/coder/bar/x/project",
"/home/coder/baz/x/project",
},
expectedNames: []string{
"project",
"x-project",
"baz-x-project",
},
},
{
name: "NameAlreadyTaken",
takenNames: []string{"project", "x-project"},
workspaceFolders: []string{
"/home/coder/foo/x/project",
},
expectedNames: []string{
"foo-x-project",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
var (
ctx = testutil.Context(t, testutil.WaitMedium)
logger = testutil.Logger(t)
mClock = quartz.NewMock(t)
fSAC = &fakeSubAgentClient{logger: logger, agents: make(map[uuid.UUID]agentcontainers.SubAgent)}
ccli = &fakeContainerCLI{arch: runtime.GOARCH}
)
for _, name := range tt.takenNames {
fSAC.agents[uuid.New()] = agentcontainers.SubAgent{Name: name}
}
mClock.Set(time.Now()).MustWait(ctx)
tickerTrap := mClock.Trap().TickerFunc("updaterLoop")
api := agentcontainers.NewAPI(logger,
agentcontainers.WithClock(mClock),
agentcontainers.WithContainerCLI(ccli),
agentcontainers.WithDevcontainerCLI(&fakeDevcontainerCLI{}),
agentcontainers.WithSubAgentClient(fSAC),
agentcontainers.WithWatcher(watcher.NewNoop()),
)
api.Start()
defer api.Close()
tickerTrap.MustWait(ctx).MustRelease(ctx)
tickerTrap.Close()
for i, workspaceFolder := range tt.workspaceFolders {
ccli.containers.Containers = append(ccli.containers.Containers, newFakeContainer(
fmt.Sprintf("container%d", i+1),
fmt.Sprintf("/.devcontainer/devcontainer%d.json", i+1),
workspaceFolder,
))
err := api.RefreshContainers(ctx)
require.NoError(t, err)
}
// Verify that both agents were created with expected names
require.Len(t, fSAC.created, len(tt.workspaceFolders))
actualNames := make([]string, len(fSAC.created))
for i, agent := range fSAC.created {
actualNames[i] = agent.Name
}
slices.Sort(tt.expectedNames)
slices.Sort(actualNames)
assert.Equal(t, tt.expectedNames, actualNames)
})
}
}
func newFakeContainer(id, configPath, workspaceFolder string) codersdk.WorkspaceAgentContainer {
return codersdk.WorkspaceAgentContainer{
ID: id,
FriendlyName: "test-friendly",
Image: "test-image:latest",
Labels: map[string]string{
agentcontainers.DevcontainerLocalFolderLabel: workspaceFolder,
agentcontainers.DevcontainerConfigFileLabel: configPath,
},
Running: true,
}
}
func fakeContainer(t *testing.T, mut ...func(*codersdk.WorkspaceAgentContainer)) codersdk.WorkspaceAgentContainer {
t.Helper()
ct := codersdk.WorkspaceAgentContainer{
CreatedAt: time.Now().UTC(),
ID: uuid.New().String(),
FriendlyName: testutil.GetRandomName(t),
Image: testutil.GetRandomName(t) + ":" + strings.Split(uuid.New().String(), "-")[0],
Labels: map[string]string{
testutil.GetRandomName(t): testutil.GetRandomName(t),
},
Running: true,
Ports: []codersdk.WorkspaceAgentContainerPort{
{
Network: "tcp",
Port: testutil.RandomPortNoListen(t),
HostPort: testutil.RandomPortNoListen(t),
//nolint:gosec // this is a test
HostIP: []string{"127.0.0.1", "[::1]", "localhost", "0.0.0.0", "[::]", testutil.GetRandomName(t)}[rand.Intn(6)],
},
},
Status: testutil.MustRandString(t, 10),
Volumes: map[string]string{testutil.GetRandomName(t): testutil.GetRandomName(t)},
}
for _, m := range mut {
m(&ct)
}
return ct
}
func TestWithDevcontainersNameGeneration(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" {
t.Skip("Dev Container tests are not supported on Windows")
}
devcontainers := []codersdk.WorkspaceAgentDevcontainer{
{
ID: uuid.New(),
Name: "original-name",
WorkspaceFolder: "/home/coder/foo/project",
ConfigPath: "/home/coder/foo/project/.devcontainer/devcontainer.json",
},
{
ID: uuid.New(),
Name: "another-name",
WorkspaceFolder: "/home/coder/bar/project",
ConfigPath: "/home/coder/bar/project/.devcontainer/devcontainer.json",
},
}
scripts := []codersdk.WorkspaceAgentScript{
{ID: devcontainers[0].ID, LogSourceID: uuid.New()},
{ID: devcontainers[1].ID, LogSourceID: uuid.New()},
}
logger := testutil.Logger(t)
// This should trigger the WithDevcontainers code path where names are generated
api := agentcontainers.NewAPI(logger,
agentcontainers.WithDevcontainers(devcontainers, scripts),
agentcontainers.WithContainerCLI(&fakeContainerCLI{
containers: codersdk.WorkspaceAgentListContainersResponse{
Containers: []codersdk.WorkspaceAgentContainer{
fakeContainer(t, func(c *codersdk.WorkspaceAgentContainer) {
c.ID = "some-container-id-1"
c.FriendlyName = "container-name-1"
c.Labels[agentcontainers.DevcontainerLocalFolderLabel] = "/home/coder/baz/project"
c.Labels[agentcontainers.DevcontainerConfigFileLabel] = "/home/coder/baz/project/.devcontainer/devcontainer.json"
}),
},
},
}),
agentcontainers.WithDevcontainerCLI(&fakeDevcontainerCLI{}),
agentcontainers.WithSubAgentClient(&fakeSubAgentClient{}),
agentcontainers.WithWatcher(watcher.NewNoop()),
)
defer api.Close()
api.Start()
r := chi.NewRouter()
r.Mount("/", api.Routes())
ctx := context.Background()
err := api.RefreshContainers(ctx)
require.NoError(t, err, "RefreshContainers should not error")
// Initial request returns the initial data.
req := httptest.NewRequest(http.MethodGet, "/", nil).
WithContext(ctx)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
var response codersdk.WorkspaceAgentListContainersResponse
err = json.NewDecoder(rec.Body).Decode(&response)
require.NoError(t, err)
// Verify the devcontainers have the expected names.
require.Len(t, response.Devcontainers, 3, "should have two devcontainers")
assert.NotEqual(t, "original-name", response.Devcontainers[2].Name, "first devcontainer should not keep original name")
assert.Equal(t, "project", response.Devcontainers[2].Name, "first devcontainer should use the project folder name")
assert.NotEqual(t, "another-name", response.Devcontainers[0].Name, "second devcontainer should not keep original name")
assert.Equal(t, "bar-project", response.Devcontainers[0].Name, "second devcontainer should has a collision and uses the folder name with a prefix")
assert.Equal(t, "baz-project", response.Devcontainers[1].Name, "third devcontainer should use the folder name with a prefix since it collides with the first two")
}
func TestDevcontainerDiscovery(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" {
t.Skip("Dev Container tests are not supported on Windows")
}
// We discover dev container projects by searching
// for git repositories at the agent's directory,
// and then recursively walking through these git
// repositories to find any `.devcontainer/devcontainer.json`
// files. These tests are to validate that behavior.
homeDir, err := os.UserHomeDir()
require.NoError(t, err)
tests := []struct {
name string
agentDir string
fs map[string]string
expected []codersdk.WorkspaceAgentDevcontainer
}{
{
name: "GitProjectInRootDir/SingleProject",
agentDir: "/home/coder",
fs: map[string]string{
"/home/coder/.git/HEAD": "",
"/home/coder/.devcontainer/devcontainer.json": "",
},
expected: []codersdk.WorkspaceAgentDevcontainer{
{
WorkspaceFolder: "/home/coder",
ConfigPath: "/home/coder/.devcontainer/devcontainer.json",
Status: codersdk.WorkspaceAgentDevcontainerStatusStopped,
},
},
},
{
name: "GitProjectInRootDir/MultipleProjects",
agentDir: "/home/coder",
fs: map[string]string{
"/home/coder/.git/HEAD": "",
"/home/coder/.devcontainer/devcontainer.json": "",
"/home/coder/site/.devcontainer/devcontainer.json": "",
},
expected: []codersdk.WorkspaceAgentDevcontainer{
{
WorkspaceFolder: "/home/coder",
ConfigPath: "/home/coder/.devcontainer/devcontainer.json",
Status: codersdk.WorkspaceAgentDevcontainerStatusStopped,
},
{
WorkspaceFolder: "/home/coder/site",
ConfigPath: "/home/coder/site/.devcontainer/devcontainer.json",
Status: codersdk.WorkspaceAgentDevcontainerStatusStopped,
},
},
},
{
name: "GitProjectInChildDir/SingleProject",
agentDir: "/home/coder",
fs: map[string]string{
"/home/coder/coder/.git/HEAD": "",
"/home/coder/coder/.devcontainer/devcontainer.json": "",
},
expected: []codersdk.WorkspaceAgentDevcontainer{
{
WorkspaceFolder: "/home/coder/coder",
ConfigPath: "/home/coder/coder/.devcontainer/devcontainer.json",
Status: codersdk.WorkspaceAgentDevcontainerStatusStopped,
},
},
},
{
name: "GitProjectInChildDir/MultipleProjects",
agentDir: "/home/coder",
fs: map[string]string{
"/home/coder/coder/.git/HEAD": "",
"/home/coder/coder/.devcontainer/devcontainer.json": "",
"/home/coder/coder/site/.devcontainer/devcontainer.json": "",
},
expected: []codersdk.WorkspaceAgentDevcontainer{
{
WorkspaceFolder: "/home/coder/coder",
ConfigPath: "/home/coder/coder/.devcontainer/devcontainer.json",
Status: codersdk.WorkspaceAgentDevcontainerStatusStopped,
},
{
WorkspaceFolder: "/home/coder/coder/site",
ConfigPath: "/home/coder/coder/site/.devcontainer/devcontainer.json",
Status: codersdk.WorkspaceAgentDevcontainerStatusStopped,
},
},
},
{
name: "GitProjectInMultipleChildDirs/SingleProjectEach",
agentDir: "/home/coder",
fs: map[string]string{
"/home/coder/coder/.git/HEAD": "",
"/home/coder/coder/.devcontainer/devcontainer.json": "",
"/home/coder/envbuilder/.git/HEAD": "",
"/home/coder/envbuilder/.devcontainer/devcontainer.json": "",
},
expected: []codersdk.WorkspaceAgentDevcontainer{
{
WorkspaceFolder: "/home/coder/coder",
ConfigPath: "/home/coder/coder/.devcontainer/devcontainer.json",
Status: codersdk.WorkspaceAgentDevcontainerStatusStopped,
},
{
WorkspaceFolder: "/home/coder/envbuilder",
ConfigPath: "/home/coder/envbuilder/.devcontainer/devcontainer.json",
Status: codersdk.WorkspaceAgentDevcontainerStatusStopped,
},
},
},
{
name: "GitProjectInMultipleChildDirs/MultipleProjectEach",
agentDir: "/home/coder",
fs: map[string]string{
"/home/coder/coder/.git/HEAD": "",
"/home/coder/coder/.devcontainer/devcontainer.json": "",
"/home/coder/coder/site/.devcontainer/devcontainer.json": "",
"/home/coder/envbuilder/.git/HEAD": "",
"/home/coder/envbuilder/.devcontainer/devcontainer.json": "",
"/home/coder/envbuilder/x/.devcontainer/devcontainer.json": "",
},
expected: []codersdk.WorkspaceAgentDevcontainer{
{
WorkspaceFolder: "/home/coder/coder",
ConfigPath: "/home/coder/coder/.devcontainer/devcontainer.json",
Status: codersdk.WorkspaceAgentDevcontainerStatusStopped,
},
{
WorkspaceFolder: "/home/coder/coder/site",
ConfigPath: "/home/coder/coder/site/.devcontainer/devcontainer.json",
Status: codersdk.WorkspaceAgentDevcontainerStatusStopped,
},
{
WorkspaceFolder: "/home/coder/envbuilder",
ConfigPath: "/home/coder/envbuilder/.devcontainer/devcontainer.json",
Status: codersdk.WorkspaceAgentDevcontainerStatusStopped,
},
{
WorkspaceFolder: "/home/coder/envbuilder/x",
ConfigPath: "/home/coder/envbuilder/x/.devcontainer/devcontainer.json",
Status: codersdk.WorkspaceAgentDevcontainerStatusStopped,
},
},
},
{
name: "RespectGitIgnore",
agentDir: "/home/coder",
fs: map[string]string{
"/home/coder/coder/.git/HEAD": "",
"/home/coder/coder/.gitignore": "y/",
"/home/coder/coder/.devcontainer.json": "",
"/home/coder/coder/x/y/.devcontainer.json": "",
},
expected: []codersdk.WorkspaceAgentDevcontainer{
{
WorkspaceFolder: "/home/coder/coder",
ConfigPath: "/home/coder/coder/.devcontainer.json",
Status: codersdk.WorkspaceAgentDevcontainerStatusStopped,
},
},
},
{
name: "RespectNestedGitIgnore",
agentDir: "/home/coder",
fs: map[string]string{
"/home/coder/coder/.git/HEAD": "",
"/home/coder/coder/.devcontainer.json": "",
"/home/coder/coder/y/.devcontainer.json": "",
"/home/coder/coder/x/.gitignore": "y/",
"/home/coder/coder/x/y/.devcontainer.json": "",
},
expected: []codersdk.WorkspaceAgentDevcontainer{
{
WorkspaceFolder: "/home/coder/coder",
ConfigPath: "/home/coder/coder/.devcontainer.json",
Status: codersdk.WorkspaceAgentDevcontainerStatusStopped,
},
{
WorkspaceFolder: "/home/coder/coder/y",
ConfigPath: "/home/coder/coder/y/.devcontainer.json",
Status: codersdk.WorkspaceAgentDevcontainerStatusStopped,
},
},
},
{
name: "RespectGitInfoExclude",
agentDir: "/home/coder",
fs: map[string]string{
"/home/coder/coder/.git/HEAD": "",
"/home/coder/coder/.git/info/exclude": "y/",
"/home/coder/coder/.devcontainer.json": "",
"/home/coder/coder/x/y/.devcontainer.json": "",
},
expected: []codersdk.WorkspaceAgentDevcontainer{
{
WorkspaceFolder: "/home/coder/coder",
ConfigPath: "/home/coder/coder/.devcontainer.json",
Status: codersdk.WorkspaceAgentDevcontainerStatusStopped,
},
},
},
{
name: "RespectHomeGitConfig",
agentDir: homeDir,
fs: map[string]string{
"/tmp/.gitignore": "node_modules/",
filepath.Join(homeDir, ".gitconfig"): `
[core]
excludesFile = /tmp/.gitignore
`,
filepath.Join(homeDir, ".git/HEAD"): "",
filepath.Join(homeDir, ".devcontainer.json"): "",
filepath.Join(homeDir, "node_modules/y/.devcontainer.json"): "",
},
expected: []codersdk.WorkspaceAgentDevcontainer{
{
WorkspaceFolder: homeDir,
ConfigPath: filepath.Join(homeDir, ".devcontainer.json"),
Status: codersdk.WorkspaceAgentDevcontainerStatusStopped,
},
},
},
{
name: "IgnoreNonsenseDevcontainerNames",
agentDir: "/home/coder",
fs: map[string]string{
"/home/coder/.git/HEAD": "",
"/home/coder/.devcontainer/devcontainer.json.bak": "",
"/home/coder/.devcontainer/devcontainer.json.old": "",
"/home/coder/.devcontainer/devcontainer.json~": "",
"/home/coder/.devcontainer/notdevcontainer.json": "",
"/home/coder/.devcontainer/devcontainer.json.swp": "",
"/home/coder/foo/.devcontainer.json.bak": "",
"/home/coder/foo/.devcontainer.json.old": "",
"/home/coder/foo/.devcontainer.json~": "",
"/home/coder/foo/.notdevcontainer.json": "",
"/home/coder/foo/.devcontainer.json.swp": "",
"/home/coder/bar/.devcontainer.json": "",
},
expected: []codersdk.WorkspaceAgentDevcontainer{
{
WorkspaceFolder: "/home/coder/bar",
ConfigPath: "/home/coder/bar/.devcontainer.json",
Status: codersdk.WorkspaceAgentDevcontainerStatusStopped,
},
},
},
}
initFS := func(t *testing.T, files map[string]string) afero.Fs {
t.Helper()
fs := afero.NewMemMapFs()
for name, content := range files {
err := afero.WriteFile(fs, name, []byte(content+"\n"), 0o600)
require.NoError(t, err)
}
return fs
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
var (
ctx = testutil.Context(t, testutil.WaitShort)
logger = testutil.Logger(t)
mClock = quartz.NewMock(t)
tickerTrap = mClock.Trap().TickerFunc("updaterLoop")
r = chi.NewRouter()
)
api := agentcontainers.NewAPI(logger,
agentcontainers.WithClock(mClock),
agentcontainers.WithWatcher(watcher.NewNoop()),
agentcontainers.WithFileSystem(initFS(t, tt.fs)),
agentcontainers.WithManifestInfo("owner", "workspace", "parent-agent", tt.agentDir),
agentcontainers.WithContainerCLI(&fakeContainerCLI{}),
agentcontainers.WithDevcontainerCLI(&fakeDevcontainerCLI{}),
agentcontainers.WithProjectDiscovery(true),
)
api.Start()
defer api.Close()
r.Mount("/", api.Routes())
tickerTrap.MustWait(ctx).MustRelease(ctx)
tickerTrap.Close()
// Wait until all projects have been discovered
require.Eventuallyf(t, func() bool {
req := httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
got := codersdk.WorkspaceAgentListContainersResponse{}
err := json.NewDecoder(rec.Body).Decode(&got)
require.NoError(t, err)
return len(got.Devcontainers) >= len(tt.expected)
}, testutil.WaitShort, testutil.IntervalFast, "dev containers never found")
// Now projects have been discovered, we'll allow the updater loop
// to set the appropriate status for these containers.
_, aw := mClock.AdvanceNext()
aw.MustWait(ctx)
// Now we'll fetch the list of dev containers
req := httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
got := codersdk.WorkspaceAgentListContainersResponse{}
err := json.NewDecoder(rec.Body).Decode(&got)
require.NoError(t, err)
// We will set the IDs of each dev container to uuid.Nil to simplify
// this check.
for idx := range got.Devcontainers {
got.Devcontainers[idx].ID = uuid.Nil
}
// Sort the expected dev containers and got dev containers by their workspace folder.
// This helps ensure a deterministic test.
slices.SortFunc(tt.expected, func(a, b codersdk.WorkspaceAgentDevcontainer) int {
return strings.Compare(a.WorkspaceFolder, b.WorkspaceFolder)
})
slices.SortFunc(got.Devcontainers, func(a, b codersdk.WorkspaceAgentDevcontainer) int {
return strings.Compare(a.WorkspaceFolder, b.WorkspaceFolder)
})
require.Equal(t, tt.expected, got.Devcontainers)
})
}
t.Run("NoErrorWhenAgentDirAbsent", func(t *testing.T) {
t.Parallel()
logger := testutil.Logger(t)
// Given: We have an empty agent directory
agentDir := ""
api := agentcontainers.NewAPI(logger,
agentcontainers.WithWatcher(watcher.NewNoop()),
agentcontainers.WithManifestInfo("owner", "workspace", "parent-agent", agentDir),
agentcontainers.WithContainerCLI(&fakeContainerCLI{}),
agentcontainers.WithDevcontainerCLI(&fakeDevcontainerCLI{}),
agentcontainers.WithProjectDiscovery(true),
)
// When: We start and close the API
api.Start()
api.Close()
// Then: We expect there to have been no errors.
// This is implicitly handled by `testutil.Logger` failing when it
// detects an error has been logged.
})
t.Run("AutoStart", func(t *testing.T) {
t.Parallel()
tests := []struct {
name string
agentDir string
fs map[string]string
configMap map[string]agentcontainers.DevcontainerConfig
expectDevcontainerCount int
expectUpCalledCount int
}{
{
name: "SingleEnabled",
agentDir: "/home/coder",
expectDevcontainerCount: 1,
expectUpCalledCount: 1,
fs: map[string]string{
"/home/coder/.git/HEAD": "",
"/home/coder/.devcontainer/devcontainer.json": "",
},
configMap: map[string]agentcontainers.DevcontainerConfig{
"/home/coder/.devcontainer/devcontainer.json": {
Configuration: agentcontainers.DevcontainerConfiguration{
Customizations: agentcontainers.DevcontainerCustomizations{
Coder: agentcontainers.CoderCustomization{
AutoStart: true,
},
},
},
},
},
},
{
name: "SingleDisabled",
agentDir: "/home/coder",
expectDevcontainerCount: 1,
expectUpCalledCount: 0,
fs: map[string]string{
"/home/coder/.git/HEAD": "",
"/home/coder/.devcontainer/devcontainer.json": "",
},
configMap: map[string]agentcontainers.DevcontainerConfig{
"/home/coder/.devcontainer/devcontainer.json": {
Configuration: agentcontainers.DevcontainerConfiguration{
Customizations: agentcontainers.DevcontainerCustomizations{
Coder: agentcontainers.CoderCustomization{
AutoStart: false,
},
},
},
},
},
},
{
name: "OneEnabledOneDisabled",
agentDir: "/home/coder",
expectDevcontainerCount: 2,
expectUpCalledCount: 1,
fs: map[string]string{
"/home/coder/.git/HEAD": "",
"/home/coder/.devcontainer/devcontainer.json": "",
"/home/coder/project/.devcontainer.json": "",
},
configMap: map[string]agentcontainers.DevcontainerConfig{
"/home/coder/.devcontainer/devcontainer.json": {
Configuration: agentcontainers.DevcontainerConfiguration{
Customizations: agentcontainers.DevcontainerCustomizations{
Coder: agentcontainers.CoderCustomization{
AutoStart: true,
},
},
},
},
"/home/coder/project/.devcontainer.json": {
Configuration: agentcontainers.DevcontainerConfiguration{
Customizations: agentcontainers.DevcontainerCustomizations{
Coder: agentcontainers.CoderCustomization{
AutoStart: false,
},
},
},
},
},
},
{
name: "MultipleEnabled",
agentDir: "/home/coder",
expectDevcontainerCount: 2,
expectUpCalledCount: 2,
fs: map[string]string{
"/home/coder/.git/HEAD": "",
"/home/coder/.devcontainer/devcontainer.json": "",
"/home/coder/project/.devcontainer.json": "",
},
configMap: map[string]agentcontainers.DevcontainerConfig{
"/home/coder/.devcontainer/devcontainer.json": {
Configuration: agentcontainers.DevcontainerConfiguration{
Customizations: agentcontainers.DevcontainerCustomizations{
Coder: agentcontainers.CoderCustomization{
AutoStart: true,
},
},
},
},
"/home/coder/project/.devcontainer.json": {
Configuration: agentcontainers.DevcontainerConfiguration{
Customizations: agentcontainers.DevcontainerCustomizations{
Coder: agentcontainers.CoderCustomization{
AutoStart: true,
},
},
},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
var (
ctx = testutil.Context(t, testutil.WaitShort)
logger = testutil.Logger(t)
mClock = quartz.NewMock(t)
upCalledMu sync.Mutex
upCalledFor = map[string]bool{}
fCCLI = &fakeContainerCLI{}
fDCCLI = &fakeDevcontainerCLI{
configMap: tt.configMap,
up: func(_, configPath string) (string, error) {
upCalledMu.Lock()
upCalledFor[configPath] = true
upCalledMu.Unlock()
return "", nil
},
}
r = chi.NewRouter()
)
api := agentcontainers.NewAPI(logger,
agentcontainers.WithClock(mClock),
agentcontainers.WithWatcher(watcher.NewNoop()),
agentcontainers.WithFileSystem(initFS(t, tt.fs)),
agentcontainers.WithManifestInfo("owner", "workspace", "parent-agent", "/home/coder"),
agentcontainers.WithContainerCLI(fCCLI),
agentcontainers.WithDevcontainerCLI(fDCCLI),
agentcontainers.WithProjectDiscovery(true),
agentcontainers.WithDiscoveryAutostart(true),
)
api.Start()
r.Mount("/", api.Routes())
// Given: We allow the discover routing to progress
var got codersdk.WorkspaceAgentListContainersResponse
require.Eventuallyf(t, func() bool {
req := httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
got = codersdk.WorkspaceAgentListContainersResponse{}
err := json.NewDecoder(rec.Body).Decode(&got)
require.NoError(t, err)
upCalledMu.Lock()
upCalledCount := len(upCalledFor)
upCalledMu.Unlock()
return len(got.Devcontainers) >= tt.expectDevcontainerCount && upCalledCount >= tt.expectUpCalledCount
}, testutil.WaitShort, testutil.IntervalFast, "dev containers never found")
// Close the API. We expect this not to fail because we should have finished
// at this point.
err := api.Close()
require.NoError(t, err)
// Then: We expect to find the expected devcontainers
assert.Len(t, got.Devcontainers, tt.expectDevcontainerCount)
// And: We expect `up` to have been called the expected amount of times.
assert.Len(t, upCalledFor, tt.expectUpCalledCount)
// And: `up` was called on the correct containers
for configPath, config := range tt.configMap {
autoStart := config.Configuration.Customizations.Coder.AutoStart
wasUpCalled := upCalledFor[configPath]
require.Equal(t, autoStart, wasUpCalled)
}
})
}
t.Run("Disabled", func(t *testing.T) {
t.Parallel()
var (
ctx = testutil.Context(t, testutil.WaitShort)
logger = testutil.Logger(t)
mClock = quartz.NewMock(t)
mDCCLI = acmock.NewMockDevcontainerCLI(gomock.NewController(t))
fs = map[string]string{
"/home/coder/.git/HEAD": "",
"/home/coder/.devcontainer/devcontainer.json": "",
}
r = chi.NewRouter()
)
// We expect that neither `ReadConfig`, nor `Up` are called as we
// have explicitly disabled the agentcontainers API from attempting
// to autostart devcontainers that it discovers.
mDCCLI.EXPECT().ReadConfig(gomock.Any(),
"/home/coder",
"/home/coder/.devcontainer/devcontainer.json",
[]string{},
).Return(agentcontainers.DevcontainerConfig{
Configuration: agentcontainers.DevcontainerConfiguration{
Customizations: agentcontainers.DevcontainerCustomizations{
Coder: agentcontainers.CoderCustomization{
AutoStart: true,
},
},
},
}, nil).Times(0)
mDCCLI.EXPECT().Up(gomock.Any(),
"/home/coder",
"/home/coder/.devcontainer/devcontainer.json",
gomock.Any(),
).Return("", nil).Times(0)
api := agentcontainers.NewAPI(logger,
agentcontainers.WithClock(mClock),
agentcontainers.WithWatcher(watcher.NewNoop()),
agentcontainers.WithFileSystem(initFS(t, fs)),
agentcontainers.WithManifestInfo("owner", "workspace", "parent-agent", "/home/coder"),
agentcontainers.WithContainerCLI(&fakeContainerCLI{}),
agentcontainers.WithDevcontainerCLI(mDCCLI),
agentcontainers.WithProjectDiscovery(true),
agentcontainers.WithDiscoveryAutostart(false),
)
api.Start()
defer api.Close()
r.Mount("/", api.Routes())
// When: All expected dev containers have been found.
require.Eventuallyf(t, func() bool {
req := httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
got := codersdk.WorkspaceAgentListContainersResponse{}
err := json.NewDecoder(rec.Body).Decode(&got)
require.NoError(t, err)
return len(got.Devcontainers) >= 1
}, testutil.WaitShort, testutil.IntervalFast, "dev containers never found")
// Then: We expect the mock infra to not fail.
})
})
}
// TestDevcontainerPrebuildSupport validates that devcontainers survive the transition
// from prebuild to claimed workspace, ensuring the existing container is reused
// with updated configuration rather than being recreated.
func TestDevcontainerPrebuildSupport(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" {
t.Skip("Dev Container tests are not supported on Windows")
}
var (
ctx = testutil.Context(t, testutil.WaitShort)
logger = testutil.Logger(t)
fDCCLI = &fakeDevcontainerCLI{readConfigErrC: make(chan func(envs []string) error, 1)}
fCCLI = &fakeContainerCLI{arch: runtime.GOARCH}
fSAC = &fakeSubAgentClient{}
testDC = codersdk.WorkspaceAgentDevcontainer{
ID: uuid.New(),
WorkspaceFolder: "/home/coder/coder",
ConfigPath: "/home/coder/coder/.devcontainer/devcontainer.json",
}
testContainer = newFakeContainer("test-container-id", testDC.ConfigPath, testDC.WorkspaceFolder)
prebuildOwner = "prebuilds"
prebuildWorkspace = "prebuilds-xyz-123"
prebuildAppURL = "prebuilds.zed"
userOwner = "user"
userWorkspace = "user-workspace"
userAppURL = "user.zed"
)
// ==================================================
// PHASE 1: Prebuild workspace creates devcontainer
// ==================================================
// Given: There are no containers initially.
fCCLI.containers = codersdk.WorkspaceAgentListContainersResponse{}
api := agentcontainers.NewAPI(logger,
// We want this first `agentcontainers.API` to have a manifest info
// that is consistent with what a prebuild workspace would have.
agentcontainers.WithManifestInfo(prebuildOwner, prebuildWorkspace, "dev", "/home/coder"),
// Given: We start with a single dev container resource.
agentcontainers.WithDevcontainers(
[]codersdk.WorkspaceAgentDevcontainer{testDC},
[]codersdk.WorkspaceAgentScript{{ID: testDC.ID, LogSourceID: uuid.New()}},
),
agentcontainers.WithSubAgentClient(fSAC),
agentcontainers.WithContainerCLI(fCCLI),
agentcontainers.WithDevcontainerCLI(fDCCLI),
agentcontainers.WithWatcher(watcher.NewNoop()),
)
api.Start()
fCCLI.mu.Lock()
fCCLI.containers = codersdk.WorkspaceAgentListContainersResponse{
Containers: []codersdk.WorkspaceAgentContainer{testContainer},
}
fCCLI.mu.Unlock()
// Given: We allow the dev container to be created.
fDCCLI.upID = testContainer.ID
fDCCLI.readConfig = agentcontainers.DevcontainerConfig{
MergedConfiguration: agentcontainers.DevcontainerMergedConfiguration{
Customizations: agentcontainers.DevcontainerMergedCustomizations{
Coder: []agentcontainers.CoderCustomization{{
Apps: []agentcontainers.SubAgentApp{
{Slug: "zed", URL: prebuildAppURL},
},
}},
},
},
}
var readConfigEnvVars []string
testutil.RequireSend(ctx, t, fDCCLI.readConfigErrC, func(env []string) error {
readConfigEnvVars = env
return nil
})
// When: We create the dev container resource
err := api.CreateDevcontainer(testDC.WorkspaceFolder, testDC.ConfigPath)
require.NoError(t, err)
require.Contains(t, readConfigEnvVars, "CODER_WORKSPACE_OWNER_NAME="+prebuildOwner)
require.Contains(t, readConfigEnvVars, "CODER_WORKSPACE_NAME="+prebuildWorkspace)
// Then: We there to be only 1 agent.
require.Len(t, fSAC.agents, 1)
// And: We expect only 1 agent to have been created.
require.Len(t, fSAC.created, 1)
firstAgent := fSAC.created[0]
// And: We expect this agent to be the current agent.
_, found := fSAC.agents[firstAgent.ID]
require.True(t, found, "first agent expected to be current agent")
// And: We expect there to be a single app.
require.Len(t, firstAgent.Apps, 1)
firstApp := firstAgent.Apps[0]
// And: We expect this app to have the pre-claim URL.
require.Equal(t, prebuildAppURL, firstApp.URL)
// Given: We now close the API
api.Close()
// =============================================================
// PHASE 2: User claims workspace, devcontainer should be reused
// =============================================================
// Given: We create a new claimed API
api = agentcontainers.NewAPI(logger,
// We want this second `agentcontainers.API` to have a manifest info
// that is consistent with what a claimed workspace would have.
agentcontainers.WithManifestInfo(userOwner, userWorkspace, "dev", "/home/coder"),
// Given: We start with a single dev container resource.
agentcontainers.WithDevcontainers(
[]codersdk.WorkspaceAgentDevcontainer{testDC},
[]codersdk.WorkspaceAgentScript{{ID: testDC.ID, LogSourceID: uuid.New()}},
),
agentcontainers.WithSubAgentClient(fSAC),
agentcontainers.WithContainerCLI(fCCLI),
agentcontainers.WithDevcontainerCLI(fDCCLI),
agentcontainers.WithWatcher(watcher.NewNoop()),
)
api.Start()
defer func() {
close(fDCCLI.readConfigErrC)
api.Close()
}()
// Given: We allow the dev container to be created.
fDCCLI.upID = testContainer.ID
fDCCLI.readConfig = agentcontainers.DevcontainerConfig{
MergedConfiguration: agentcontainers.DevcontainerMergedConfiguration{
Customizations: agentcontainers.DevcontainerMergedCustomizations{
Coder: []agentcontainers.CoderCustomization{{
Apps: []agentcontainers.SubAgentApp{
{Slug: "zed", URL: userAppURL},
},
}},
},
},
}
testutil.RequireSend(ctx, t, fDCCLI.readConfigErrC, func(env []string) error {
readConfigEnvVars = env
return nil
})
// When: We create the dev container resource.
err = api.CreateDevcontainer(testDC.WorkspaceFolder, testDC.ConfigPath)
require.NoError(t, err)
// Then: We expect the environment variables were passed correctly.
require.Contains(t, readConfigEnvVars, "CODER_WORKSPACE_OWNER_NAME="+userOwner)
require.Contains(t, readConfigEnvVars, "CODER_WORKSPACE_NAME="+userWorkspace)
// And: We expect there to be only 1 agent.
require.Len(t, fSAC.agents, 1)
// And: We expect _a separate agent_ to have been created.
require.Len(t, fSAC.created, 2)
secondAgent := fSAC.created[1]
// And: We expect this new agent to be the current agent.
_, found = fSAC.agents[secondAgent.ID]
require.True(t, found, "second agent expected to be current agent")
// And: We expect there to be a single app.
require.Len(t, secondAgent.Apps, 1)
secondApp := secondAgent.Apps[0]
// And: We expect this app to have the post-claim URL.
require.Equal(t, userAppURL, secondApp.URL)
}