feat(agent/agentcontainers): refactor Lister to ContainerCLI and implement new methods (#18243)

This commit is contained in:
Mathias Fredriksson
2025-06-06 10:33:09 +00:00
committed by GitHub
parent 533c6dcbbe
commit a12429e9f8
11 changed files with 367 additions and 95 deletions
+65 -16
View File
@@ -1,9 +1,9 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: .. (interfaces: Lister,DevcontainerCLI)
// Source: .. (interfaces: ContainerCLI,DevcontainerCLI)
//
// Generated by this command:
//
// mockgen -destination ./acmock.go -package acmock .. Lister,DevcontainerCLI
// mockgen -destination ./acmock.go -package acmock .. ContainerCLI,DevcontainerCLI
//
// Package acmock is a generated GoMock package.
@@ -18,32 +18,81 @@ import (
gomock "go.uber.org/mock/gomock"
)
// MockLister is a mock of Lister interface.
type MockLister struct {
// MockContainerCLI is a mock of ContainerCLI interface.
type MockContainerCLI struct {
ctrl *gomock.Controller
recorder *MockListerMockRecorder
recorder *MockContainerCLIMockRecorder
isgomock struct{}
}
// MockListerMockRecorder is the mock recorder for MockLister.
type MockListerMockRecorder struct {
mock *MockLister
// MockContainerCLIMockRecorder is the mock recorder for MockContainerCLI.
type MockContainerCLIMockRecorder struct {
mock *MockContainerCLI
}
// NewMockLister creates a new mock instance.
func NewMockLister(ctrl *gomock.Controller) *MockLister {
mock := &MockLister{ctrl: ctrl}
mock.recorder = &MockListerMockRecorder{mock}
// NewMockContainerCLI creates a new mock instance.
func NewMockContainerCLI(ctrl *gomock.Controller) *MockContainerCLI {
mock := &MockContainerCLI{ctrl: ctrl}
mock.recorder = &MockContainerCLIMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockLister) EXPECT() *MockListerMockRecorder {
func (m *MockContainerCLI) EXPECT() *MockContainerCLIMockRecorder {
return m.recorder
}
// Copy mocks base method.
func (m *MockContainerCLI) Copy(ctx context.Context, containerName, src, dst string) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Copy", ctx, containerName, src, dst)
ret0, _ := ret[0].(error)
return ret0
}
// Copy indicates an expected call of Copy.
func (mr *MockContainerCLIMockRecorder) Copy(ctx, containerName, src, dst any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Copy", reflect.TypeOf((*MockContainerCLI)(nil).Copy), ctx, containerName, src, dst)
}
// DetectArchitecture mocks base method.
func (m *MockContainerCLI) DetectArchitecture(ctx context.Context, containerName string) (string, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "DetectArchitecture", ctx, containerName)
ret0, _ := ret[0].(string)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// DetectArchitecture indicates an expected call of DetectArchitecture.
func (mr *MockContainerCLIMockRecorder) DetectArchitecture(ctx, containerName any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DetectArchitecture", reflect.TypeOf((*MockContainerCLI)(nil).DetectArchitecture), ctx, containerName)
}
// ExecAs mocks base method.
func (m *MockContainerCLI) ExecAs(ctx context.Context, containerName, user string, args ...string) ([]byte, error) {
m.ctrl.T.Helper()
varargs := []any{ctx, containerName, user}
for _, a := range args {
varargs = append(varargs, a)
}
ret := m.ctrl.Call(m, "ExecAs", varargs...)
ret0, _ := ret[0].([]byte)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// ExecAs indicates an expected call of ExecAs.
func (mr *MockContainerCLIMockRecorder) ExecAs(ctx, containerName, user any, args ...any) *gomock.Call {
mr.mock.ctrl.T.Helper()
varargs := append([]any{ctx, containerName, user}, args...)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExecAs", reflect.TypeOf((*MockContainerCLI)(nil).ExecAs), varargs...)
}
// List mocks base method.
func (m *MockLister) List(ctx context.Context) (codersdk.WorkspaceAgentListContainersResponse, error) {
func (m *MockContainerCLI) List(ctx context.Context) (codersdk.WorkspaceAgentListContainersResponse, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "List", ctx)
ret0, _ := ret[0].(codersdk.WorkspaceAgentListContainersResponse)
@@ -52,9 +101,9 @@ func (m *MockLister) List(ctx context.Context) (codersdk.WorkspaceAgentListConta
}
// List indicates an expected call of List.
func (mr *MockListerMockRecorder) List(ctx any) *gomock.Call {
func (mr *MockContainerCLIMockRecorder) List(ctx any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockLister)(nil).List), ctx)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockContainerCLI)(nil).List), ctx)
}
// MockDevcontainerCLI is a mock of DevcontainerCLI interface.
+1 -1
View File
@@ -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,DevcontainerCLI
//go:generate mockgen -destination ./acmock.go -package acmock .. ContainerCLI,DevcontainerCLI
+8 -8
View File
@@ -43,7 +43,7 @@ type API struct {
logger slog.Logger
watcher watcher.Watcher
execer agentexec.Execer
cl Lister
ccli ContainerCLI
dccli DevcontainerCLI
clock quartz.Clock
scriptLogger func(logSourceID uuid.UUID) ScriptLogger
@@ -80,11 +80,11 @@ func WithExecer(execer agentexec.Execer) Option {
}
}
// WithLister sets the agentcontainers.Lister implementation to use.
// The default implementation uses the Docker CLI to list containers.
func WithLister(cl Lister) Option {
// WithContainerCLI sets the agentcontainers.ContainerCLI implementation
// to use. The default implementation uses the Docker CLI.
func WithContainerCLI(ccli ContainerCLI) Option {
return func(api *API) {
api.cl = cl
api.ccli = ccli
}
}
@@ -186,8 +186,8 @@ func NewAPI(logger slog.Logger, options ...Option) *API {
for _, opt := range options {
opt(api)
}
if api.cl == nil {
api.cl = NewDocker(api.execer)
if api.ccli == nil {
api.ccli = NewDockerCLI(api.execer)
}
if api.dccli == nil {
api.dccli = NewDevcontainerCLI(logger.Named("devcontainer-cli"), api.execer)
@@ -363,7 +363,7 @@ func (api *API) updateContainers(ctx context.Context) error {
listCtx, listCancel := context.WithTimeout(ctx, listContainersTimeout)
defer listCancel()
updated, err := api.cl.List(listCtx)
updated, err := api.ccli.List(listCtx)
if err != nil {
// If the context was canceled, we hold off on clearing the
// containers cache. This is to avoid clearing the cache if
+52 -36
View File
@@ -28,15 +28,31 @@ import (
"github.com/coder/quartz"
)
// fakeLister implements the agentcontainers.Lister interface for
// fakeContainerCLI implements the agentcontainers.ContainerCLI interface for
// testing.
type fakeLister struct {
type fakeContainerCLI struct {
containers codersdk.WorkspaceAgentListContainersResponse
err error
listErr error
arch string
archErr error
copyErr error
execErr error
}
func (f *fakeLister) List(_ context.Context) (codersdk.WorkspaceAgentListContainersResponse, error) {
return f.containers, f.err
func (f *fakeContainerCLI) List(_ context.Context) (codersdk.WorkspaceAgentListContainersResponse, error) {
return f.containers, f.listErr
}
func (f *fakeContainerCLI) DetectArchitecture(_ context.Context, _ string) (string, error) {
return f.arch, f.archErr
}
func (f *fakeContainerCLI) Copy(ctx context.Context, name, src, dst string) error {
return f.copyErr
}
func (f *fakeContainerCLI) ExecAs(ctx context.Context, name, user string, args ...string) ([]byte, error) {
return nil, f.execErr
}
// fakeDevcontainerCLI implements the agentcontainers.DevcontainerCLI
@@ -180,7 +196,7 @@ func TestAPI(t *testing.T) {
// 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)
setupMock func(mcl *acmock.MockContainerCLI, preReq *gomock.Call)
// expected result
expected codersdk.WorkspaceAgentListContainersResponse
// expected error
@@ -189,7 +205,7 @@ func TestAPI(t *testing.T) {
{
name: "no initial data",
initialData: initialDataPayload{makeResponse(), nil},
setupMock: func(mcl *acmock.MockLister, preReq *gomock.Call) {
setupMock: func(mcl *acmock.MockContainerCLI, preReq *gomock.Call) {
mcl.EXPECT().List(gomock.Any()).Return(makeResponse(fakeCt), nil).After(preReq).AnyTimes()
},
expected: makeResponse(fakeCt),
@@ -207,7 +223,7 @@ func TestAPI(t *testing.T) {
{
name: "lister error only during initial data",
initialData: initialDataPayload{makeResponse(), assert.AnError},
setupMock: func(mcl *acmock.MockLister, preReq *gomock.Call) {
setupMock: func(mcl *acmock.MockContainerCLI, preReq *gomock.Call) {
mcl.EXPECT().List(gomock.Any()).Return(makeResponse(fakeCt), nil).After(preReq).AnyTimes()
},
expected: makeResponse(fakeCt),
@@ -215,7 +231,7 @@ func TestAPI(t *testing.T) {
{
name: "lister error after initial data",
initialData: initialDataPayload{makeResponse(fakeCt), nil},
setupMock: func(mcl *acmock.MockLister, preReq *gomock.Call) {
setupMock: func(mcl *acmock.MockContainerCLI, preReq *gomock.Call) {
mcl.EXPECT().List(gomock.Any()).Return(makeResponse(), assert.AnError).After(preReq).AnyTimes()
},
expectedErr: assert.AnError.Error(),
@@ -223,7 +239,7 @@ func TestAPI(t *testing.T) {
{
name: "updated data",
initialData: initialDataPayload{makeResponse(fakeCt), nil},
setupMock: func(mcl *acmock.MockLister, preReq *gomock.Call) {
setupMock: func(mcl *acmock.MockContainerCLI, preReq *gomock.Call) {
mcl.EXPECT().List(gomock.Any()).Return(makeResponse(fakeCt2), nil).After(preReq).AnyTimes()
},
expected: makeResponse(fakeCt2),
@@ -236,7 +252,7 @@ func TestAPI(t *testing.T) {
mClock = quartz.NewMock(t)
tickerTrap = mClock.Trap().TickerFunc("updaterLoop")
mCtrl = gomock.NewController(t)
mLister = acmock.NewMockLister(mCtrl)
mLister = acmock.NewMockContainerCLI(mCtrl)
logger = slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
r = chi.NewRouter()
)
@@ -250,7 +266,7 @@ func TestAPI(t *testing.T) {
api := agentcontainers.NewAPI(logger,
agentcontainers.WithClock(mClock),
agentcontainers.WithLister(mLister),
agentcontainers.WithContainerCLI(mLister),
)
defer api.Close()
r.Mount("/", api.Routes())
@@ -326,7 +342,7 @@ func TestAPI(t *testing.T) {
tests := []struct {
name string
containerID string
lister *fakeLister
lister *fakeContainerCLI
devcontainerCLI *fakeDevcontainerCLI
wantStatus []int
wantBody []string
@@ -334,7 +350,7 @@ func TestAPI(t *testing.T) {
{
name: "Missing container ID",
containerID: "",
lister: &fakeLister{},
lister: &fakeContainerCLI{},
devcontainerCLI: &fakeDevcontainerCLI{},
wantStatus: []int{http.StatusBadRequest},
wantBody: []string{"Missing container ID or name"},
@@ -342,8 +358,8 @@ func TestAPI(t *testing.T) {
{
name: "List error",
containerID: "container-id",
lister: &fakeLister{
err: xerrors.New("list error"),
lister: &fakeContainerCLI{
listErr: xerrors.New("list error"),
},
devcontainerCLI: &fakeDevcontainerCLI{},
wantStatus: []int{http.StatusInternalServerError},
@@ -352,7 +368,7 @@ func TestAPI(t *testing.T) {
{
name: "Container not found",
containerID: "nonexistent-container",
lister: &fakeLister{
lister: &fakeContainerCLI{
containers: codersdk.WorkspaceAgentListContainersResponse{
Containers: []codersdk.WorkspaceAgentContainer{validContainer},
},
@@ -364,7 +380,7 @@ func TestAPI(t *testing.T) {
{
name: "Missing workspace folder label",
containerID: "missing-folder-container",
lister: &fakeLister{
lister: &fakeContainerCLI{
containers: codersdk.WorkspaceAgentListContainersResponse{
Containers: []codersdk.WorkspaceAgentContainer{missingFolderContainer},
},
@@ -376,7 +392,7 @@ func TestAPI(t *testing.T) {
{
name: "Devcontainer CLI error",
containerID: "container-id",
lister: &fakeLister{
lister: &fakeContainerCLI{
containers: codersdk.WorkspaceAgentListContainersResponse{
Containers: []codersdk.WorkspaceAgentContainer{validContainer},
},
@@ -390,7 +406,7 @@ func TestAPI(t *testing.T) {
{
name: "OK",
containerID: "container-id",
lister: &fakeLister{
lister: &fakeContainerCLI{
containers: codersdk.WorkspaceAgentListContainersResponse{
Containers: []codersdk.WorkspaceAgentContainer{validContainer},
},
@@ -423,7 +439,7 @@ func TestAPI(t *testing.T) {
api := agentcontainers.NewAPI(
logger,
agentcontainers.WithClock(mClock),
agentcontainers.WithLister(tt.lister),
agentcontainers.WithContainerCLI(tt.lister),
agentcontainers.WithDevcontainerCLI(tt.devcontainerCLI),
agentcontainers.WithWatcher(watcher.NewNoop()),
)
@@ -559,7 +575,7 @@ func TestAPI(t *testing.T) {
tests := []struct {
name string
lister *fakeLister
lister *fakeContainerCLI
knownDevcontainers []codersdk.WorkspaceAgentDevcontainer
wantStatus int
wantCount int
@@ -567,20 +583,20 @@ func TestAPI(t *testing.T) {
}{
{
name: "List error",
lister: &fakeLister{
err: xerrors.New("list error"),
lister: &fakeContainerCLI{
listErr: xerrors.New("list error"),
},
wantStatus: http.StatusInternalServerError,
},
{
name: "Empty containers",
lister: &fakeLister{},
lister: &fakeContainerCLI{},
wantStatus: http.StatusOK,
wantCount: 0,
},
{
name: "Only known devcontainers, no containers",
lister: &fakeLister{
lister: &fakeContainerCLI{
containers: codersdk.WorkspaceAgentListContainersResponse{
Containers: []codersdk.WorkspaceAgentContainer{},
},
@@ -597,7 +613,7 @@ func TestAPI(t *testing.T) {
},
{
name: "Runtime-detected devcontainer",
lister: &fakeLister{
lister: &fakeContainerCLI{
containers: codersdk.WorkspaceAgentListContainersResponse{
Containers: []codersdk.WorkspaceAgentContainer{
{
@@ -631,7 +647,7 @@ func TestAPI(t *testing.T) {
},
{
name: "Mixed known and runtime-detected devcontainers",
lister: &fakeLister{
lister: &fakeContainerCLI{
containers: codersdk.WorkspaceAgentListContainersResponse{
Containers: []codersdk.WorkspaceAgentContainer{
{
@@ -679,7 +695,7 @@ func TestAPI(t *testing.T) {
},
{
name: "Both running and non-running containers have container references",
lister: &fakeLister{
lister: &fakeContainerCLI{
containers: codersdk.WorkspaceAgentListContainersResponse{
Containers: []codersdk.WorkspaceAgentContainer{
{
@@ -723,7 +739,7 @@ func TestAPI(t *testing.T) {
},
{
name: "Config path update",
lister: &fakeLister{
lister: &fakeContainerCLI{
containers: codersdk.WorkspaceAgentListContainersResponse{
Containers: []codersdk.WorkspaceAgentContainer{
{
@@ -759,7 +775,7 @@ func TestAPI(t *testing.T) {
},
{
name: "Name generation and uniqueness",
lister: &fakeLister{
lister: &fakeContainerCLI{
containers: codersdk.WorkspaceAgentListContainersResponse{
Containers: []codersdk.WorkspaceAgentContainer{
{
@@ -831,7 +847,7 @@ func TestAPI(t *testing.T) {
r := chi.NewRouter()
apiOptions := []agentcontainers.Option{
agentcontainers.WithClock(mClock),
agentcontainers.WithLister(tt.lister),
agentcontainers.WithContainerCLI(tt.lister),
agentcontainers.WithWatcher(watcher.NewNoop()),
}
@@ -914,7 +930,7 @@ func TestAPI(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitShort)
logger := slogtest.Make(t, nil).Leveled(slog.LevelDebug)
fLister := &fakeLister{
fLister := &fakeContainerCLI{
containers: codersdk.WorkspaceAgentListContainersResponse{
Containers: []codersdk.WorkspaceAgentContainer{container},
},
@@ -926,7 +942,7 @@ func TestAPI(t *testing.T) {
api := agentcontainers.NewAPI(logger,
agentcontainers.WithClock(mClock),
agentcontainers.WithLister(fLister),
agentcontainers.WithContainerCLI(fLister),
agentcontainers.WithWatcher(fWatcher),
agentcontainers.WithDevcontainers(
[]codersdk.WorkspaceAgentDevcontainer{dc},
@@ -1013,7 +1029,7 @@ func TestAPI(t *testing.T) {
mClock.Set(startTime)
tickerTrap := mClock.Trap().TickerFunc("updaterLoop")
fWatcher := newFakeWatcher(t)
fLister := &fakeLister{
fLister := &fakeContainerCLI{
containers: codersdk.WorkspaceAgentListContainersResponse{
Containers: []codersdk.WorkspaceAgentContainer{container},
},
@@ -1022,7 +1038,7 @@ func TestAPI(t *testing.T) {
logger := slogtest.Make(t, nil).Leveled(slog.LevelDebug)
api := agentcontainers.NewAPI(
logger,
agentcontainers.WithLister(fLister),
agentcontainers.WithContainerCLI(fLister),
agentcontainers.WithWatcher(fWatcher),
agentcontainers.WithClock(mClock),
)
+20 -7
View File
@@ -6,19 +6,32 @@ import (
"github.com/coder/coder/v2/codersdk"
)
// Lister is an interface for listing containers visible to the
// workspace agent.
type Lister interface {
// ContainerCLI is an interface for interacting with containers in a workspace.
type ContainerCLI interface {
// List returns a list of containers visible to the workspace agent.
// This should include running and stopped containers.
List(ctx context.Context) (codersdk.WorkspaceAgentListContainersResponse, error)
// DetectArchitecture detects the architecture of a container.
DetectArchitecture(ctx context.Context, containerName string) (string, error)
// Copy copies a file from the host to a container.
Copy(ctx context.Context, containerName, src, dst string) error
// ExecAs executes a command in a container as a specific user.
ExecAs(ctx context.Context, containerName, user string, args ...string) ([]byte, error)
}
// NoopLister is a Lister interface that never returns any containers.
type NoopLister struct{}
// noopContainerCLI is a ContainerCLI that does nothing.
type noopContainerCLI struct{}
var _ Lister = NoopLister{}
var _ ContainerCLI = noopContainerCLI{}
func (NoopLister) List(_ context.Context) (codersdk.WorkspaceAgentListContainersResponse, error) {
func (noopContainerCLI) List(_ context.Context) (codersdk.WorkspaceAgentListContainersResponse, error) {
return codersdk.WorkspaceAgentListContainersResponse{}, nil
}
func (noopContainerCLI) DetectArchitecture(_ context.Context, _ string) (string, error) {
return "<none>", nil
}
func (noopContainerCLI) Copy(_ context.Context, _ string, _ string, _ string) error { return nil }
func (noopContainerCLI) ExecAs(_ context.Context, _ string, _ string, _ ...string) ([]byte, error) {
return nil, nil
}
+77 -9
View File
@@ -228,23 +228,23 @@ func run(ctx context.Context, execer agentexec.Execer, cmd string, args ...strin
return stdout, stderr, err
}
// DockerCLILister is a ContainerLister that lists containers using the docker CLI
type DockerCLILister struct {
// dockerCLI is an implementation for Docker CLI that lists containers.
type dockerCLI struct {
execer agentexec.Execer
}
var _ Lister = &DockerCLILister{}
var _ ContainerCLI = (*dockerCLI)(nil)
func NewDocker(execer agentexec.Execer) Lister {
return &DockerCLILister{
execer: agentexec.DefaultExecer,
func NewDockerCLI(execer agentexec.Execer) ContainerCLI {
return &dockerCLI{
execer: execer,
}
}
func (dcl *DockerCLILister) List(ctx context.Context) (codersdk.WorkspaceAgentListContainersResponse, error) {
func (dcli *dockerCLI) List(ctx context.Context) (codersdk.WorkspaceAgentListContainersResponse, error) {
var stdoutBuf, stderrBuf bytes.Buffer
// List all container IDs, one per line, with no truncation
cmd := dcl.execer.CommandContext(ctx, "docker", "ps", "--all", "--quiet", "--no-trunc")
cmd := dcli.execer.CommandContext(ctx, "docker", "ps", "--all", "--quiet", "--no-trunc")
cmd.Stdout = &stdoutBuf
cmd.Stderr = &stderrBuf
if err := cmd.Run(); err != nil {
@@ -288,7 +288,7 @@ func (dcl *DockerCLILister) List(ctx context.Context) (codersdk.WorkspaceAgentLi
// will still contain valid JSON. We will just end up missing
// information about the removed container. We could potentially
// log this error, but I'm not sure it's worth it.
dockerInspectStdout, dockerInspectStderr, err := runDockerInspect(ctx, dcl.execer, ids...)
dockerInspectStdout, dockerInspectStderr, err := runDockerInspect(ctx, dcli.execer, ids...)
if err != nil {
return codersdk.WorkspaceAgentListContainersResponse{}, xerrors.Errorf("run docker inspect: %w: %s", err, dockerInspectStderr)
}
@@ -517,3 +517,71 @@ func isLoopbackOrUnspecified(ips string) bool {
}
return nip.IsLoopback() || nip.IsUnspecified()
}
// DetectArchitecture detects the architecture of a container by inspecting its
// image.
func (dcli *dockerCLI) DetectArchitecture(ctx context.Context, containerName string) (string, error) {
// Inspect the container to get the image name, which contains the architecture.
stdout, stderr, err := runCmd(ctx, dcli.execer, "docker", "inspect", "--format", "{{.Config.Image}}", containerName)
if err != nil {
return "", xerrors.Errorf("inspect container %s: %w: %s", containerName, err, stderr)
}
imageName := string(stdout)
if imageName == "" {
return "", xerrors.Errorf("no image found for container %s", containerName)
}
stdout, stderr, err = runCmd(ctx, dcli.execer, "docker", "inspect", "--format", "{{.Architecture}}", imageName)
if err != nil {
return "", xerrors.Errorf("inspect image %s: %w: %s", imageName, err, stderr)
}
arch := string(stdout)
if arch == "" {
return "", xerrors.Errorf("no architecture found for image %s", imageName)
}
return arch, nil
}
// Copy copies a file from the host to a container.
func (dcli *dockerCLI) Copy(ctx context.Context, containerName, src, dst string) error {
_, stderr, err := runCmd(ctx, dcli.execer, "docker", "cp", src, containerName+":"+dst)
if err != nil {
return xerrors.Errorf("copy %s to %s:%s: %w: %s", src, containerName, dst, err, stderr)
}
return nil
}
// ExecAs executes a command in a container as a specific user.
func (dcli *dockerCLI) ExecAs(ctx context.Context, containerName, uid string, args ...string) ([]byte, error) {
execArgs := []string{"exec"}
if uid != "" {
altUID := uid
if uid == "root" {
// UID 0 is more portable than the name root, so we use that
// because some containers may not have a user named "root".
altUID = "0"
}
execArgs = append(execArgs, "--user", altUID)
}
execArgs = append(execArgs, containerName)
execArgs = append(execArgs, args...)
stdout, stderr, err := runCmd(ctx, dcli.execer, "docker", execArgs...)
if err != nil {
return nil, xerrors.Errorf("exec in container %s as user %s: %w: %s", containerName, uid, err, stderr)
}
return stdout, nil
}
// runCmd is a helper function that runs a command with the given
// arguments and returns the stdout and stderr output.
func runCmd(ctx context.Context, execer agentexec.Execer, cmd string, args ...string) (stdout, stderr []byte, err error) {
var stdoutBuf, stderrBuf bytes.Buffer
c := execer.CommandContext(ctx, cmd, args...)
c.Stdout = &stdoutBuf
c.Stderr = &stderrBuf
err = c.Run()
stdout = bytes.TrimSpace(stdoutBuf.Bytes())
stderr = bytes.TrimSpace(stderrBuf.Bytes())
return stdout, stderr, err
}
@@ -0,0 +1,126 @@
package agentcontainers_test
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/ory/dockertest/v3"
"github.com/ory/dockertest/v3/docker"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/agent/agentcontainers"
"github.com/coder/coder/v2/agent/agentexec"
"github.com/coder/coder/v2/testutil"
)
// TestIntegrationDockerCLI tests the DetectArchitecture, Copy, and
// ExecAs methods using a real Docker container. All tests share a
// single container to avoid setup overhead.
//
// Run manually with: CODER_TEST_USE_DOCKER=1 go test ./agent/agentcontainers -run TestIntegrationDockerCLI
//
//nolint:tparallel,paralleltest // Docker integration tests don't run in parallel to avoid flakiness.
func TestIntegrationDockerCLI(t *testing.T) {
if ctud, ok := os.LookupEnv("CODER_TEST_USE_DOCKER"); !ok || ctud != "1" {
t.Skip("Set CODER_TEST_USE_DOCKER=1 to run this test")
}
pool, err := dockertest.NewPool("")
require.NoError(t, err, "Could not connect to docker")
// Start a simple busybox container for all subtests to share.
ct, err := pool.RunWithOptions(&dockertest.RunOptions{
Repository: "busybox",
Tag: "latest",
Cmd: []string{"sleep", "infinity"},
}, func(config *docker.HostConfig) {
config.AutoRemove = true
config.RestartPolicy = docker.RestartPolicy{Name: "no"}
})
require.NoError(t, err, "Could not start test docker container")
t.Logf("Created container %q", ct.Container.Name)
t.Cleanup(func() {
assert.NoError(t, pool.Purge(ct), "Could not purge resource %q", ct.Container.Name)
t.Logf("Purged container %q", ct.Container.Name)
})
// Wait for container to start.
require.Eventually(t, func() bool {
ct, ok := pool.ContainerByName(ct.Container.Name)
return ok && ct.Container.State.Running
}, testutil.WaitShort, testutil.IntervalSlow, "Container did not start in time")
dcli := agentcontainers.NewDockerCLI(agentexec.DefaultExecer)
ctx := testutil.Context(t, testutil.WaitMedium) // Longer timeout for multiple subtests
containerName := strings.TrimPrefix(ct.Container.Name, "/")
t.Run("DetectArchitecture", func(t *testing.T) {
t.Parallel()
arch, err := dcli.DetectArchitecture(ctx, containerName)
require.NoError(t, err, "DetectArchitecture failed")
require.NotEmpty(t, arch, "arch has no content")
require.Equal(t, runtime.GOARCH, arch, "architecture does not match runtime, did you run this test with a remote Docker socket?")
t.Logf("Detected architecture: %s", arch)
})
t.Run("Copy", func(t *testing.T) {
t.Parallel()
want := "Help, I'm trapped!"
tempFile := filepath.Join(t.TempDir(), "test-file.txt")
err := os.WriteFile(tempFile, []byte(want), 0o600)
require.NoError(t, err, "create test file failed")
destPath := "/tmp/copied-file.txt"
err = dcli.Copy(ctx, containerName, tempFile, destPath)
require.NoError(t, err, "Copy failed")
got, err := dcli.ExecAs(ctx, containerName, "", "cat", destPath)
require.NoError(t, err, "ExecAs failed after Copy")
require.Equal(t, want, string(got), "copied file content did not match original")
t.Logf("Successfully copied file from %s to container %s:%s", tempFile, containerName, destPath)
})
t.Run("ExecAs", func(t *testing.T) {
t.Parallel()
// Test ExecAs without specifying user (should use container's default).
want := "root"
got, err := dcli.ExecAs(ctx, containerName, "", "whoami")
require.NoError(t, err, "ExecAs without user should succeed")
require.Equal(t, want, string(got), "ExecAs without user should output expected string")
// Test ExecAs with numeric UID (non root).
want = "1000"
_, err = dcli.ExecAs(ctx, containerName, want, "whoami")
require.Error(t, err, "ExecAs with UID 1000 should fail as user does not exist in busybox")
require.Contains(t, err.Error(), "whoami: unknown uid 1000", "ExecAs with UID 1000 should return 'unknown uid' error")
// Test ExecAs with root user (should convert "root" to "0", which still outputs root due to passwd).
want = "root"
got, err = dcli.ExecAs(ctx, containerName, "root", "whoami")
require.NoError(t, err, "ExecAs with root user should succeed")
require.Equal(t, want, string(got), "ExecAs with root user should output expected string")
// Test ExecAs with numeric UID.
want = "root"
got, err = dcli.ExecAs(ctx, containerName, "0", "whoami")
require.NoError(t, err, "ExecAs with UID 0 should succeed")
require.Equal(t, want, string(got), "ExecAs with UID 0 should output expected string")
// Test ExecAs with multiple arguments.
want = "multiple args test"
got, err = dcli.ExecAs(ctx, containerName, "", "sh", "-c", "echo '"+want+"'")
require.NoError(t, err, "ExecAs with multiple arguments should succeed")
require.Equal(t, want, string(got), "ExecAs with multiple arguments should output expected string")
t.Logf("Successfully executed commands in container %s", containerName)
})
}
+1 -1
View File
@@ -78,7 +78,7 @@ func TestIntegrationDocker(t *testing.T) {
return ok && ct.Container.State.Running
}, testutil.WaitShort, testutil.IntervalSlow, "Container did not start in time")
dcl := agentcontainers.NewDocker(agentexec.DefaultExecer)
dcl := agentcontainers.NewDockerCLI(agentexec.DefaultExecer)
ctx := testutil.Context(t, testutil.WaitShort)
actual, err := dcl.List(ctx)
require.NoError(t, err, "Could not list containers")
+4 -4
View File
@@ -306,7 +306,7 @@ func TestOpenVSCodeDevContainer(t *testing.T) {
containerFolder := "/workspace/coder"
ctrl := gomock.NewController(t)
mcl := acmock.NewMockLister(ctrl)
mcl := acmock.NewMockContainerCLI(ctrl)
mcl.EXPECT().List(gomock.Any()).Return(
codersdk.WorkspaceAgentListContainersResponse{
Containers: []codersdk.WorkspaceAgentContainer{
@@ -337,7 +337,7 @@ func TestOpenVSCodeDevContainer(t *testing.T) {
_ = agenttest.New(t, client.URL, agentToken, func(o *agent.Options) {
o.ExperimentalDevcontainersEnabled = true
o.ContainerAPIOptions = append(o.ContainerAPIOptions, agentcontainers.WithLister(mcl))
o.ContainerAPIOptions = append(o.ContainerAPIOptions, agentcontainers.WithContainerCLI(mcl))
})
_ = coderdtest.NewWorkspaceAgentWaiter(t, client, workspace.ID).Wait()
@@ -481,7 +481,7 @@ func TestOpenVSCodeDevContainer_NoAgentDirectory(t *testing.T) {
containerFolder := "/workspace/coder"
ctrl := gomock.NewController(t)
mcl := acmock.NewMockLister(ctrl)
mcl := acmock.NewMockContainerCLI(ctrl)
mcl.EXPECT().List(gomock.Any()).Return(
codersdk.WorkspaceAgentListContainersResponse{
Containers: []codersdk.WorkspaceAgentContainer{
@@ -511,7 +511,7 @@ func TestOpenVSCodeDevContainer_NoAgentDirectory(t *testing.T) {
_ = agenttest.New(t, client.URL, agentToken, func(o *agent.Options) {
o.ExperimentalDevcontainersEnabled = true
o.ContainerAPIOptions = append(o.ContainerAPIOptions, agentcontainers.WithLister(mcl))
o.ContainerAPIOptions = append(o.ContainerAPIOptions, agentcontainers.WithContainerCLI(mcl))
})
_ = coderdtest.NewWorkspaceAgentWaiter(t, client, workspace.ID).Wait()
+2 -2
View File
@@ -2057,7 +2057,7 @@ func TestSSH_Container(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, workspace, agentToken := setupWorkspaceForAgent(t)
ctrl := gomock.NewController(t)
mLister := acmock.NewMockLister(ctrl)
mLister := acmock.NewMockContainerCLI(ctrl)
mLister.EXPECT().List(gomock.Any()).Return(codersdk.WorkspaceAgentListContainersResponse{
Containers: []codersdk.WorkspaceAgentContainer{
{
@@ -2069,7 +2069,7 @@ func TestSSH_Container(t *testing.T) {
}, nil).AnyTimes()
_ = agenttest.New(t, client.URL, agentToken, func(o *agent.Options) {
o.ExperimentalDevcontainersEnabled = true
o.ContainerAPIOptions = append(o.ContainerAPIOptions, agentcontainers.WithLister(mLister))
o.ContainerAPIOptions = append(o.ContainerAPIOptions, agentcontainers.WithContainerCLI(mLister))
})
_ = coderdtest.NewWorkspaceAgentWaiter(t, client, workspace.ID).Wait()
+11 -11
View File
@@ -1320,18 +1320,18 @@ func TestWorkspaceAgentContainers(t *testing.T) {
for _, tc := range []struct {
name string
setupMock func(*acmock.MockLister) (codersdk.WorkspaceAgentListContainersResponse, error)
setupMock func(*acmock.MockContainerCLI) (codersdk.WorkspaceAgentListContainersResponse, error)
}{
{
name: "test response",
setupMock: func(mcl *acmock.MockLister) (codersdk.WorkspaceAgentListContainersResponse, error) {
setupMock: func(mcl *acmock.MockContainerCLI) (codersdk.WorkspaceAgentListContainersResponse, error) {
mcl.EXPECT().List(gomock.Any()).Return(testResponse, nil).AnyTimes()
return testResponse, nil
},
},
{
name: "error response",
setupMock: func(mcl *acmock.MockLister) (codersdk.WorkspaceAgentListContainersResponse, error) {
setupMock: func(mcl *acmock.MockContainerCLI) (codersdk.WorkspaceAgentListContainersResponse, error) {
mcl.EXPECT().List(gomock.Any()).Return(codersdk.WorkspaceAgentListContainersResponse{}, assert.AnError).AnyTimes()
return codersdk.WorkspaceAgentListContainersResponse{}, assert.AnError
},
@@ -1342,7 +1342,7 @@ func TestWorkspaceAgentContainers(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
mcl := acmock.NewMockLister(ctrl)
mcl := acmock.NewMockContainerCLI(ctrl)
expected, expectedErr := tc.setupMock(mcl)
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
@@ -1358,7 +1358,7 @@ func TestWorkspaceAgentContainers(t *testing.T) {
_ = agenttest.New(t, client.URL, r.AgentToken, func(o *agent.Options) {
o.Logger = logger.Named("agent")
o.ExperimentalDevcontainersEnabled = true
o.ContainerAPIOptions = append(o.ContainerAPIOptions, agentcontainers.WithLister(mcl))
o.ContainerAPIOptions = append(o.ContainerAPIOptions, agentcontainers.WithContainerCLI(mcl))
})
resources := coderdtest.NewWorkspaceAgentWaiter(t, client, r.Workspace.ID).Wait()
require.Len(t, resources, 1, "expected one resource")
@@ -1419,11 +1419,11 @@ func TestWorkspaceAgentRecreateDevcontainer(t *testing.T) {
for _, tc := range []struct {
name string
setupMock func(*acmock.MockLister, *acmock.MockDevcontainerCLI) (status int)
setupMock func(*acmock.MockContainerCLI, *acmock.MockDevcontainerCLI) (status int)
}{
{
name: "Recreate",
setupMock: func(mcl *acmock.MockLister, mdccli *acmock.MockDevcontainerCLI) int {
setupMock: func(mcl *acmock.MockContainerCLI, mdccli *acmock.MockDevcontainerCLI) int {
mcl.EXPECT().List(gomock.Any()).Return(codersdk.WorkspaceAgentListContainersResponse{
Containers: []codersdk.WorkspaceAgentContainer{devContainer},
}, nil).AnyTimes()
@@ -1433,14 +1433,14 @@ func TestWorkspaceAgentRecreateDevcontainer(t *testing.T) {
},
{
name: "Container does not exist",
setupMock: func(mcl *acmock.MockLister, mdccli *acmock.MockDevcontainerCLI) int {
setupMock: func(mcl *acmock.MockContainerCLI, mdccli *acmock.MockDevcontainerCLI) int {
mcl.EXPECT().List(gomock.Any()).Return(codersdk.WorkspaceAgentListContainersResponse{}, nil).AnyTimes()
return http.StatusNotFound
},
},
{
name: "Not a devcontainer",
setupMock: func(mcl *acmock.MockLister, mdccli *acmock.MockDevcontainerCLI) int {
setupMock: func(mcl *acmock.MockContainerCLI, mdccli *acmock.MockDevcontainerCLI) int {
mcl.EXPECT().List(gomock.Any()).Return(codersdk.WorkspaceAgentListContainersResponse{
Containers: []codersdk.WorkspaceAgentContainer{plainContainer},
}, nil).AnyTimes()
@@ -1452,7 +1452,7 @@ func TestWorkspaceAgentRecreateDevcontainer(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
mcl := acmock.NewMockLister(ctrl)
mcl := acmock.NewMockContainerCLI(ctrl)
mdccli := acmock.NewMockDevcontainerCLI(ctrl)
wantStatus := tc.setupMock(mcl, mdccli)
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
@@ -1471,7 +1471,7 @@ func TestWorkspaceAgentRecreateDevcontainer(t *testing.T) {
o.ExperimentalDevcontainersEnabled = true
o.ContainerAPIOptions = append(
o.ContainerAPIOptions,
agentcontainers.WithLister(mcl),
agentcontainers.WithContainerCLI(mcl),
agentcontainers.WithDevcontainerCLI(mdccli),
agentcontainers.WithWatcher(watcher.NewNoop()),
)