diff --git a/agent/agentcontainers/acmock/acmock.go b/agent/agentcontainers/acmock/acmock.go index 869d2f7d0923b..4be3b2cf53519 100644 --- a/agent/agentcontainers/acmock/acmock.go +++ b/agent/agentcontainers/acmock/acmock.go @@ -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. diff --git a/agent/agentcontainers/acmock/doc.go b/agent/agentcontainers/acmock/doc.go index b807efa253b75..d0951fc848eb1 100644 --- a/agent/agentcontainers/acmock/doc.go +++ b/agent/agentcontainers/acmock/doc.go @@ -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 diff --git a/agent/agentcontainers/api.go b/agent/agentcontainers/api.go index 8fff664c0b0f7..4017de1931093 100644 --- a/agent/agentcontainers/api.go +++ b/agent/agentcontainers/api.go @@ -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 diff --git a/agent/agentcontainers/api_test.go b/agent/agentcontainers/api_test.go index fb55825097190..57c4c8f894f3e 100644 --- a/agent/agentcontainers/api_test.go +++ b/agent/agentcontainers/api_test.go @@ -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 *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 *fakeLister) List(_ context.Context) (codersdk.WorkspaceAgentListContainersResponse, error) { - return f.containers, f.err +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), ) diff --git a/agent/agentcontainers/containers.go b/agent/agentcontainers/containers.go index 5be288781d480..e728507e8f394 100644 --- a/agent/agentcontainers/containers.go +++ b/agent/agentcontainers/containers.go @@ -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 "", 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 +} diff --git a/agent/agentcontainers/containers_dockercli.go b/agent/agentcontainers/containers_dockercli.go index d5499f6b1af2b..83463481c97f7 100644 --- a/agent/agentcontainers/containers_dockercli.go +++ b/agent/agentcontainers/containers_dockercli.go @@ -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 +} diff --git a/agent/agentcontainers/containers_dockercli_test.go b/agent/agentcontainers/containers_dockercli_test.go new file mode 100644 index 0000000000000..c69110a757bc7 --- /dev/null +++ b/agent/agentcontainers/containers_dockercli_test.go @@ -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) + }) +} diff --git a/agent/agentcontainers/containers_test.go b/agent/agentcontainers/containers_test.go index 59befb2fd2be0..387c8dccc961d 100644 --- a/agent/agentcontainers/containers_test.go +++ b/agent/agentcontainers/containers_test.go @@ -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") diff --git a/cli/open_test.go b/cli/open_test.go index 97d24f0634d9d..4441e51e58c4b 100644 --- a/cli/open_test.go +++ b/cli/open_test.go @@ -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() diff --git a/cli/ssh_test.go b/cli/ssh_test.go index 8845200273697..1774d8d131a9d 100644 --- a/cli/ssh_test.go +++ b/cli/ssh_test.go @@ -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() diff --git a/coderd/workspaceagents_test.go b/coderd/workspaceagents_test.go index a9b981f820be2..f32c7b1458ca2 100644 --- a/coderd/workspaceagents_test.go +++ b/coderd/workspaceagents_test.go @@ -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()), ) pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy