Files
coder/agent/agentcontainers/api_test.go
T
Mathias Fredriksson 0731304905 feat(agent/agentcontainers): recreate devcontainers concurrently (#18042)
This change introduces a refactor of the devcontainers recreation logic
which is now handled asynchronously rather than being request scoped.
The response was consequently changed from "No Content" to "Accepted" to
reflect this.

A new `Status` field was introduced to the devcontainer struct which
replaces `Running` (bool). This reflects that the devcontainer can now
be in various states (starting, running, stopped or errored).

The status field also protects against multiple concurrent recrations,
as long as they are initiated via the API.

Updates #16424
2025-05-26 18:30:52 +03:00

1138 lines
39 KiB
Go

package agentcontainers_test
import (
"context"
"encoding/json"
"math/rand"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/fsnotify/fsnotify"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
"golang.org/x/xerrors"
"cdr.dev/slog"
"cdr.dev/slog/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/codersdk"
"github.com/coder/coder/v2/testutil"
"github.com/coder/quartz"
)
// fakeLister implements the agentcontainers.Lister interface for
// testing.
type fakeLister struct {
containers codersdk.WorkspaceAgentListContainersResponse
err error
}
func (f *fakeLister) List(_ context.Context) (codersdk.WorkspaceAgentListContainersResponse, error) {
return f.containers, f.err
}
// fakeDevcontainerCLI implements the agentcontainers.DevcontainerCLI
// interface for testing.
type fakeDevcontainerCLI struct {
id string
err error
continueUp chan struct{}
}
func (f *fakeDevcontainerCLI) Up(ctx context.Context, _, _ string, _ ...agentcontainers.DevcontainerCLIUpOptions) (string, error) {
if f.continueUp != nil {
select {
case <-ctx.Done():
return "", xerrors.New("test timeout")
case <-f.continueUp:
}
}
return f.id, f.err
}
// 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, xerrors.New("watcher closed")
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)
}
func TestAPI(t *testing.T) {
t.Parallel()
// 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.MockLister, preReq *gomock.Call)
// expected result
expected codersdk.WorkspaceAgentListContainersResponse
// expected error
expectedErr string
}{
{
name: "no initial data",
initialData: initialDataPayload{makeResponse(), nil},
setupMock: func(mcl *acmock.MockLister, 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.MockLister, 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.MockLister, 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.MockLister, 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.NewMockLister(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.WithLister(mLister),
)
defer api.Close()
r.Mount("/", api.Routes())
// Make sure the ticker function has been registered
// before advancing the clock.
tickerTrap.MustWait(ctx).Release()
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()
validContainer := codersdk.WorkspaceAgentContainer{
ID: "container-id",
FriendlyName: "container-name",
Running: true,
Labels: map[string]string{
agentcontainers.DevcontainerLocalFolderLabel: "/workspace",
agentcontainers.DevcontainerConfigFileLabel: "/workspace/.devcontainer/devcontainer.json",
},
}
missingFolderContainer := codersdk.WorkspaceAgentContainer{
ID: "missing-folder-container",
FriendlyName: "missing-folder-container",
Labels: map[string]string{},
}
tests := []struct {
name string
containerID string
lister *fakeLister
devcontainerCLI *fakeDevcontainerCLI
wantStatus []int
wantBody []string
}{
{
name: "Missing container ID",
containerID: "",
lister: &fakeLister{},
devcontainerCLI: &fakeDevcontainerCLI{},
wantStatus: []int{http.StatusBadRequest},
wantBody: []string{"Missing container ID or name"},
},
{
name: "List error",
containerID: "container-id",
lister: &fakeLister{
err: xerrors.New("list error"),
},
devcontainerCLI: &fakeDevcontainerCLI{},
wantStatus: []int{http.StatusInternalServerError},
wantBody: []string{"Could not list containers"},
},
{
name: "Container not found",
containerID: "nonexistent-container",
lister: &fakeLister{
containers: codersdk.WorkspaceAgentListContainersResponse{
Containers: []codersdk.WorkspaceAgentContainer{validContainer},
},
},
devcontainerCLI: &fakeDevcontainerCLI{},
wantStatus: []int{http.StatusNotFound},
wantBody: []string{"Container not found"},
},
{
name: "Missing workspace folder label",
containerID: "missing-folder-container",
lister: &fakeLister{
containers: codersdk.WorkspaceAgentListContainersResponse{
Containers: []codersdk.WorkspaceAgentContainer{missingFolderContainer},
},
},
devcontainerCLI: &fakeDevcontainerCLI{},
wantStatus: []int{http.StatusBadRequest},
wantBody: []string{"Missing workspace folder label"},
},
{
name: "Devcontainer CLI error",
containerID: "container-id",
lister: &fakeLister{
containers: codersdk.WorkspaceAgentListContainersResponse{
Containers: []codersdk.WorkspaceAgentContainer{validContainer},
},
},
devcontainerCLI: &fakeDevcontainerCLI{
err: xerrors.New("devcontainer CLI error"),
},
wantStatus: []int{http.StatusAccepted, http.StatusConflict},
wantBody: []string{"Devcontainer recreation initiated", "Devcontainer recreation already in progress"},
},
{
name: "OK",
containerID: "container-id",
lister: &fakeLister{
containers: codersdk.WorkspaceAgentListContainersResponse{
Containers: []codersdk.WorkspaceAgentContainer{validContainer},
},
},
devcontainerCLI: &fakeDevcontainerCLI{},
wantStatus: []int{http.StatusAccepted, http.StatusConflict},
wantBody: []string{"Devcontainer recreation initiated", "Devcontainer recreation already in progress"},
},
}
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.continueUp = make(chan struct{})
// Setup router with the handler under test.
r := chi.NewRouter()
api := agentcontainers.NewAPI(
logger,
agentcontainers.WithClock(mClock),
agentcontainers.WithLister(tt.lister),
agentcontainers.WithDevcontainerCLI(tt.devcontainerCLI),
agentcontainers.WithWatcher(watcher.NewNoop()),
)
defer api.Close()
r.Mount("/", api.Routes())
// Make sure the ticker function has been registered
// before advancing the clock.
tickerTrap.MustWait(ctx).Release()
tickerTrap.Close()
for i := range tt.wantStatus {
// Simulate HTTP request to the recreate endpoint.
req := httptest.NewRequest(http.MethodPost, "/devcontainers/container/"+tt.containerID+"/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.continueUp)
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, "/devcontainers", nil).
WithContext(ctx)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code, "status code mismatch")
var resp codersdk.WorkspaceAgentDevcontainersResponse
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")
// Allow the devcontainer CLI to continue the up process.
close(tt.devcontainerCLI.continueUp)
// Ensure the devcontainer ends up in error state if the up call fails.
if tt.devcontainerCLI.err != 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).Release()
nowRecreateErrorTrap.Close()
// Advance the clock to run the devcontainer state update routine.
_, aw = mClock.AdvanceNext()
aw.MustWait(ctx)
req = httptest.NewRequest(http.MethodGet, "/devcontainers", 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")
return
}
// Ensure the devcontainer ends up in success state.
nowRecreateSuccessTrap.MustWait(ctx).Release()
nowRecreateSuccessTrap.Close()
// Advance the clock to run the devcontainer state update routine.
_, aw = mClock.AdvanceNext()
aw.MustWait(ctx)
req = httptest.NewRequest(http.MethodGet, "/devcontainers", 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 stopped after recreation")
})
}
})
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 *fakeLister
knownDevcontainers []codersdk.WorkspaceAgentDevcontainer
wantStatus int
wantCount int
verify func(t *testing.T, devcontainers []codersdk.WorkspaceAgentDevcontainer)
}{
{
name: "List error",
lister: &fakeLister{
err: xerrors.New("list error"),
},
wantStatus: http.StatusInternalServerError,
},
{
name: "Empty containers",
lister: &fakeLister{},
wantStatus: http.StatusOK,
wantCount: 0,
},
{
name: "Only known devcontainers, no containers",
lister: &fakeLister{
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: &fakeLister{
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: &fakeLister{
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)
require.NotNil(t, known1.Container)
assert.Nil(t, known2.Container)
require.NotNil(t, runtime1.Container)
assert.Equal(t, "known-container-1", known1.Container.ID)
assert.Equal(t, "runtime-container-1", runtime1.Container.ID)
},
},
{
name: "Both running and non-running containers have container references",
lister: &fakeLister{
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")
require.NotNil(t, nonRunning.Container, "non-running container should have container reference")
assert.Equal(t, "running-container", running.Container.ID)
assert.Equal(t, "non-running-container", nonRunning.Container.ID)
},
},
{
name: "Config path update",
lister: &fakeLister{
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: &fakeLister{
containers: codersdk.WorkspaceAgentListContainersResponse{
Containers: []codersdk.WorkspaceAgentContainer{
{
ID: "project1-container",
FriendlyName: "project1-container",
Running: true,
Labels: map[string]string{
agentcontainers.DevcontainerLocalFolderLabel: "/workspace/project",
agentcontainers.DevcontainerConfigFileLabel: "/workspace/project/.devcontainer/devcontainer.json",
},
},
{
ID: "project2-container",
FriendlyName: "project2-container",
Running: true,
Labels: map[string]string{
agentcontainers.DevcontainerLocalFolderLabel: "/home/user/project",
agentcontainers.DevcontainerConfigFileLabel: "/home/user/project/.devcontainer/devcontainer.json",
},
},
{
ID: "project3-container",
FriendlyName: "project3-container",
Running: true,
Labels: map[string]string{
agentcontainers.DevcontainerLocalFolderLabel: "/var/lib/project",
agentcontainers.DevcontainerConfigFileLabel: "/var/lib/project/.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")
},
},
}
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)
// Setup router with the handler under test.
r := chi.NewRouter()
apiOptions := []agentcontainers.Option{
agentcontainers.WithLister(tt.lister),
agentcontainers.WithWatcher(watcher.NewNoop()),
}
// 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...)
defer api.Close()
r.Mount("/", api.Routes())
ctx := testutil.Context(t, testutil.WaitShort)
req := httptest.NewRequest(http.MethodGet, "/devcontainers", 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.WorkspaceAgentDevcontainersResponse
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 := &fakeLister{
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.WithLister(fLister),
agentcontainers.WithWatcher(fWatcher),
agentcontainers.WithDevcontainers(
[]codersdk.WorkspaceAgentDevcontainer{dc},
[]codersdk.WorkspaceAgentScript{{LogSourceID: uuid.New(), ID: dc.ID}},
),
)
defer api.Close()
// Make sure the ticker function has been registered
// before advancing any use of mClock.Advance.
tickerTrap.MustWait(ctx).Release()
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, "/devcontainers", nil).
WithContext(ctx)
rec := httptest.NewRecorder()
api.Routes().ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
var resp1 codersdk.WorkspaceAgentDevcontainersResponse
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, "/devcontainers", nil).
WithContext(ctx)
rec = httptest.NewRecorder()
api.Routes().ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
var resp2 codersdk.WorkspaceAgentDevcontainersResponse
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 := &fakeLister{
containers: codersdk.WorkspaceAgentListContainersResponse{
Containers: []codersdk.WorkspaceAgentContainer{container},
},
}
logger := slogtest.Make(t, nil).Leveled(slog.LevelDebug)
api := agentcontainers.NewAPI(
logger,
agentcontainers.WithLister(fLister),
agentcontainers.WithWatcher(fWatcher),
agentcontainers.WithClock(mClock),
)
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).Release()
tickerTrap.Close()
// Call the list endpoint first to ensure config files are
// detected and watched.
req := httptest.NewRequest(http.MethodGet, "/devcontainers", nil).
WithContext(ctx)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
var response codersdk.WorkspaceAgentDevcontainersResponse
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")
assert.False(t, response.Devcontainers[0].Container.DevcontainerDirty,
"container should not be marked as dirty initially")
// 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, "/devcontainers", 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")
assert.True(t, response.Devcontainers[0].Container.DevcontainerDirty,
"container should be marked as dirty after config file was modified")
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, "/devcontainers", 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")
assert.False(t, response.Devcontainers[0].Container.DevcontainerDirty,
"dirty flag should be cleared on the container after container recreation")
})
}
// 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
}
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
}