mirror of
https://github.com/coder/coder.git
synced 2025-07-12 00:14:10 +00:00
feat(agent/agentcontainers): add devcontainers list endpoint (#17389)
This change allows listing both predefined and runtime-detected devcontainers, as well as showing whether or not the devcontainer is running and which container represents it. Fixes coder/internal#478
This commit is contained in:
committed by
GitHub
parent
c8c4de5f7a
commit
00b5f56734
@ -3,11 +3,15 @@ package agentcontainers
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"path"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/google/uuid"
|
||||||
"golang.org/x/xerrors"
|
"golang.org/x/xerrors"
|
||||||
|
|
||||||
"cdr.dev/slog"
|
"cdr.dev/slog"
|
||||||
@ -31,11 +35,13 @@ type API struct {
|
|||||||
dccli DevcontainerCLI
|
dccli DevcontainerCLI
|
||||||
clock quartz.Clock
|
clock quartz.Clock
|
||||||
|
|
||||||
// lockCh protects the below fields. We use a channel instead of a mutex so we
|
// lockCh protects the below fields. We use a channel instead of a
|
||||||
// can handle cancellation properly.
|
// mutex so we can handle cancellation properly.
|
||||||
lockCh chan struct{}
|
lockCh chan struct{}
|
||||||
containers codersdk.WorkspaceAgentListContainersResponse
|
containers codersdk.WorkspaceAgentListContainersResponse
|
||||||
mtime time.Time
|
mtime time.Time
|
||||||
|
devcontainerNames map[string]struct{} // Track devcontainer names to avoid duplicates.
|
||||||
|
knownDevcontainers []codersdk.WorkspaceAgentDevcontainer // Track predefined and runtime-detected devcontainers.
|
||||||
}
|
}
|
||||||
|
|
||||||
// Option is a functional option for API.
|
// Option is a functional option for API.
|
||||||
@ -55,12 +61,29 @@ func WithDevcontainerCLI(dccli DevcontainerCLI) Option {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithDevcontainers sets the known devcontainers for the API. This
|
||||||
|
// allows the API to be aware of devcontainers defined in the workspace
|
||||||
|
// agent manifest.
|
||||||
|
func WithDevcontainers(devcontainers []codersdk.WorkspaceAgentDevcontainer) Option {
|
||||||
|
return func(api *API) {
|
||||||
|
if len(devcontainers) > 0 {
|
||||||
|
api.knownDevcontainers = slices.Clone(devcontainers)
|
||||||
|
api.devcontainerNames = make(map[string]struct{}, len(devcontainers))
|
||||||
|
for _, devcontainer := range devcontainers {
|
||||||
|
api.devcontainerNames[devcontainer.Name] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// NewAPI returns a new API with the given options applied.
|
// NewAPI returns a new API with the given options applied.
|
||||||
func NewAPI(logger slog.Logger, options ...Option) *API {
|
func NewAPI(logger slog.Logger, options ...Option) *API {
|
||||||
api := &API{
|
api := &API{
|
||||||
clock: quartz.NewReal(),
|
clock: quartz.NewReal(),
|
||||||
cacheDuration: defaultGetContainersCacheDuration,
|
cacheDuration: defaultGetContainersCacheDuration,
|
||||||
lockCh: make(chan struct{}, 1),
|
lockCh: make(chan struct{}, 1),
|
||||||
|
devcontainerNames: make(map[string]struct{}),
|
||||||
|
knownDevcontainers: []codersdk.WorkspaceAgentDevcontainer{},
|
||||||
}
|
}
|
||||||
for _, opt := range options {
|
for _, opt := range options {
|
||||||
opt(api)
|
opt(api)
|
||||||
@ -79,6 +102,7 @@ func NewAPI(logger slog.Logger, options ...Option) *API {
|
|||||||
func (api *API) Routes() http.Handler {
|
func (api *API) Routes() http.Handler {
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
r.Get("/", api.handleList)
|
r.Get("/", api.handleList)
|
||||||
|
r.Get("/devcontainers", api.handleListDevcontainers)
|
||||||
r.Post("/{id}/recreate", api.handleRecreate)
|
r.Post("/{id}/recreate", api.handleRecreate)
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
@ -121,12 +145,11 @@ func (api *API) getContainers(ctx context.Context) (codersdk.WorkspaceAgentListC
|
|||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return codersdk.WorkspaceAgentListContainersResponse{}, ctx.Err()
|
return codersdk.WorkspaceAgentListContainersResponse{}, ctx.Err()
|
||||||
default:
|
case api.lockCh <- struct{}{}:
|
||||||
api.lockCh <- struct{}{}
|
|
||||||
}
|
|
||||||
defer func() {
|
defer func() {
|
||||||
<-api.lockCh
|
<-api.lockCh
|
||||||
}()
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
now := api.clock.Now()
|
now := api.clock.Now()
|
||||||
if now.Sub(api.mtime) < api.cacheDuration {
|
if now.Sub(api.mtime) < api.cacheDuration {
|
||||||
@ -142,6 +165,53 @@ func (api *API) getContainers(ctx context.Context) (codersdk.WorkspaceAgentListC
|
|||||||
api.containers = updated
|
api.containers = updated
|
||||||
api.mtime = now
|
api.mtime = now
|
||||||
|
|
||||||
|
// Reset all known devcontainers to not running.
|
||||||
|
for i := range api.knownDevcontainers {
|
||||||
|
api.knownDevcontainers[i].Running = false
|
||||||
|
api.knownDevcontainers[i].Container = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the container is running and update the known devcontainers.
|
||||||
|
for _, container := range updated.Containers {
|
||||||
|
workspaceFolder := container.Labels[DevcontainerLocalFolderLabel]
|
||||||
|
if workspaceFolder != "" {
|
||||||
|
// Check if this is already in our known list.
|
||||||
|
if knownIndex := slices.IndexFunc(api.knownDevcontainers, func(dc codersdk.WorkspaceAgentDevcontainer) bool {
|
||||||
|
return dc.WorkspaceFolder == workspaceFolder
|
||||||
|
}); knownIndex != -1 {
|
||||||
|
// Update existing entry with runtime information.
|
||||||
|
if api.knownDevcontainers[knownIndex].ConfigPath == "" {
|
||||||
|
api.knownDevcontainers[knownIndex].ConfigPath = container.Labels[DevcontainerConfigFileLabel]
|
||||||
|
}
|
||||||
|
api.knownDevcontainers[knownIndex].Running = container.Running
|
||||||
|
api.knownDevcontainers[knownIndex].Container = &container
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not in our known list, add as a runtime detected entry.
|
||||||
|
name := path.Base(workspaceFolder)
|
||||||
|
if _, ok := api.devcontainerNames[name]; ok {
|
||||||
|
// Try to find a unique name by appending a number.
|
||||||
|
for i := 2; ; i++ {
|
||||||
|
newName := fmt.Sprintf("%s-%d", name, i)
|
||||||
|
if _, ok := api.devcontainerNames[newName]; !ok {
|
||||||
|
name = newName
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
api.devcontainerNames[name] = struct{}{}
|
||||||
|
api.knownDevcontainers = append(api.knownDevcontainers, codersdk.WorkspaceAgentDevcontainer{
|
||||||
|
ID: uuid.New(),
|
||||||
|
Name: name,
|
||||||
|
WorkspaceFolder: workspaceFolder,
|
||||||
|
ConfigPath: container.Labels[DevcontainerConfigFileLabel],
|
||||||
|
Running: container.Running,
|
||||||
|
Container: &container,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return copyListContainersResponse(api.containers), nil
|
return copyListContainersResponse(api.containers), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -158,7 +228,7 @@ func (api *API) handleRecreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
containers, err := api.cl.List(ctx)
|
containers, err := api.getContainers(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{
|
httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{
|
||||||
Message: "Could not list containers",
|
Message: "Could not list containers",
|
||||||
@ -203,3 +273,39 @@ func (api *API) handleRecreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleListDevcontainers handles the HTTP request to list known devcontainers.
|
||||||
|
func (api *API) handleListDevcontainers(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
// Run getContainers to detect the latest devcontainers and their state.
|
||||||
|
_, err := api.getContainers(ctx)
|
||||||
|
if err != nil {
|
||||||
|
httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{
|
||||||
|
Message: "Could not list containers",
|
||||||
|
Detail: err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case api.lockCh <- struct{}{}:
|
||||||
|
}
|
||||||
|
devcontainers := slices.Clone(api.knownDevcontainers)
|
||||||
|
<-api.lockCh
|
||||||
|
|
||||||
|
slices.SortFunc(devcontainers, func(a, b codersdk.WorkspaceAgentDevcontainer) int {
|
||||||
|
if cmp := strings.Compare(a.WorkspaceFolder, b.WorkspaceFolder); cmp != 0 {
|
||||||
|
return cmp
|
||||||
|
}
|
||||||
|
return strings.Compare(a.ConfigPath, b.ConfigPath)
|
||||||
|
})
|
||||||
|
|
||||||
|
response := codersdk.WorkspaceAgentDevcontainersResponse{
|
||||||
|
Devcontainers: devcontainers,
|
||||||
|
}
|
||||||
|
|
||||||
|
httpapi.Write(ctx, w, http.StatusOK, response)
|
||||||
|
}
|
||||||
|
@ -2,11 +2,13 @@ package agentcontainers_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/google/uuid"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"golang.org/x/xerrors"
|
"golang.org/x/xerrors"
|
||||||
@ -151,10 +153,10 @@ func TestAPI(t *testing.T) {
|
|||||||
agentcontainers.WithLister(tt.lister),
|
agentcontainers.WithLister(tt.lister),
|
||||||
agentcontainers.WithDevcontainerCLI(tt.devcontainerCLI),
|
agentcontainers.WithDevcontainerCLI(tt.devcontainerCLI),
|
||||||
)
|
)
|
||||||
r.Mount("/containers", api.Routes())
|
r.Mount("/", api.Routes())
|
||||||
|
|
||||||
// Simulate HTTP request to the recreate endpoint.
|
// Simulate HTTP request to the recreate endpoint.
|
||||||
req := httptest.NewRequest(http.MethodPost, "/containers/"+tt.containerID+"/recreate", nil)
|
req := httptest.NewRequest(http.MethodPost, "/"+tt.containerID+"/recreate", nil)
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
r.ServeHTTP(rec, req)
|
r.ServeHTTP(rec, req)
|
||||||
|
|
||||||
@ -168,4 +170,338 @@ func TestAPI(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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.False(t, dc.Running, "devcontainer should not be running")
|
||||||
|
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.True(t, dc.Running)
|
||||||
|
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.True(t, known1.Running)
|
||||||
|
assert.False(t, known2.Running)
|
||||||
|
assert.True(t, runtime1.Running)
|
||||||
|
|
||||||
|
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.True(t, running.Running)
|
||||||
|
assert.False(t, nonRunning.Running)
|
||||||
|
|
||||||
|
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.True(t, dc2.Running)
|
||||||
|
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, nil).Leveled(slog.LevelDebug)
|
||||||
|
|
||||||
|
// Setup router with the handler under test.
|
||||||
|
r := chi.NewRouter()
|
||||||
|
apiOptions := []agentcontainers.Option{
|
||||||
|
agentcontainers.WithLister(tt.lister),
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(tt.knownDevcontainers) > 0 {
|
||||||
|
apiOptions = append(apiOptions, agentcontainers.WithDevcontainers(tt.knownDevcontainers))
|
||||||
|
}
|
||||||
|
|
||||||
|
api := agentcontainers.NewAPI(logger, apiOptions...)
|
||||||
|
r.Mount("/", api.Routes())
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/devcontainers", nil)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
}
|
}
|
||||||
|
13
agent/api.go
13
agent/api.go
@ -37,10 +37,19 @@ func (a *agent) apiHandler() http.Handler {
|
|||||||
cacheDuration: cacheDuration,
|
cacheDuration: cacheDuration,
|
||||||
}
|
}
|
||||||
|
|
||||||
containerAPI := agentcontainers.NewAPI(
|
containerAPIOpts := []agentcontainers.Option{
|
||||||
a.logger.Named("containers"),
|
|
||||||
agentcontainers.WithLister(a.lister),
|
agentcontainers.WithLister(a.lister),
|
||||||
|
}
|
||||||
|
if a.experimentalDevcontainersEnabled {
|
||||||
|
manifest := a.manifest.Load()
|
||||||
|
if manifest != nil && len(manifest.Devcontainers) > 0 {
|
||||||
|
containerAPIOpts = append(
|
||||||
|
containerAPIOpts,
|
||||||
|
agentcontainers.WithDevcontainers(manifest.Devcontainers),
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
containerAPI := agentcontainers.NewAPI(a.logger.Named("containers"), containerAPIOpts...)
|
||||||
|
|
||||||
promHandler := PrometheusMetricsHandler(a.prometheusRegistry, a.logger)
|
promHandler := PrometheusMetricsHandler(a.prometheusRegistry, a.logger)
|
||||||
|
|
||||||
|
@ -392,6 +392,12 @@ func (c *Client) WorkspaceAgentListeningPorts(ctx context.Context, agentID uuid.
|
|||||||
return listeningPorts, json.NewDecoder(res.Body).Decode(&listeningPorts)
|
return listeningPorts, json.NewDecoder(res.Body).Decode(&listeningPorts)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WorkspaceAgentDevcontainersResponse is the response to the devcontainers
|
||||||
|
// request.
|
||||||
|
type WorkspaceAgentDevcontainersResponse struct {
|
||||||
|
Devcontainers []WorkspaceAgentDevcontainer `json:"devcontainers"`
|
||||||
|
}
|
||||||
|
|
||||||
// WorkspaceAgentDevcontainer defines the location of a devcontainer
|
// WorkspaceAgentDevcontainer defines the location of a devcontainer
|
||||||
// configuration in a workspace that is visible to the workspace agent.
|
// configuration in a workspace that is visible to the workspace agent.
|
||||||
type WorkspaceAgentDevcontainer struct {
|
type WorkspaceAgentDevcontainer struct {
|
||||||
@ -399,6 +405,10 @@ type WorkspaceAgentDevcontainer struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
WorkspaceFolder string `json:"workspace_folder"`
|
WorkspaceFolder string `json:"workspace_folder"`
|
||||||
ConfigPath string `json:"config_path,omitempty"`
|
ConfigPath string `json:"config_path,omitempty"`
|
||||||
|
|
||||||
|
// Additional runtime fields.
|
||||||
|
Running bool `json:"running"`
|
||||||
|
Container *WorkspaceAgentContainer `json:"container,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// WorkspaceAgentContainer describes a devcontainer of some sort
|
// WorkspaceAgentContainer describes a devcontainer of some sort
|
||||||
|
7
site/src/api/typesGenerated.ts
generated
7
site/src/api/typesGenerated.ts
generated
@ -3239,6 +3239,13 @@ export interface WorkspaceAgentDevcontainer {
|
|||||||
readonly name: string;
|
readonly name: string;
|
||||||
readonly workspace_folder: string;
|
readonly workspace_folder: string;
|
||||||
readonly config_path?: string;
|
readonly config_path?: string;
|
||||||
|
readonly running: boolean;
|
||||||
|
readonly container?: WorkspaceAgentContainer;
|
||||||
|
}
|
||||||
|
|
||||||
|
// From codersdk/workspaceagents.go
|
||||||
|
export interface WorkspaceAgentDevcontainersResponse {
|
||||||
|
readonly devcontainers: readonly WorkspaceAgentDevcontainer[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// From codersdk/workspaceagents.go
|
// From codersdk/workspaceagents.go
|
||||||
|
Reference in New Issue
Block a user