mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: show devcontainer dirty status and allow recreate (#17880)
Updates #16424
This commit is contained in:
Generated
+47
-2
@@ -1,9 +1,9 @@
|
||||
// Code generated by MockGen. DO NOT EDIT.
|
||||
// Source: .. (interfaces: Lister)
|
||||
// Source: .. (interfaces: Lister,DevcontainerCLI)
|
||||
//
|
||||
// Generated by this command:
|
||||
//
|
||||
// mockgen -destination ./acmock.go -package acmock .. Lister
|
||||
// mockgen -destination ./acmock.go -package acmock .. Lister,DevcontainerCLI
|
||||
//
|
||||
|
||||
// Package acmock is a generated GoMock package.
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
context "context"
|
||||
reflect "reflect"
|
||||
|
||||
agentcontainers "github.com/coder/coder/v2/agent/agentcontainers"
|
||||
codersdk "github.com/coder/coder/v2/codersdk"
|
||||
gomock "go.uber.org/mock/gomock"
|
||||
)
|
||||
@@ -55,3 +56,47 @@ func (mr *MockListerMockRecorder) List(ctx any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockLister)(nil).List), ctx)
|
||||
}
|
||||
|
||||
// MockDevcontainerCLI is a mock of DevcontainerCLI interface.
|
||||
type MockDevcontainerCLI struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockDevcontainerCLIMockRecorder
|
||||
isgomock struct{}
|
||||
}
|
||||
|
||||
// MockDevcontainerCLIMockRecorder is the mock recorder for MockDevcontainerCLI.
|
||||
type MockDevcontainerCLIMockRecorder struct {
|
||||
mock *MockDevcontainerCLI
|
||||
}
|
||||
|
||||
// NewMockDevcontainerCLI creates a new mock instance.
|
||||
func NewMockDevcontainerCLI(ctrl *gomock.Controller) *MockDevcontainerCLI {
|
||||
mock := &MockDevcontainerCLI{ctrl: ctrl}
|
||||
mock.recorder = &MockDevcontainerCLIMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||
func (m *MockDevcontainerCLI) EXPECT() *MockDevcontainerCLIMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// Up mocks base method.
|
||||
func (m *MockDevcontainerCLI) Up(ctx context.Context, workspaceFolder, configPath string, opts ...agentcontainers.DevcontainerCLIUpOptions) (string, error) {
|
||||
m.ctrl.T.Helper()
|
||||
varargs := []any{ctx, workspaceFolder, configPath}
|
||||
for _, a := range opts {
|
||||
varargs = append(varargs, a)
|
||||
}
|
||||
ret := m.ctrl.Call(m, "Up", varargs...)
|
||||
ret0, _ := ret[0].(string)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// Up indicates an expected call of Up.
|
||||
func (mr *MockDevcontainerCLIMockRecorder) Up(ctx, workspaceFolder, configPath any, opts ...any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
varargs := append([]any{ctx, workspaceFolder, configPath}, opts...)
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Up", reflect.TypeOf((*MockDevcontainerCLI)(nil).Up), varargs...)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Package acmock contains a mock implementation of agentcontainers.Lister for use in tests.
|
||||
package acmock
|
||||
|
||||
//go:generate mockgen -destination ./acmock.go -package acmock .. Lister
|
||||
//go:generate mockgen -destination ./acmock.go -package acmock .. Lister,DevcontainerCLI
|
||||
|
||||
@@ -69,6 +69,15 @@ func WithClock(clock quartz.Clock) Option {
|
||||
}
|
||||
}
|
||||
|
||||
// WithCacheDuration sets the cache duration for the API.
|
||||
// This is used to control how often the API refreshes the list of
|
||||
// containers. The default is 10 seconds.
|
||||
func WithCacheDuration(d time.Duration) Option {
|
||||
return func(api *API) {
|
||||
api.cacheDuration = d
|
||||
}
|
||||
}
|
||||
|
||||
// WithExecer sets the agentexec.Execer implementation to use.
|
||||
func WithExecer(execer agentexec.Execer) Option {
|
||||
return func(api *API) {
|
||||
@@ -336,7 +345,8 @@ func (api *API) getContainers(ctx context.Context) (codersdk.WorkspaceAgentListC
|
||||
}
|
||||
|
||||
// Check if the container is running and update the known devcontainers.
|
||||
for _, container := range updated.Containers {
|
||||
for i := range updated.Containers {
|
||||
container := &updated.Containers[i]
|
||||
workspaceFolder := container.Labels[DevcontainerLocalFolderLabel]
|
||||
configFile := container.Labels[DevcontainerConfigFileLabel]
|
||||
|
||||
@@ -344,6 +354,20 @@ func (api *API) getContainers(ctx context.Context) (codersdk.WorkspaceAgentListC
|
||||
continue
|
||||
}
|
||||
|
||||
container.DevcontainerDirty = dirtyStates[workspaceFolder]
|
||||
if container.DevcontainerDirty {
|
||||
lastModified, hasModTime := api.configFileModifiedTimes[configFile]
|
||||
if hasModTime && container.CreatedAt.After(lastModified) {
|
||||
api.logger.Info(ctx, "new container created after config modification, not marking as dirty",
|
||||
slog.F("container", container.ID),
|
||||
slog.F("created_at", container.CreatedAt),
|
||||
slog.F("config_modified_at", lastModified),
|
||||
slog.F("file", configFile),
|
||||
)
|
||||
container.DevcontainerDirty = false
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this is already in our known list.
|
||||
if knownIndex := slices.IndexFunc(api.knownDevcontainers, func(dc codersdk.WorkspaceAgentDevcontainer) bool {
|
||||
return dc.WorkspaceFolder == workspaceFolder
|
||||
@@ -356,7 +380,7 @@ func (api *API) getContainers(ctx context.Context) (codersdk.WorkspaceAgentListC
|
||||
}
|
||||
}
|
||||
api.knownDevcontainers[knownIndex].Running = container.Running
|
||||
api.knownDevcontainers[knownIndex].Container = &container
|
||||
api.knownDevcontainers[knownIndex].Container = container
|
||||
|
||||
// Check if this container was created after the config
|
||||
// file was modified.
|
||||
@@ -395,28 +419,14 @@ func (api *API) getContainers(ctx context.Context) (codersdk.WorkspaceAgentListC
|
||||
}
|
||||
}
|
||||
|
||||
dirty := dirtyStates[workspaceFolder]
|
||||
if dirty {
|
||||
lastModified, hasModTime := api.configFileModifiedTimes[configFile]
|
||||
if hasModTime && container.CreatedAt.After(lastModified) {
|
||||
api.logger.Info(ctx, "new container created after config modification, not marking as dirty",
|
||||
slog.F("container", container.ID),
|
||||
slog.F("created_at", container.CreatedAt),
|
||||
slog.F("config_modified_at", lastModified),
|
||||
slog.F("file", configFile),
|
||||
)
|
||||
dirty = false
|
||||
}
|
||||
}
|
||||
|
||||
api.knownDevcontainers = append(api.knownDevcontainers, codersdk.WorkspaceAgentDevcontainer{
|
||||
ID: uuid.New(),
|
||||
Name: name,
|
||||
WorkspaceFolder: workspaceFolder,
|
||||
ConfigPath: configFile,
|
||||
Running: container.Running,
|
||||
Dirty: dirty,
|
||||
Container: &container,
|
||||
Dirty: container.DevcontainerDirty,
|
||||
Container: container,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -510,6 +520,13 @@ func (api *API) handleDevcontainerRecreate(w http.ResponseWriter, r *http.Reques
|
||||
slog.F("name", api.knownDevcontainers[i].Name),
|
||||
)
|
||||
api.knownDevcontainers[i].Dirty = false
|
||||
// TODO(mafredri): This should be handled by a service that
|
||||
// updates the devcontainer state periodically and on-demand.
|
||||
api.knownDevcontainers[i].Container = nil
|
||||
// Set the modified time to the zero value to indicate that
|
||||
// the containers list must be refreshed. This will see to
|
||||
// it that the new container is re-assigned.
|
||||
api.mtime = time.Time{}
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -579,6 +596,9 @@ func (api *API) markDevcontainerDirty(configPath string, modifiedAt time.Time) {
|
||||
slog.F("modified_at", modifiedAt),
|
||||
)
|
||||
api.knownDevcontainers[i].Dirty = true
|
||||
if api.knownDevcontainers[i].Container != nil {
|
||||
api.knownDevcontainers[i].Container.DevcontainerDirty = true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
package agentcontainers
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/mock/gomock"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"cdr.dev/slog/sloggers/slogtest"
|
||||
"github.com/coder/coder/v2/agent/agentcontainers/acmock"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
"github.com/coder/quartz"
|
||||
)
|
||||
|
||||
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}
|
||||
}
|
||||
|
||||
// Each test case is called multiple times to ensure idempotency
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
// data to be stored in the handler
|
||||
cacheData codersdk.WorkspaceAgentListContainersResponse
|
||||
// duration of cache
|
||||
cacheDur time.Duration
|
||||
// relative age of the cached data
|
||||
cacheAge time.Duration
|
||||
// function to set up expectations for the mock
|
||||
setupMock func(*acmock.MockLister)
|
||||
// expected result
|
||||
expected codersdk.WorkspaceAgentListContainersResponse
|
||||
// expected error
|
||||
expectedErr string
|
||||
}{
|
||||
{
|
||||
name: "no cache",
|
||||
setupMock: func(mcl *acmock.MockLister) {
|
||||
mcl.EXPECT().List(gomock.Any()).Return(makeResponse(fakeCt), nil).AnyTimes()
|
||||
},
|
||||
expected: makeResponse(fakeCt),
|
||||
},
|
||||
{
|
||||
name: "no data",
|
||||
cacheData: makeResponse(),
|
||||
cacheAge: 2 * time.Second,
|
||||
cacheDur: time.Second,
|
||||
setupMock: func(mcl *acmock.MockLister) {
|
||||
mcl.EXPECT().List(gomock.Any()).Return(makeResponse(fakeCt), nil).AnyTimes()
|
||||
},
|
||||
expected: makeResponse(fakeCt),
|
||||
},
|
||||
{
|
||||
name: "cached data",
|
||||
cacheAge: time.Second,
|
||||
cacheData: makeResponse(fakeCt),
|
||||
cacheDur: 2 * time.Second,
|
||||
expected: makeResponse(fakeCt),
|
||||
},
|
||||
{
|
||||
name: "lister error",
|
||||
setupMock: func(mcl *acmock.MockLister) {
|
||||
mcl.EXPECT().List(gomock.Any()).Return(makeResponse(), assert.AnError).AnyTimes()
|
||||
},
|
||||
expectedErr: assert.AnError.Error(),
|
||||
},
|
||||
{
|
||||
name: "stale cache",
|
||||
cacheAge: 2 * time.Second,
|
||||
cacheData: makeResponse(fakeCt),
|
||||
cacheDur: time.Second,
|
||||
setupMock: func(mcl *acmock.MockLister) {
|
||||
mcl.EXPECT().List(gomock.Any()).Return(makeResponse(fakeCt2), nil).AnyTimes()
|
||||
},
|
||||
expected: makeResponse(fakeCt2),
|
||||
},
|
||||
} {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
var (
|
||||
ctx = testutil.Context(t, testutil.WaitShort)
|
||||
clk = quartz.NewMock(t)
|
||||
ctrl = gomock.NewController(t)
|
||||
mockLister = acmock.NewMockLister(ctrl)
|
||||
now = time.Now().UTC()
|
||||
logger = slogtest.Make(t, nil).Leveled(slog.LevelDebug)
|
||||
api = NewAPI(logger, WithLister(mockLister))
|
||||
)
|
||||
defer api.Close()
|
||||
|
||||
api.cacheDuration = tc.cacheDur
|
||||
api.clock = clk
|
||||
api.containers = tc.cacheData
|
||||
if tc.cacheAge != 0 {
|
||||
api.mtime = now.Add(-tc.cacheAge)
|
||||
}
|
||||
if tc.setupMock != nil {
|
||||
tc.setupMock(mockLister)
|
||||
}
|
||||
|
||||
clk.Set(now).MustWait(ctx)
|
||||
|
||||
// Repeat the test to ensure idempotency
|
||||
for i := 0; i < 2; i++ {
|
||||
actual, err := api.getContainers(ctx)
|
||||
if tc.expectedErr != "" {
|
||||
require.Empty(t, actual, "expected no data (attempt %d)", i)
|
||||
require.ErrorContains(t, err, tc.expectedErr, "expected error (attempt %d)", i)
|
||||
} else {
|
||||
require.NoError(t, err, "expected no error (attempt %d)", i)
|
||||
require.Equal(t, tc.expected, actual, "expected containers to be equal (attempt %d)", i)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -3,8 +3,10 @@ package agentcontainers_test
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -13,11 +15,13 @@ import (
|
||||
"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"
|
||||
@@ -146,6 +150,136 @@ func (w *fakeWatcher) sendEventWaitNextCalled(ctx context.Context, event fsnotif
|
||||
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}
|
||||
}
|
||||
|
||||
// Each test case is called multiple times to ensure idempotency
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
// data to be stored in the handler
|
||||
cacheData codersdk.WorkspaceAgentListContainersResponse
|
||||
// duration of cache
|
||||
cacheDur time.Duration
|
||||
// relative age of the cached data
|
||||
cacheAge time.Duration
|
||||
// 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 cache",
|
||||
setupMock: func(mcl *acmock.MockLister, preReq *gomock.Call) {
|
||||
mcl.EXPECT().List(gomock.Any()).Return(makeResponse(fakeCt), nil).After(preReq).AnyTimes()
|
||||
},
|
||||
expected: makeResponse(fakeCt),
|
||||
},
|
||||
{
|
||||
name: "no data",
|
||||
cacheData: makeResponse(),
|
||||
cacheAge: 2 * time.Second,
|
||||
cacheDur: time.Second,
|
||||
setupMock: func(mcl *acmock.MockLister, preReq *gomock.Call) {
|
||||
mcl.EXPECT().List(gomock.Any()).Return(makeResponse(fakeCt), nil).After(preReq).AnyTimes()
|
||||
},
|
||||
expected: makeResponse(fakeCt),
|
||||
},
|
||||
{
|
||||
name: "cached data",
|
||||
cacheAge: time.Second,
|
||||
cacheData: makeResponse(fakeCt),
|
||||
cacheDur: 2 * time.Second,
|
||||
expected: makeResponse(fakeCt),
|
||||
},
|
||||
{
|
||||
name: "lister error",
|
||||
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: "stale cache",
|
||||
cacheAge: 2 * time.Second,
|
||||
cacheData: makeResponse(fakeCt),
|
||||
cacheDur: time.Second,
|
||||
setupMock: func(mcl *acmock.MockLister, preReq *gomock.Call) {
|
||||
mcl.EXPECT().List(gomock.Any()).Return(makeResponse(fakeCt2), nil).After(preReq).AnyTimes()
|
||||
},
|
||||
expected: makeResponse(fakeCt2),
|
||||
},
|
||||
} {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
var (
|
||||
ctx = testutil.Context(t, testutil.WaitShort)
|
||||
clk = quartz.NewMock(t)
|
||||
ctrl = gomock.NewController(t)
|
||||
mockLister = acmock.NewMockLister(ctrl)
|
||||
now = time.Now().UTC()
|
||||
logger = slogtest.Make(t, nil).Leveled(slog.LevelDebug)
|
||||
r = chi.NewRouter()
|
||||
api = agentcontainers.NewAPI(logger,
|
||||
agentcontainers.WithCacheDuration(tc.cacheDur),
|
||||
agentcontainers.WithClock(clk),
|
||||
agentcontainers.WithLister(mockLister),
|
||||
)
|
||||
)
|
||||
defer api.Close()
|
||||
|
||||
r.Mount("/", api.Routes())
|
||||
|
||||
preReq := mockLister.EXPECT().List(gomock.Any()).Return(tc.cacheData, nil).Times(1)
|
||||
if tc.setupMock != nil {
|
||||
tc.setupMock(mockLister, preReq)
|
||||
}
|
||||
|
||||
if tc.cacheAge != 0 {
|
||||
clk.Set(now.Add(-tc.cacheAge)).MustWait(ctx)
|
||||
} else {
|
||||
clk.Set(now).MustWait(ctx)
|
||||
}
|
||||
|
||||
// Prime the cache with the initial data.
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
clk.Set(now).MustWait(ctx)
|
||||
|
||||
// Repeat the test to ensure idempotency
|
||||
for i := 0; i < 2; i++ {
|
||||
req = httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
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, "expected error (attempt %d)", i)
|
||||
} else {
|
||||
var got codersdk.WorkspaceAgentListContainersResponse
|
||||
err := json.NewDecoder(rec.Body).Decode(&got)
|
||||
require.NoError(t, err, "unmarshal response failed")
|
||||
require.Equal(t, tc.expected, got, "expected containers to be equal (attempt %d)", i)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Recreate", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -660,6 +794,9 @@ func TestAPI(t *testing.T) {
|
||||
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")
|
||||
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.
|
||||
@@ -689,6 +826,9 @@ func TestAPI(t *testing.T) {
|
||||
require.Len(t, response.Devcontainers, 1)
|
||||
assert.True(t, response.Devcontainers[0].Dirty,
|
||||
"container should be marked as dirty 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")
|
||||
|
||||
mClock.Advance(time.Minute).MustWait(ctx)
|
||||
|
||||
@@ -707,7 +847,10 @@ func TestAPI(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Len(t, response.Devcontainers, 1)
|
||||
assert.False(t, response.Devcontainers[0].Dirty,
|
||||
"dirty flag should be cleared after container recreation")
|
||||
"dirty flag should be cleared on the devcontainer after container 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")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -725,3 +868,32 @@ func mustFindDevcontainerByPath(t *testing.T, devcontainers []codersdk.Workspace
|
||||
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
|
||||
}
|
||||
|
||||
Generated
+40
@@ -8606,6 +8606,42 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"/workspaceagents/{workspaceagent}/containers/devcontainers/container/{container}/recreate": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"Agents"
|
||||
],
|
||||
"summary": "Recreate devcontainer for workspace agent",
|
||||
"operationId": "recreate-devcontainer-for-workspace-agent",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"description": "Workspace agent ID",
|
||||
"name": "workspaceagent",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Container ID or name",
|
||||
"name": "container",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "No Content"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/workspaceagents/{workspaceagent}/coordinate": {
|
||||
"get": {
|
||||
"security": [
|
||||
@@ -17134,6 +17170,10 @@ const docTemplate = `{
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"devcontainer_dirty": {
|
||||
"description": "DevcontainerDirty is true if the devcontainer configuration has changed\nsince the container was created. This is used to determine if the\ncontainer needs to be rebuilt.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"id": {
|
||||
"description": "ID is the unique identifier of the container.",
|
||||
"type": "string"
|
||||
|
||||
Generated
+38
@@ -7605,6 +7605,40 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/workspaceagents/{workspaceagent}/containers/devcontainers/container/{container}/recreate": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"tags": ["Agents"],
|
||||
"summary": "Recreate devcontainer for workspace agent",
|
||||
"operationId": "recreate-devcontainer-for-workspace-agent",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"description": "Workspace agent ID",
|
||||
"name": "workspaceagent",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Container ID or name",
|
||||
"name": "container",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "No Content"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/workspaceagents/{workspaceagent}/coordinate": {
|
||||
"get": {
|
||||
"security": [
|
||||
@@ -15643,6 +15677,10 @@
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"devcontainer_dirty": {
|
||||
"description": "DevcontainerDirty is true if the devcontainer configuration has changed\nsince the container was created. This is used to determine if the\ncontainer needs to be rebuilt.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"id": {
|
||||
"description": "ID is the unique identifier of the container.",
|
||||
"type": "string"
|
||||
|
||||
@@ -1326,6 +1326,7 @@ func New(options *Options) *API {
|
||||
r.Get("/listening-ports", api.workspaceAgentListeningPorts)
|
||||
r.Get("/connection", api.workspaceAgentConnection)
|
||||
r.Get("/containers", api.workspaceAgentListContainers)
|
||||
r.Post("/containers/devcontainers/container/{container}/recreate", api.workspaceAgentRecreateDevcontainer)
|
||||
r.Get("/coordinate", api.workspaceAgentClientCoordinate)
|
||||
|
||||
// PTY is part of workspaceAppServer.
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
"github.com/sqlc-dev/pqtype"
|
||||
"golang.org/x/exp/maps"
|
||||
@@ -893,6 +894,91 @@ func (api *API) workspaceAgentListContainers(rw http.ResponseWriter, r *http.Req
|
||||
httpapi.Write(ctx, rw, http.StatusOK, cts)
|
||||
}
|
||||
|
||||
// @Summary Recreate devcontainer for workspace agent
|
||||
// @ID recreate-devcontainer-for-workspace-agent
|
||||
// @Security CoderSessionToken
|
||||
// @Tags Agents
|
||||
// @Param workspaceagent path string true "Workspace agent ID" format(uuid)
|
||||
// @Param container path string true "Container ID or name"
|
||||
// @Success 204
|
||||
// @Router /workspaceagents/{workspaceagent}/containers/devcontainers/container/{container}/recreate [post]
|
||||
func (api *API) workspaceAgentRecreateDevcontainer(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
workspaceAgent := httpmw.WorkspaceAgentParam(r)
|
||||
|
||||
container := chi.URLParam(r, "container")
|
||||
if container == "" {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Container ID or name is required.",
|
||||
Validations: []codersdk.ValidationError{
|
||||
{Field: "container", Detail: "Container ID or name is required."},
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
apiAgent, err := db2sdk.WorkspaceAgent(
|
||||
api.DERPMap(),
|
||||
*api.TailnetCoordinator.Load(),
|
||||
workspaceAgent,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
api.AgentInactiveDisconnectTimeout,
|
||||
api.DeploymentValues.AgentFallbackTroubleshootingURL.String(),
|
||||
)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error reading workspace agent.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
if apiAgent.Status != codersdk.WorkspaceAgentConnected {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: fmt.Sprintf("Agent state is %q, it must be in the %q state.", apiAgent.Status, codersdk.WorkspaceAgentConnected),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// If the agent is unreachable, the request will hang. Assume that if we
|
||||
// don't get a response after 30s that the agent is unreachable.
|
||||
dialCtx, dialCancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer dialCancel()
|
||||
agentConn, release, err := api.agentProvider.AgentConn(dialCtx, workspaceAgent.ID)
|
||||
if err != nil {
|
||||
httpapi.Write(dialCtx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error dialing workspace agent.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
defer release()
|
||||
|
||||
err = agentConn.RecreateDevcontainer(ctx, container)
|
||||
if err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
httpapi.Write(ctx, rw, http.StatusRequestTimeout, codersdk.Response{
|
||||
Message: "Failed to recreate devcontainer from agent.",
|
||||
Detail: "Request timed out.",
|
||||
})
|
||||
return
|
||||
}
|
||||
// If the agent returns a codersdk.Error, we can return that directly.
|
||||
if cerr, ok := codersdk.AsError(err); ok {
|
||||
httpapi.Write(ctx, rw, cerr.StatusCode(), cerr.Response)
|
||||
return
|
||||
}
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error recreating devcontainer.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
httpapi.Write(ctx, rw, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
// @Summary Get connection info for workspace agent
|
||||
// @ID get-connection-info-for-workspace-agent
|
||||
// @Security CoderSessionToken
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -36,6 +37,7 @@ import (
|
||||
"github.com/coder/coder/v2/agent"
|
||||
"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/agenttest"
|
||||
agentproto "github.com/coder/coder/v2/agent/proto"
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
@@ -1347,6 +1349,115 @@ func TestWorkspaceAgentContainers(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestWorkspaceAgentRecreateDevcontainer(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("Mock", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
workspaceFolder = t.TempDir()
|
||||
configFile = filepath.Join(workspaceFolder, ".devcontainer", "devcontainer.json")
|
||||
dcLabels = map[string]string{
|
||||
agentcontainers.DevcontainerLocalFolderLabel: workspaceFolder,
|
||||
agentcontainers.DevcontainerConfigFileLabel: configFile,
|
||||
}
|
||||
devContainer = codersdk.WorkspaceAgentContainer{
|
||||
ID: uuid.NewString(),
|
||||
CreatedAt: dbtime.Now(),
|
||||
FriendlyName: testutil.GetRandomName(t),
|
||||
Image: "busybox:latest",
|
||||
Labels: dcLabels,
|
||||
Running: true,
|
||||
Status: "running",
|
||||
DevcontainerDirty: true,
|
||||
}
|
||||
plainContainer = codersdk.WorkspaceAgentContainer{
|
||||
ID: uuid.NewString(),
|
||||
CreatedAt: dbtime.Now(),
|
||||
FriendlyName: testutil.GetRandomName(t),
|
||||
Image: "busybox:latest",
|
||||
Labels: map[string]string{},
|
||||
Running: true,
|
||||
Status: "running",
|
||||
}
|
||||
)
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
setupMock func(*acmock.MockLister, *acmock.MockDevcontainerCLI) (status int)
|
||||
}{
|
||||
{
|
||||
name: "Recreate",
|
||||
setupMock: func(mcl *acmock.MockLister, mdccli *acmock.MockDevcontainerCLI) int {
|
||||
mcl.EXPECT().List(gomock.Any()).Return(codersdk.WorkspaceAgentListContainersResponse{
|
||||
Containers: []codersdk.WorkspaceAgentContainer{devContainer},
|
||||
}, nil).Times(1)
|
||||
mdccli.EXPECT().Up(gomock.Any(), workspaceFolder, configFile, gomock.Any()).Return("someid", nil).Times(1)
|
||||
return 0
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Container does not exist",
|
||||
setupMock: func(mcl *acmock.MockLister, mdccli *acmock.MockDevcontainerCLI) int {
|
||||
mcl.EXPECT().List(gomock.Any()).Return(codersdk.WorkspaceAgentListContainersResponse{}, nil).Times(1)
|
||||
return http.StatusNotFound
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Not a devcontainer",
|
||||
setupMock: func(mcl *acmock.MockLister, mdccli *acmock.MockDevcontainerCLI) int {
|
||||
mcl.EXPECT().List(gomock.Any()).Return(codersdk.WorkspaceAgentListContainersResponse{
|
||||
Containers: []codersdk.WorkspaceAgentContainer{plainContainer},
|
||||
}, nil).Times(1)
|
||||
return http.StatusNotFound
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
mcl := acmock.NewMockLister(ctrl)
|
||||
mdccli := acmock.NewMockDevcontainerCLI(ctrl)
|
||||
wantStatus := tc.setupMock(mcl, mdccli)
|
||||
client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
r := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
|
||||
OrganizationID: user.OrganizationID,
|
||||
OwnerID: user.UserID,
|
||||
}).WithAgent(func(agents []*proto.Agent) []*proto.Agent {
|
||||
return agents
|
||||
}).Do()
|
||||
_ = agenttest.New(t, client.URL, r.AgentToken, func(o *agent.Options) {
|
||||
o.ExperimentalDevcontainersEnabled = true
|
||||
o.ContainerAPIOptions = append(
|
||||
o.ContainerAPIOptions,
|
||||
agentcontainers.WithLister(mcl),
|
||||
agentcontainers.WithDevcontainerCLI(mdccli),
|
||||
agentcontainers.WithWatcher(watcher.NewNoop()),
|
||||
)
|
||||
})
|
||||
resources := coderdtest.NewWorkspaceAgentWaiter(t, client, r.Workspace.ID).Wait()
|
||||
require.Len(t, resources, 1, "expected one resource")
|
||||
require.Len(t, resources[0].Agents, 1, "expected one agent")
|
||||
agentID := resources[0].Agents[0].ID
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
err := client.WorkspaceAgentRecreateDevcontainer(ctx, agentID, devContainer.ID)
|
||||
if wantStatus > 0 {
|
||||
cerr, ok := codersdk.AsError(err)
|
||||
require.True(t, ok, "expected error to be a coder error")
|
||||
assert.Equal(t, wantStatus, cerr.StatusCode())
|
||||
} else {
|
||||
require.NoError(t, err, "failed to recreate devcontainer")
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWorkspaceAgentAppHealth(t *testing.T) {
|
||||
t.Parallel()
|
||||
client, db := coderdtest.NewWithDatabase(t, nil)
|
||||
|
||||
@@ -439,6 +439,10 @@ type WorkspaceAgentContainer struct {
|
||||
// Volumes is a map of "things" mounted into the container. Again, this
|
||||
// is somewhat implementation-dependent.
|
||||
Volumes map[string]string `json:"volumes"`
|
||||
// DevcontainerDirty is true if the devcontainer configuration has changed
|
||||
// since the container was created. This is used to determine if the
|
||||
// container needs to be rebuilt.
|
||||
DevcontainerDirty bool `json:"devcontainer_dirty"`
|
||||
}
|
||||
|
||||
func (c *WorkspaceAgentContainer) Match(idOrName string) bool {
|
||||
@@ -502,6 +506,19 @@ func (c *Client) WorkspaceAgentListContainers(ctx context.Context, agentID uuid.
|
||||
return cr, json.NewDecoder(res.Body).Decode(&cr)
|
||||
}
|
||||
|
||||
// WorkspaceAgentRecreateDevcontainer recreates the devcontainer with the given ID.
|
||||
func (c *Client) WorkspaceAgentRecreateDevcontainer(ctx context.Context, agentID uuid.UUID, containerIDOrName string) error {
|
||||
res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/workspaceagents/%s/containers/devcontainers/container/%s/recreate", agentID, containerIDOrName), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusNoContent {
|
||||
return ReadBodyAsError(res)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
//nolint:revive // Follow is a control flag on the server as well.
|
||||
func (c *Client) WorkspaceAgentLogsAfter(ctx context.Context, agentID uuid.UUID, after int64, follow bool) (<-chan []WorkspaceAgentLog, io.Closer, error) {
|
||||
var queryParams []string
|
||||
|
||||
Generated
+28
@@ -776,6 +776,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaceagents/{workspaceagent}/con
|
||||
"containers": [
|
||||
{
|
||||
"created_at": "2019-08-24T14:15:22Z",
|
||||
"devcontainer_dirty": true,
|
||||
"id": "string",
|
||||
"image": "string",
|
||||
"labels": {
|
||||
@@ -813,6 +814,33 @@ curl -X GET http://coder-server:8080/api/v2/workspaceagents/{workspaceagent}/con
|
||||
|
||||
To perform this operation, you must be authenticated. [Learn more](authentication.md).
|
||||
|
||||
## Recreate devcontainer for workspace agent
|
||||
|
||||
### Code samples
|
||||
|
||||
```shell
|
||||
# Example request using curl
|
||||
curl -X POST http://coder-server:8080/api/v2/workspaceagents/{workspaceagent}/containers/devcontainers/container/{container}/recreate \
|
||||
-H 'Coder-Session-Token: API_KEY'
|
||||
```
|
||||
|
||||
`POST /workspaceagents/{workspaceagent}/containers/devcontainers/container/{container}/recreate`
|
||||
|
||||
### Parameters
|
||||
|
||||
| Name | In | Type | Required | Description |
|
||||
|------------------|------|--------------|----------|----------------------|
|
||||
| `workspaceagent` | path | string(uuid) | true | Workspace agent ID |
|
||||
| `container` | path | string | true | Container ID or name |
|
||||
|
||||
### Responses
|
||||
|
||||
| Status | Meaning | Description | Schema |
|
||||
|--------|-----------------------------------------------------------------|-------------|--------|
|
||||
| 204 | [No Content](https://tools.ietf.org/html/rfc7231#section-6.3.5) | No Content | |
|
||||
|
||||
To perform this operation, you must be authenticated. [Learn more](authentication.md).
|
||||
|
||||
## Coordinate workspace agent
|
||||
|
||||
### Code samples
|
||||
|
||||
Generated
+16
-13
@@ -8621,6 +8621,7 @@ If the schedule is empty, the user will be updated to use the default schedule.|
|
||||
```json
|
||||
{
|
||||
"created_at": "2019-08-24T14:15:22Z",
|
||||
"devcontainer_dirty": true,
|
||||
"id": "string",
|
||||
"image": "string",
|
||||
"labels": {
|
||||
@@ -8647,19 +8648,20 @@ If the schedule is empty, the user will be updated to use the default schedule.|
|
||||
|
||||
### Properties
|
||||
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
|--------------------|---------------------------------------------------------------------------------------|----------|--------------|--------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `created_at` | string | false | | Created at is the time the container was created. |
|
||||
| `id` | string | false | | ID is the unique identifier of the container. |
|
||||
| `image` | string | false | | Image is the name of the container image. |
|
||||
| `labels` | object | false | | Labels is a map of key-value pairs of container labels. |
|
||||
| » `[any property]` | string | false | | |
|
||||
| `name` | string | false | | Name is the human-readable name of the container. |
|
||||
| `ports` | array of [codersdk.WorkspaceAgentContainerPort](#codersdkworkspaceagentcontainerport) | false | | Ports includes ports exposed by the container. |
|
||||
| `running` | boolean | false | | Running is true if the container is currently running. |
|
||||
| `status` | string | false | | Status is the current status of the container. This is somewhat implementation-dependent, but should generally be a human-readable string. |
|
||||
| `volumes` | object | false | | Volumes is a map of "things" mounted into the container. Again, this is somewhat implementation-dependent. |
|
||||
| » `[any property]` | string | false | | |
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
|----------------------|---------------------------------------------------------------------------------------|----------|--------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `created_at` | string | false | | Created at is the time the container was created. |
|
||||
| `devcontainer_dirty` | boolean | false | | Devcontainer dirty is true if the devcontainer configuration has changed since the container was created. This is used to determine if the container needs to be rebuilt. |
|
||||
| `id` | string | false | | ID is the unique identifier of the container. |
|
||||
| `image` | string | false | | Image is the name of the container image. |
|
||||
| `labels` | object | false | | Labels is a map of key-value pairs of container labels. |
|
||||
| » `[any property]` | string | false | | |
|
||||
| `name` | string | false | | Name is the human-readable name of the container. |
|
||||
| `ports` | array of [codersdk.WorkspaceAgentContainerPort](#codersdkworkspaceagentcontainerport) | false | | Ports includes ports exposed by the container. |
|
||||
| `running` | boolean | false | | Running is true if the container is currently running. |
|
||||
| `status` | string | false | | Status is the current status of the container. This is somewhat implementation-dependent, but should generally be a human-readable string. |
|
||||
| `volumes` | object | false | | Volumes is a map of "things" mounted into the container. Again, this is somewhat implementation-dependent. |
|
||||
| » `[any property]` | string | false | | |
|
||||
|
||||
## codersdk.WorkspaceAgentContainerPort
|
||||
|
||||
@@ -8726,6 +8728,7 @@ If the schedule is empty, the user will be updated to use the default schedule.|
|
||||
"containers": [
|
||||
{
|
||||
"created_at": "2019-08-24T14:15:22Z",
|
||||
"devcontainer_dirty": true,
|
||||
"id": "string",
|
||||
"image": "string",
|
||||
"labels": {
|
||||
|
||||
Generated
+1
@@ -3309,6 +3309,7 @@ export interface WorkspaceAgentContainer {
|
||||
readonly ports: readonly WorkspaceAgentContainerPort[];
|
||||
readonly status: string;
|
||||
readonly volumes: Record<string, string>;
|
||||
readonly devcontainer_dirty: boolean;
|
||||
}
|
||||
|
||||
// From codersdk/workspaceagents.go
|
||||
|
||||
@@ -4384,4 +4384,5 @@ export const MockWorkspaceAgentContainer: TypesGen.WorkspaceAgentContainer = {
|
||||
volumes: {
|
||||
"/mnt/volume1": "/volume1",
|
||||
},
|
||||
devcontainer_dirty: false,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user