From a3b1bda5f40ed3fedefe22691d47074f830eb379 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Thu, 30 Jan 2025 15:05:44 +0000 Subject: [PATCH 01/13] feat(agent): add container list handler Adds an API endpoint to coderd `/api/v2/workspaceagents/:id/containers` that allows listing containers visible to the agent. This initial implementation only supports listing containers using the Docker CLI. Support for other data sources may be added at a future date. --- agent/api.go | 4 + agent/containers.go | 91 +++++++++++++ agent/containers_dockercli.go | 191 ++++++++++++++++++++++++++ agent/containers_internal_test.go | 206 +++++++++++++++++++++++++++++ agent/containers_mock.go | 56 ++++++++ coderd/apidoc/docs.go | 105 +++++++++++++++ coderd/apidoc/swagger.json | 101 ++++++++++++++ coderd/coderd.go | 1 + coderd/util/maps/maps.go | 29 ++++ coderd/util/maps/maps_test.go | 64 +++++++++ coderd/workspaceagents.go | 87 ++++++++++++ coderd/workspaceagents_test.go | 83 ++++++++++++ codersdk/workspaceagents.go | 62 +++++++++ codersdk/workspacesdk/agentconn.go | 16 +++ docs/reference/api/agents.md | 62 +++++++++ docs/reference/api/schemas.md | 82 ++++++++++++ site/src/api/typesGenerated.ts | 18 +++ 17 files changed, 1258 insertions(+) create mode 100644 agent/containers.go create mode 100644 agent/containers_dockercli.go create mode 100644 agent/containers_internal_test.go create mode 100644 agent/containers_mock.go create mode 100644 coderd/util/maps/maps.go create mode 100644 coderd/util/maps/maps_test.go diff --git a/agent/api.go b/agent/api.go index 2df791d6fbb68..bf8c11330753d 100644 --- a/agent/api.go +++ b/agent/api.go @@ -35,7 +35,11 @@ func (a *agent) apiHandler() http.Handler { ignorePorts: cpy, cacheDuration: cacheDuration, } + ch := &containersHandler{ + cacheDuration: defaultGetContainersCacheDuration, + } promHandler := PrometheusMetricsHandler(a.prometheusRegistry, a.logger) + r.Get("/api/v0/containers", ch.handler) r.Get("/api/v0/listening-ports", lp.handler) r.Get("/api/v0/netcheck", a.HandleNetcheck) r.Get("/debug/logs", a.HandleHTTPDebugLogs) diff --git a/agent/containers.go b/agent/containers.go new file mode 100644 index 0000000000000..eccd41fb96aeb --- /dev/null +++ b/agent/containers.go @@ -0,0 +1,91 @@ +package agent + +//go:generate mockgen -destination ./containers_mock.go -package agent . ContainerLister + +import ( + "context" + "net/http" + "sync" + "time" + + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/quartz" +) + +const ( + defaultGetContainersCacheDuration = 10 * time.Second + dockerCreatedAtTimeFormat = "2006-01-02 15:04:05 -0700 MST" + getContainersTimeout = 5 * time.Second +) + +type containersHandler struct { + cacheDuration time.Duration + cl ContainerLister + clock quartz.Clock + + mu sync.Mutex // protects the below + containers []codersdk.WorkspaceAgentContainer + mtime time.Time +} + +func (ch *containersHandler) handler(rw http.ResponseWriter, r *http.Request) { + ct, err := ch.getContainers(r.Context()) + if err != nil { + httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Could not get containers.", + Detail: err.Error(), + }) + return + } + + httpapi.Write(r.Context(), rw, http.StatusOK, ct) +} + +func (ch *containersHandler) getContainers(ctx context.Context) ([]codersdk.WorkspaceAgentContainer, error) { + ch.mu.Lock() + defer ch.mu.Unlock() + + // make zero-value usable + if ch.cacheDuration == 0 { + ch.cacheDuration = defaultGetContainersCacheDuration + } + if ch.cl == nil { + // TODO(cian): we may need some way to select the desired + // implementation, but for now there is only one. + ch.cl = &dockerCLIContainerLister{} + } + if ch.containers == nil { + ch.containers = make([]codersdk.WorkspaceAgentContainer, 0) + } + if ch.clock == nil { + ch.clock = quartz.NewReal() + } + + now := ch.clock.Now() + if now.Sub(ch.mtime) < ch.cacheDuration { + cpy := make([]codersdk.WorkspaceAgentContainer, len(ch.containers)) + copy(cpy, ch.containers) + return cpy, nil + } + + cancelCtx, cancelFunc := context.WithTimeout(ctx, getContainersTimeout) + defer cancelFunc() + updated, err := ch.cl.List(cancelCtx) + if err != nil { + return nil, xerrors.Errorf("get containers: %w", err) + } + ch.containers = updated + ch.mtime = now + + // return a copy + cpy := make([]codersdk.WorkspaceAgentContainer, len(ch.containers)) + copy(cpy, ch.containers) + return cpy, nil +} + +type ContainerLister interface { + List(ctx context.Context) ([]codersdk.WorkspaceAgentContainer, error) +} diff --git a/agent/containers_dockercli.go b/agent/containers_dockercli.go new file mode 100644 index 0000000000000..eefa02dcfbc7a --- /dev/null +++ b/agent/containers_dockercli.go @@ -0,0 +1,191 @@ +package agent + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os/exec" + "sort" + "strconv" + "strings" + "time" + + "github.com/coder/coder/v2/codersdk" + + "golang.org/x/exp/maps" + "golang.org/x/xerrors" +) + +// dockerCLIContainerLister is a ContainerLister that lists containers using the docker CLI +type dockerCLIContainerLister struct{} + +var _ ContainerLister = &dockerCLIContainerLister{} + +func (*dockerCLIContainerLister) List(ctx context.Context) ([]codersdk.WorkspaceAgentContainer, error) { + var buf bytes.Buffer + // List all container IDs, one per line, with no truncation + cmd := exec.CommandContext(ctx, "docker", "ps", "--all", "--quiet", "--no-trunc") + cmd.Stdout = &buf + if err := cmd.Run(); err != nil { + return nil, xerrors.Errorf("run docker ps: %w", err) + } + + // the output is returned with a single item per line, so we have to decode it + // line-by-line + ids := make([]string, 0) + for _, line := range strings.Split(buf.String(), "\n") { + tmp := strings.TrimSpace(line) + if tmp == "" { + continue + } + ids = append(ids, tmp) + } + + // now we can get the detailed information for each container + // Run `docker inspect` on each container ID + buf.Reset() + execArgs := []string{"inspect"} + execArgs = append(execArgs, ids...) + cmd = exec.CommandContext(ctx, "docker", execArgs...) + cmd.Stdout = &buf + if err := cmd.Run(); err != nil { + return nil, xerrors.Errorf("run docker inspect: %w", err) + } + + // out := make([]codersdk.WorkspaceAgentContainer, 0) + ins := make([]dockerInspect, 0) + if err := json.NewDecoder(&buf).Decode(&ins); err != nil { + return nil, xerrors.Errorf("decode docker inspect output: %w", err) + } + + out := make([]codersdk.WorkspaceAgentContainer, 0) + for _, in := range ins { + out = append(out, convertDockerInspect(in)) + } + + return out, nil +} + +// To avoid a direct dependency on the Docker API, we use the docker CLI +// to fetch information about containers. +type dockerInspect struct { + ID string `json:"Id"` + Created time.Time `json:"Created"` + Name string `json:"Name"` + Config dockerInspectConfig `json:"Config"` + State dockerInspectState `json:"State"` +} + +type dockerInspectConfig struct { + ExposedPorts map[string]struct{} `json:"ExposedPorts"` + Image string `json:"Image"` + Labels map[string]string `json:"Labels"` + Volumes map[string]struct{} `json:"Volumes"` +} + +type dockerInspectState struct { + Running bool `json:"Running"` + ExitCode int `json:"ExitCode"` + Error string `json:"Error"` +} + +func (dis dockerInspectState) String() string { + if dis.Running { + return "running" + } + var sb strings.Builder + _, _ = sb.WriteString("exited") + if dis.ExitCode != 0 { + _, _ = sb.WriteString(fmt.Sprintf(" with code %d", dis.ExitCode)) + } else { + _, _ = sb.WriteString(" successfully") + } + if dis.Error != "" { + _, _ = sb.WriteString(fmt.Sprintf(": %s", dis.Error)) + } + return sb.String() +} + +func convertDockerInspect(in dockerInspect) codersdk.WorkspaceAgentContainer { + out := codersdk.WorkspaceAgentContainer{ + CreatedAt: in.Created, + // Remove the leading slash from the container name + FriendlyName: strings.TrimPrefix(in.Name, "/"), + ID: in.ID, + Image: in.Config.Image, + Labels: in.Config.Labels, + Ports: make([]codersdk.WorkspaceAgentListeningPort, 0), + Running: in.State.Running, + Status: in.State.String(), + Volumes: make(map[string]string), + } + + // sort the keys for deterministic output + portKeys := maps.Keys(in.Config.ExposedPorts) + sort.Strings(portKeys) + for _, p := range portKeys { + port, network, err := convertDockerPort(p) + if err != nil { + // ignore invalid ports + continue + } + out.Ports = append(out.Ports, codersdk.WorkspaceAgentListeningPort{ + Network: network, + Port: port, + }) + } + + // sort the keys for deterministic output + volKeys := maps.Keys(in.Config.Volumes) + sort.Strings(volKeys) + for _, k := range volKeys { + v0, v1 := convertDockerVolume(k) + out.Volumes[v0] = v1 + } + + return out +} + +// convertDockerPort converts a Docker port string to a port number and network +// example: "8080/tcp" -> 8080, "tcp" +// +// "8080" -> 8080, "tcp" +func convertDockerPort(in string) (uint16, string, error) { + parts := strings.Split(in, "/") + switch len(parts) { + case 0: + return 0, "", xerrors.Errorf("invalid port format: %s", in) + case 1: + // assume it's a TCP port + p, err := strconv.Atoi(parts[0]) + if err != nil { + return 0, "", xerrors.Errorf("invalid port format: %s", in) + } + return uint16(p), "tcp", nil + default: + p, err := strconv.Atoi(parts[0]) + if err != nil { + return 0, "", xerrors.Errorf("invalid port format: %s", in) + } + return uint16(p), parts[1], nil + } +} + +// convertDockerVolume converts a Docker volume string to a host path and +// container path. If the host path is not specified, the container path is used +// as the host path. +// example: "/host/path=/container/path" -> "/host/path", "/container/path" +// +// "/container/path" -> "/container/path", "/container/path" +func convertDockerVolume(in string) (hostPath, containerPath string) { + parts := strings.Split(in, "=") + switch len(parts) { + case 0: + return in, in + case 1: + return parts[0], parts[0] + default: + return parts[0], parts[1] + } +} diff --git a/agent/containers_internal_test.go b/agent/containers_internal_test.go new file mode 100644 index 0000000000000..a77bc65fb3310 --- /dev/null +++ b/agent/containers_internal_test.go @@ -0,0 +1,206 @@ +package agent + +import ( + "runtime" + "strings" + "testing" + "time" + + "github.com/google/uuid" + "github.com/ory/dockertest/v3" + "github.com/ory/dockertest/v3/docker" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/testutil" + "github.com/coder/quartz" +) + +// TestDockerCLIContainerLister tests the happy path of the +// dockerCLIContainerLister.List method. It starts a container with a known +// label, lists the containers, and verifies that the expected container is +// returned. The container is deleted after the test is complete. +func TestDockerCLIContainerLister(t *testing.T) { + t.Parallel() + if runtime.GOOS != "linux" { + t.Skip("creating containers on non-linux runners is slow and flaky") + } + + pool, err := dockertest.NewPool("") + require.NoError(t, err, "Could not connect to docker") + testLabelValue := uuid.New().String() + ct, err := pool.RunWithOptions(&dockertest.RunOptions{ + Repository: "busybox", + Tag: "latest", + Cmd: []string{"sleep", "infnity"}, + Labels: map[string]string{"com.coder.test": testLabelValue}, + }, func(config *docker.HostConfig) { + config.AutoRemove = true + config.RestartPolicy = docker.RestartPolicy{Name: "no"} + }) + require.NoError(t, err, "Could not start test docker container") + t.Cleanup(func() { + assert.NoError(t, pool.Purge(ct), "Could not purge resource") + }) + + dcl := dockerCLIContainerLister{} + ctx := testutil.Context(t, testutil.WaitShort) + actual, err := dcl.List(ctx) + require.NoError(t, err, "Could not list containers") + var found bool + for _, foundContainer := range actual { + if foundContainer.ID == ct.Container.ID { + found = true + assert.Equal(t, ct.Container.Created, foundContainer.CreatedAt) + // ory/dockertest pre-pends a forward slash to the container name. + assert.Equal(t, strings.TrimPrefix(ct.Container.Name, "/"), foundContainer.FriendlyName) + // ory/dockertest returns the sha256 digest of the image. + assert.Equal(t, "busybox:latest", foundContainer.Image) + assert.Equal(t, ct.Container.Config.Labels, foundContainer.Labels) + assert.True(t, foundContainer.Running) + assert.Equal(t, "running", foundContainer.Status) + assert.Len(t, foundContainer.Ports, 0) + assert.Len(t, foundContainer.Volumes, 0) + break + } + } + assert.True(t, found, "Expected to find container with label 'com.coder.test=%s'", testLabelValue) +} + +// TestContainersHandler tests the containersHandler.getContainers method using +// a mock implementation. It specifically tests caching behavior. +func TestContainersHandler(t *testing.T) { + t.Parallel() + + t.Run("list", func(t *testing.T) { + t.Parallel() + + fakeCt := fakeContainer(t) + fakeCt2 := fakeContainer(t) + + // 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.WorkspaceAgentContainer + // 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(*MockContainerLister) + // expected result + expected []codersdk.WorkspaceAgentContainer + // expected error + expectedErr string + }{ + { + name: "no cache", + setupMock: func(mcl *MockContainerLister) { + mcl.EXPECT().List(gomock.Any()).Return([]codersdk.WorkspaceAgentContainer{fakeCt}, nil).AnyTimes() + }, + expected: []codersdk.WorkspaceAgentContainer{fakeCt}, + }, + { + name: "no data", + cacheData: nil, + cacheAge: 2 * time.Second, + cacheDur: time.Second, + setupMock: func(mcl *MockContainerLister) { + mcl.EXPECT().List(gomock.Any()).Return([]codersdk.WorkspaceAgentContainer{fakeCt}, nil).AnyTimes() + }, + expected: []codersdk.WorkspaceAgentContainer{fakeCt}, + }, + { + name: "cached data", + cacheAge: time.Second, + cacheData: []codersdk.WorkspaceAgentContainer{fakeCt}, + cacheDur: 2 * time.Second, + expected: []codersdk.WorkspaceAgentContainer{fakeCt}, + }, + { + name: "lister error", + setupMock: func(mcl *MockContainerLister) { + mcl.EXPECT().List(gomock.Any()).Return(nil, assert.AnError).AnyTimes() + }, + expectedErr: assert.AnError.Error(), + }, + { + name: "stale cache", + cacheAge: 2 * time.Second, + cacheData: []codersdk.WorkspaceAgentContainer{fakeCt}, + cacheDur: time.Second, + setupMock: func(mcl *MockContainerLister) { + mcl.EXPECT().List(gomock.Any()).Return([]codersdk.WorkspaceAgentContainer{fakeCt2}, nil).AnyTimes() + }, + expected: []codersdk.WorkspaceAgentContainer{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 = NewMockContainerLister(ctrl) + now = time.Now().UTC() + ch = containersHandler{ + cacheDuration: tc.cacheDur, + cl: mockLister, + clock: clk, + containers: tc.cacheData, + } + ) + if tc.cacheAge != 0 { + ch.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 := ch.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.WorkspaceAgentListeningPort{ + { + Network: "tcp", + Port: testutil.RandomPortNoListen(t), + }, + }, + Status: testutil.MustRandString(t, 10), + Volumes: map[string]string{testutil.GetRandomName(t): testutil.GetRandomName(t)}, + } + for _, m := range mut { + m(&ct) + } + return ct +} diff --git a/agent/containers_mock.go b/agent/containers_mock.go new file mode 100644 index 0000000000000..0c723ff9cce06 --- /dev/null +++ b/agent/containers_mock.go @@ -0,0 +1,56 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/coder/coder/v2/agent (interfaces: ContainerLister) +// +// Generated by this command: +// +// mockgen -destination ./containers_mock.go -package agent . ContainerLister +// + +// Package agent is a generated GoMock package. +package agent + +import ( + context "context" + reflect "reflect" + + codersdk "github.com/coder/coder/v2/codersdk" + gomock "go.uber.org/mock/gomock" +) + +// MockContainerLister is a mock of ContainerLister interface. +type MockContainerLister struct { + ctrl *gomock.Controller + recorder *MockContainerListerMockRecorder +} + +// MockContainerListerMockRecorder is the mock recorder for MockContainerLister. +type MockContainerListerMockRecorder struct { + mock *MockContainerLister +} + +// NewMockContainerLister creates a new mock instance. +func NewMockContainerLister(ctrl *gomock.Controller) *MockContainerLister { + mock := &MockContainerLister{ctrl: ctrl} + mock.recorder = &MockContainerListerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockContainerLister) EXPECT() *MockContainerListerMockRecorder { + return m.recorder +} + +// List mocks base method. +func (m *MockContainerLister) List(arg0 context.Context) ([]codersdk.WorkspaceAgentContainer, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "List", arg0) + ret0, _ := ret[0].([]codersdk.WorkspaceAgentContainer) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// List indicates an expected call of List. +func (mr *MockContainerListerMockRecorder) List(arg0 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockContainerLister)(nil).List), arg0) +} diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 98c694ab4175d..b2226f6d3e30c 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -7854,6 +7854,49 @@ const docTemplate = `{ } } }, + "/workspaceagents/{workspaceagent}/containers": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Agents" + ], + "summary": "Get running containers for workspace agent", + "operationId": "get-running-containers-for-workspace-agent", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Workspace agent ID", + "name": "workspaceagent", + "in": "path", + "required": true + }, + { + "type": "string", + "format": "key=value", + "description": "Labels", + "name": "label", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.WorkspaceAgentListContainersResponse" + } + } + } + } + }, "/workspaceagents/{workspaceagent}/coordinate": { "get": { "security": [ @@ -15608,6 +15651,57 @@ const docTemplate = `{ } } }, + "codersdk.WorkspaceAgentContainer": { + "type": "object", + "properties": { + "created_at": { + "description": "CreatedAt is the time the container was created.", + "type": "string", + "format": "date-time" + }, + "id": { + "description": "ID is the unique identifier of the container.", + "type": "string" + }, + "image": { + "description": "Image is the name of the container image.", + "type": "string" + }, + "labels": { + "description": "Labels is a map of key-value pairs of container labels.", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "name": { + "description": "FriendlyName is the human-readable name of the container.", + "type": "string" + }, + "ports": { + "description": "Ports includes ports exposed by the container.", + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.WorkspaceAgentListeningPort" + } + }, + "running": { + "description": "Running is true if the container is currently running.", + "type": "boolean" + }, + "status": { + "description": "Status is the current status of the container. This is somewhat\nimplementation-dependent, but should generally be a human-readable\nstring.", + "type": "string" + }, + "volumes": { + "description": "Volumes is a map of \"things\" mounted into the container. Again, this\nis somewhat implementation-dependent.", + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + }, "codersdk.WorkspaceAgentHealth": { "type": "object", "properties": { @@ -15648,6 +15742,17 @@ const docTemplate = `{ "WorkspaceAgentLifecycleOff" ] }, + "codersdk.WorkspaceAgentListContainersResponse": { + "type": "object", + "properties": { + "containers": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.WorkspaceAgentContainer" + } + } + } + }, "codersdk.WorkspaceAgentListeningPort": { "type": "object", "properties": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index afe36a8389899..2d0989130b88e 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -6930,6 +6930,45 @@ } } }, + "/workspaceagents/{workspaceagent}/containers": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Agents"], + "summary": "Get running containers for workspace agent", + "operationId": "get-running-containers-for-workspace-agent", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Workspace agent ID", + "name": "workspaceagent", + "in": "path", + "required": true + }, + { + "type": "string", + "format": "key=value", + "description": "Labels", + "name": "label", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.WorkspaceAgentListContainersResponse" + } + } + } + } + }, "/workspaceagents/{workspaceagent}/coordinate": { "get": { "security": [ @@ -14215,6 +14254,57 @@ } } }, + "codersdk.WorkspaceAgentContainer": { + "type": "object", + "properties": { + "created_at": { + "description": "CreatedAt is the time the container was created.", + "type": "string", + "format": "date-time" + }, + "id": { + "description": "ID is the unique identifier of the container.", + "type": "string" + }, + "image": { + "description": "Image is the name of the container image.", + "type": "string" + }, + "labels": { + "description": "Labels is a map of key-value pairs of container labels.", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "name": { + "description": "FriendlyName is the human-readable name of the container.", + "type": "string" + }, + "ports": { + "description": "Ports includes ports exposed by the container.", + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.WorkspaceAgentListeningPort" + } + }, + "running": { + "description": "Running is true if the container is currently running.", + "type": "boolean" + }, + "status": { + "description": "Status is the current status of the container. This is somewhat\nimplementation-dependent, but should generally be a human-readable\nstring.", + "type": "string" + }, + "volumes": { + "description": "Volumes is a map of \"things\" mounted into the container. Again, this\nis somewhat implementation-dependent.", + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + }, "codersdk.WorkspaceAgentHealth": { "type": "object", "properties": { @@ -14255,6 +14345,17 @@ "WorkspaceAgentLifecycleOff" ] }, + "codersdk.WorkspaceAgentListContainersResponse": { + "type": "object", + "properties": { + "containers": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.WorkspaceAgentContainer" + } + } + } + }, "codersdk.WorkspaceAgentListeningPort": { "type": "object", "properties": { diff --git a/coderd/coderd.go b/coderd/coderd.go index be558797389b9..df4c66b2beb13 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1212,6 +1212,7 @@ func New(options *Options) *API { r.Get("/listening-ports", api.workspaceAgentListeningPorts) r.Get("/connection", api.workspaceAgentConnection) r.Get("/coordinate", api.workspaceAgentClientCoordinate) + r.Get("/containers", api.workspaceAgentListContainers) // PTY is part of workspaceAppServer. }) diff --git a/coderd/util/maps/maps.go b/coderd/util/maps/maps.go new file mode 100644 index 0000000000000..6e3e7f698d657 --- /dev/null +++ b/coderd/util/maps/maps.go @@ -0,0 +1,29 @@ +package maps + +import ( + "sort" + + "golang.org/x/exp/constraints" +) + +// Subset returns true if all the keys of a are present +// in b and have the same values. +func Subset[T, U comparable](a, b map[T]U) bool { + for ka, va := range a { + if vb, ok := b[ka]; !ok { + return false + } else if va != vb { + return false + } + } + return true +} + +// SortedKeys returns the keys of m in sorted order. +func SortedKeys[T constraints.Ordered](m map[T]any) (keys []T) { + for k := range m { + keys = append(keys, k) + } + sort.Slice(keys, func(i, j int) bool { return keys[i] < keys[j] }) + return keys +} diff --git a/coderd/util/maps/maps_test.go b/coderd/util/maps/maps_test.go new file mode 100644 index 0000000000000..1858d6467e89a --- /dev/null +++ b/coderd/util/maps/maps_test.go @@ -0,0 +1,64 @@ +package maps_test + +import ( + "strconv" + "testing" + + "github.com/coder/coder/v2/coderd/util/maps" +) + +func TestSubset(t *testing.T) { + t.Parallel() + + for idx, tc := range []struct { + a map[string]string + b map[string]string + expected bool + }{ + { + a: nil, + b: nil, + expected: true, + }, + { + a: map[string]string{}, + b: map[string]string{}, + expected: true, + }, + { + a: map[string]string{"a": "1", "b": "2"}, + b: map[string]string{"a": "1", "b": "2"}, + expected: true, + }, + { + a: map[string]string{"a": "1", "b": "2"}, + b: map[string]string{"a": "1"}, + expected: false, + }, + { + a: map[string]string{"a": "1"}, + b: map[string]string{"a": "1", "b": "2"}, + expected: true, + }, + { + a: map[string]string{"a": "1", "b": "2"}, + b: map[string]string{}, + expected: false, + }, + { + a: map[string]string{"a": "1", "b": "2"}, + b: map[string]string{"a": "1", "b": "3"}, + expected: false, + }, + } { + tc := tc + t.Run("#"+strconv.Itoa(idx), func(t *testing.T) { + t.Parallel() + + actual := maps.Subset(tc.a, tc.b) + if actual != tc.expected { + t.Errorf("expected %v, got %v", tc.expected, actual) + } + }) + } +} diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index 026c3581ff14d..7abe68663236a 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -34,6 +34,7 @@ import ( "github.com/coder/coder/v2/coderd/jwtutils" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/rbac/policy" + maputil "github.com/coder/coder/v2/coderd/util/maps" "github.com/coder/coder/v2/coderd/wspubsub" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/agentsdk" @@ -678,6 +679,92 @@ func (api *API) workspaceAgentListeningPorts(rw http.ResponseWriter, r *http.Req httpapi.Write(ctx, rw, http.StatusOK, portsResponse) } +// @Summary Get running containers for workspace agent +// @ID get-running-containers-for-workspace-agent +// @Security CoderSessionToken +// @Produce json +// @Tags Agents +// @Param workspaceagent path string true "Workspace agent ID" format(uuid) +// @Param label query string true "Labels" format(key=value) +// @Success 200 {object} codersdk.WorkspaceAgentListContainersResponse +// @Router /workspaceagents/{workspaceagent}/containers [get] +func (api *API) workspaceAgentListContainers(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + workspaceAgent := httpmw.WorkspaceAgentParam(r) + + labelParam, ok := r.URL.Query()["label"] + if !ok { + labelParam = []string{} + } + labels := make(map[string]string) + for _, label := range labelParam { + kvs := strings.Split(label, "=") + if len(kvs) != 2 { + httpapi.Write(r.Context(), rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid label format", + Detail: "Labels must be in the format key=value", + }) + return + } + labels[kvs[0]] = kvs[1] + } + + // 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. + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + 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 + } + + agentConn, release, err := api.agentProvider.AgentConn(ctx, workspaceAgent.ID) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error dialing workspace agent.", + Detail: err.Error(), + }) + return + } + defer release() + + // Get a list of containers that the agent is able to detect + cts, err := agentConn.ListContainers(ctx) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching containers.", + Detail: err.Error(), + }) + return + } + + // Filter in-place by labels + filtered := slices.DeleteFunc(cts, func(ct codersdk.WorkspaceAgentContainer) bool { + return !maputil.Subset(labels, ct.Labels) + }) + + httpapi.Write(ctx, rw, http.StatusOK, codersdk.WorkspaceAgentListContainersResponse{Containers: filtered}) +} + // @Summary Get connection info for workspace agent // @ID get-connection-info-for-workspace-agent // @Security CoderSessionToken diff --git a/coderd/workspaceagents_test.go b/coderd/workspaceagents_test.go index c75b3f3ed53fc..c9b69e8cf5a91 100644 --- a/coderd/workspaceagents_test.go +++ b/coderd/workspaceagents_test.go @@ -16,6 +16,8 @@ import ( "github.com/go-jose/go-jose/v4/jwt" "github.com/google/uuid" + "github.com/ory/dockertest/v3" + "github.com/ory/dockertest/v3/docker" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/xerrors" @@ -1053,6 +1055,87 @@ func TestWorkspaceAgentListeningPorts(t *testing.T) { }) } +func TestWorkspaceAgentContainers(t *testing.T) { + t.Parallel() + + if runtime.GOOS != "linux" { + t.Skip("this test creates containers, which is flaky on non-linux runners") + } + + pool, err := dockertest.NewPool("") + require.NoError(t, err, "Could not connect to docker") + testLabels := map[string]string{ + "com.coder.test": uuid.New().String(), + } + ct, err := pool.RunWithOptions(&dockertest.RunOptions{ + Repository: "busybox", + Tag: "latest", + Cmd: []string{"sleep", "infnity"}, + Labels: testLabels, + }, func(config *docker.HostConfig) { + config.AutoRemove = true + config.RestartPolicy = docker.RestartPolicy{Name: "no"} + }) + require.NoError(t, err, "Could not start test docker container") + t.Cleanup(func() { + assert.NoError(t, pool.Purge(ct), "Could not purge resource") + }) + + // Start another container which we will expect to ignore. + ct2, err := pool.RunWithOptions(&dockertest.RunOptions{ + Repository: "busybox", + Tag: "latest", + Cmd: []string{"sleep", "infnity"}, + Labels: map[string]string{"com.coder.test": "ignoreme"}, + }, func(config *docker.HostConfig) { + config.AutoRemove = true + config.RestartPolicy = docker.RestartPolicy{Name: "no"} + }) + require.NoError(t, err, "Could not start second test docker container") + t.Cleanup(func() { + assert.NoError(t, pool.Purge(ct2), "Could not purge resource") + }) + + 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(_ *agent.Options) {}) + 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) + + // If we filter by testLabels, we should only get one container back. + res, err := client.WorkspaceAgentListContainers(ctx, agentID, testLabels) + require.NoError(t, err, "failed to list containers filtered by test label") + require.Len(t, res.Containers, 1, "expected exactly one container") + assert.Equal(t, ct.Container.ID, res.Containers[0].ID, "expected container ID to match") + assert.Equal(t, "busybox:latest", res.Containers[0].Image, "expected container image to match") + assert.Equal(t, ct.Container.Config.Labels, res.Containers[0].Labels, "expected container labels to match") + assert.Equal(t, strings.TrimPrefix(ct.Container.Name, "/"), res.Containers[0].FriendlyName, "expected container name to match") + assert.True(t, res.Containers[0].Running, "expected container to be running") + assert.Equal(t, "running", res.Containers[0].Status, "expected container status to be running") + + // List all containers and ensure we get at least both (there may be more). + res, err = client.WorkspaceAgentListContainers(ctx, agentID, nil) + require.NoError(t, err, "failed to list all containers") + require.NotEmpty(t, res.Containers, "expected to find containers") + var found []string + for _, c := range res.Containers { + found = append(found, c.ID) + } + require.Contains(t, found, ct.Container.ID, "expected to find first container without label filter") + require.Contains(t, found, ct2.Container.ID, "expected to find first container without label filter") +} + func TestWorkspaceAgentAppHealth(t *testing.T) { t.Parallel() client, db := coderdtest.NewWithDatabase(t, nil) diff --git a/codersdk/workspaceagents.go b/codersdk/workspaceagents.go index 4f04b70aee83c..543f435509cc1 100644 --- a/codersdk/workspaceagents.go +++ b/codersdk/workspaceagents.go @@ -392,6 +392,68 @@ func (c *Client) WorkspaceAgentListeningPorts(ctx context.Context, agentID uuid. return listeningPorts, json.NewDecoder(res.Body).Decode(&listeningPorts) } +// WorkspaceAgentContainer describes a container of some sort that is visible +// to the workspace agent. +type WorkspaceAgentContainer struct { + // CreatedAt is the time the container was created. + CreatedAt time.Time `json:"created_at" format:"date-time"` + // ID is the unique identifier of the container. + ID string `json:"id"` + // FriendlyName is the human-readable name of the container. + FriendlyName string `json:"name"` + // Image is the name of the container image. + Image string `json:"image"` + // Labels is a map of key-value pairs of container labels. + Labels map[string]string `json:"labels"` + // Running is true if the container is currently running. + Running bool `json:"running"` + // Ports includes ports exposed by the container. + Ports []WorkspaceAgentListeningPort `json:"ports"` + // Status is the current status of the container. This is somewhat + // implementation-dependent, but should generally be a human-readable + // string. + Status string `json:"status"` + // Volumes is a map of "things" mounted into the container. Again, this + // is somewhat implementation-dependent. + Volumes map[string]string `json:"volumes"` +} + +// WorkspaceAgentListContainersResponse is the response to the list containers +// request. +type WorkspaceAgentListContainersResponse struct { + Containers []WorkspaceAgentContainer `json:"containers"` +} + +// WorkspaceAgentContainersLabelFilter is a RequestOption for filtering +// listing containers by labels. +func WorkspaceAgentContainersLabelFilter(kvs map[string]string) RequestOption { + return func(r *http.Request) { + q := r.URL.Query() + for k, v := range kvs { + kv := fmt.Sprintf("%s=%s", k, v) + q.Add("label", kv) + } + r.URL.RawQuery = q.Encode() + } +} + +// WorkspaceAgentListContainers returns a list of containers that are currently +// running on a Docker daemon accessible to the workspace agent. +func (c *Client) WorkspaceAgentListContainers(ctx context.Context, agentID uuid.UUID, labels map[string]string) (WorkspaceAgentListContainersResponse, error) { + lf := WorkspaceAgentContainersLabelFilter(labels) + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspaceagents/%s/containers", agentID), nil, lf) + if err != nil { + return WorkspaceAgentListContainersResponse{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return WorkspaceAgentListContainersResponse{}, ReadBodyAsError(res) + } + var cr WorkspaceAgentListContainersResponse + + return cr, json.NewDecoder(res.Body).Decode(&cr) +} + //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 diff --git a/codersdk/workspacesdk/agentconn.go b/codersdk/workspacesdk/agentconn.go index 4c3a9539bbf55..6a2f313f71471 100644 --- a/codersdk/workspacesdk/agentconn.go +++ b/codersdk/workspacesdk/agentconn.go @@ -336,6 +336,22 @@ func (c *AgentConn) PrometheusMetrics(ctx context.Context) ([]byte, error) { return bs, nil } +// ListContainers returns a response from the agent's containers endpoint +func (c *AgentConn) ListContainers(ctx context.Context) ([]codersdk.WorkspaceAgentContainer, error) { + ctx, span := tracing.StartSpan(ctx) + defer span.End() + res, err := c.apiRequest(ctx, http.MethodGet, "/api/v0/containers", nil) + if err != nil { + return nil, xerrors.Errorf("do request: %w", err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return nil, codersdk.ReadBodyAsError(res) + } + var resp []codersdk.WorkspaceAgentContainer + return resp, json.NewDecoder(res.Body).Decode(&resp) +} + // apiRequest makes a request to the workspace agent's HTTP API server. func (c *AgentConn) apiRequest(ctx context.Context, method, path string, body io.Reader) (*http.Response, error) { ctx, span := tracing.StartSpan(ctx) diff --git a/docs/reference/api/agents.md b/docs/reference/api/agents.md index 22ebe7f35530f..7387a915c5cea 100644 --- a/docs/reference/api/agents.md +++ b/docs/reference/api/agents.md @@ -638,6 +638,68 @@ curl -X GET http://coder-server:8080/api/v2/workspaceagents/{workspaceagent}/con To perform this operation, you must be authenticated. [Learn more](authentication.md). +## Get running containers for workspace agent + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/workspaceagents/{workspaceagent}/containers?label=string \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /workspaceagents/{workspaceagent}/containers` + +### Parameters + +| Name | In | Type | Required | Description | +|------------------|-------|-------------------|----------|--------------------| +| `workspaceagent` | path | string(uuid) | true | Workspace agent ID | +| `label` | query | string(key=value) | true | Labels | + +### Example responses + +> 200 Response + +```json +{ + "containers": [ + { + "created_at": "2019-08-24T14:15:22Z", + "id": "string", + "image": "string", + "labels": { + "property1": "string", + "property2": "string" + }, + "name": "string", + "ports": [ + { + "network": "string", + "port": 0, + "process_name": "string" + } + ], + "running": true, + "status": "string", + "volumes": { + "property1": "string", + "property2": "string" + } + } + ] +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +|--------|---------------------------------------------------------|-------------|----------------------------------------------------------------------------------------------------------| +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.WorkspaceAgentListContainersResponse](schemas.md#codersdkworkspaceagentlistcontainersresponse) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Coordinate workspace agent ### Code samples diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 082b3f3a1f19f..f15f49e3c042d 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -7561,6 +7561,50 @@ If the schedule is empty, the user will be updated to use the default schedule.| | `updated_at` | string | false | | | | `version` | string | false | | | +## codersdk.WorkspaceAgentContainer + +```json +{ + "created_at": "2019-08-24T14:15:22Z", + "id": "string", + "image": "string", + "labels": { + "property1": "string", + "property2": "string" + }, + "name": "string", + "ports": [ + { + "network": "string", + "port": 0, + "process_name": "string" + } + ], + "running": true, + "status": "string", + "volumes": { + "property1": "string", + "property2": "string" + } +} +``` + +### 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.WorkspaceAgentListeningPort](#codersdkworkspaceagentlisteningport) | 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.WorkspaceAgentHealth ```json @@ -7599,6 +7643,44 @@ If the schedule is empty, the user will be updated to use the default schedule.| | `shutdown_error` | | `off` | +## codersdk.WorkspaceAgentListContainersResponse + +```json +{ + "containers": [ + { + "created_at": "2019-08-24T14:15:22Z", + "id": "string", + "image": "string", + "labels": { + "property1": "string", + "property2": "string" + }, + "name": "string", + "ports": [ + { + "network": "string", + "port": 0, + "process_name": "string" + } + ], + "running": true, + "status": "string", + "volumes": { + "property1": "string", + "property2": "string" + } + } + ] +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|--------------|-------------------------------------------------------------------------------|----------|--------------|-------------| +| `containers` | array of [codersdk.WorkspaceAgentContainer](#codersdkworkspaceagentcontainer) | false | | | + ## codersdk.WorkspaceAgentListeningPort ```json diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 2e7732c525c42..deee5cb8336f1 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -2926,6 +2926,19 @@ export interface WorkspaceAgent { readonly startup_script_behavior: WorkspaceAgentStartupScriptBehavior; } +// From codersdk/workspaceagents.go +export interface WorkspaceAgentContainer { + readonly created_at: string; + readonly id: string; + readonly name: string; + readonly image: string; + readonly labels: Record; + readonly running: boolean; + readonly ports: readonly WorkspaceAgentListeningPort[]; + readonly status: string; + readonly volumes: Record; +} + // From codersdk/workspaceagents.go export interface WorkspaceAgentHealth { readonly healthy: boolean; @@ -2956,6 +2969,11 @@ export const WorkspaceAgentLifecycles: WorkspaceAgentLifecycle[] = [ "starting", ]; +// From codersdk/workspaceagents.go +export interface WorkspaceAgentListContainersResponse { + readonly containers: readonly WorkspaceAgentContainer[]; +} + // From codersdk/workspaceagents.go export interface WorkspaceAgentListeningPort { readonly process_name: string; From b3fed458adbe535ba47bc4130e8a2a5827b33c53 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Wed, 5 Feb 2025 16:25:28 +0000 Subject: [PATCH 02/13] Apply suggestions from code review --- agent/containers_dockercli.go | 5 ----- 1 file changed, 5 deletions(-) diff --git a/agent/containers_dockercli.go b/agent/containers_dockercli.go index eefa02dcfbc7a..2c22db2b0acc8 100644 --- a/agent/containers_dockercli.go +++ b/agent/containers_dockercli.go @@ -31,8 +31,6 @@ func (*dockerCLIContainerLister) List(ctx context.Context) ([]codersdk.Workspace return nil, xerrors.Errorf("run docker ps: %w", err) } - // the output is returned with a single item per line, so we have to decode it - // line-by-line ids := make([]string, 0) for _, line := range strings.Split(buf.String(), "\n") { tmp := strings.TrimSpace(line) @@ -53,7 +51,6 @@ func (*dockerCLIContainerLister) List(ctx context.Context) ([]codersdk.Workspace return nil, xerrors.Errorf("run docker inspect: %w", err) } - // out := make([]codersdk.WorkspaceAgentContainer, 0) ins := make([]dockerInspect, 0) if err := json.NewDecoder(&buf).Decode(&ins); err != nil { return nil, xerrors.Errorf("decode docker inspect output: %w", err) @@ -149,7 +146,6 @@ func convertDockerInspect(in dockerInspect) codersdk.WorkspaceAgentContainer { // convertDockerPort converts a Docker port string to a port number and network // example: "8080/tcp" -> 8080, "tcp" -// // "8080" -> 8080, "tcp" func convertDockerPort(in string) (uint16, string, error) { parts := strings.Split(in, "/") @@ -176,7 +172,6 @@ func convertDockerPort(in string) (uint16, string, error) { // container path. If the host path is not specified, the container path is used // as the host path. // example: "/host/path=/container/path" -> "/host/path", "/container/path" -// // "/container/path" -> "/container/path", "/container/path" func convertDockerVolume(in string) (hostPath, containerPath string) { parts := strings.Split(in, "=") From 9342c69dd3dfdcaa19ef37b8106ee2a6dc444fcb Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Wed, 5 Feb 2025 20:13:16 +0000 Subject: [PATCH 03/13] lint --- agent/containers_dockercli.go | 2 ++ coderd/coderd.go | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/agent/containers_dockercli.go b/agent/containers_dockercli.go index 2c22db2b0acc8..ed6c1a2f7a43a 100644 --- a/agent/containers_dockercli.go +++ b/agent/containers_dockercli.go @@ -146,6 +146,7 @@ func convertDockerInspect(in dockerInspect) codersdk.WorkspaceAgentContainer { // convertDockerPort converts a Docker port string to a port number and network // example: "8080/tcp" -> 8080, "tcp" +// // "8080" -> 8080, "tcp" func convertDockerPort(in string) (uint16, string, error) { parts := strings.Split(in, "/") @@ -172,6 +173,7 @@ func convertDockerPort(in string) (uint16, string, error) { // container path. If the host path is not specified, the container path is used // as the host path. // example: "/host/path=/container/path" -> "/host/path", "/container/path" +// // "/container/path" -> "/container/path", "/container/path" func convertDockerVolume(in string) (hostPath, containerPath string) { parts := strings.Split(in, "=") diff --git a/coderd/coderd.go b/coderd/coderd.go index df4c66b2beb13..4603f78acc0d9 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1211,8 +1211,8 @@ func New(options *Options) *API { r.Get("/logs", api.workspaceAgentLogs) r.Get("/listening-ports", api.workspaceAgentListeningPorts) r.Get("/connection", api.workspaceAgentConnection) - r.Get("/coordinate", api.workspaceAgentClientCoordinate) r.Get("/containers", api.workspaceAgentListContainers) + r.Get("/coordinate", api.workspaceAgentClientCoordinate) // PTY is part of workspaceAppServer. }) From 9fe24099ff2a1c3ba618b4a8ab1331da01fb4686 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Thu, 6 Feb 2025 17:11:34 +0000 Subject: [PATCH 04/13] address code review comments --- agent/api.go | 2 +- agent/containers.go | 70 +++++++++---- agent/containers_dockercli.go | 110 +++++++++++++-------- agent/containers_internal_test.go | 154 +++++++++++++++++++++++++---- agent/containers_mock.go | 11 ++- coderd/apidoc/docs.go | 12 ++- coderd/apidoc/swagger.json | 12 ++- coderd/util/maps/maps.go | 4 +- coderd/workspaceagents.go | 11 ++- coderd/workspaceagents_test.go | 8 +- codersdk/workspaceagents.go | 20 ++-- codersdk/workspacesdk/agentconn.go | 8 +- docs/reference/api/agents.md | 3 + docs/reference/api/schemas.md | 12 ++- site/src/api/typesGenerated.ts | 5 +- 15 files changed, 325 insertions(+), 117 deletions(-) diff --git a/agent/api.go b/agent/api.go index bf8c11330753d..128df26df2f98 100644 --- a/agent/api.go +++ b/agent/api.go @@ -35,7 +35,7 @@ func (a *agent) apiHandler() http.Handler { ignorePorts: cpy, cacheDuration: cacheDuration, } - ch := &containersHandler{ + ch := &devcontainersHandler{ cacheDuration: defaultGetContainersCacheDuration, } promHandler := PrometheusMetricsHandler(a.prometheusRegistry, a.logger) diff --git a/agent/containers.go b/agent/containers.go index eccd41fb96aeb..c33943904a52c 100644 --- a/agent/containers.go +++ b/agent/containers.go @@ -4,7 +4,9 @@ package agent import ( "context" + "errors" "net/http" + "slices" "sync" "time" @@ -21,19 +23,29 @@ const ( getContainersTimeout = 5 * time.Second ) -type containersHandler struct { +type devcontainersHandler struct { cacheDuration time.Duration cl ContainerLister clock quartz.Clock - mu sync.Mutex // protects the below - containers []codersdk.WorkspaceAgentContainer + initLockOnce sync.Once // ensures we don't get a race when initializing lockCh + // lockCh protects the below fields. We use a channel instead of a mutex so we + // can handle cancellation properly. + lockCh chan struct{} + containers *codersdk.WorkspaceAgentListContainersResponse mtime time.Time } -func (ch *containersHandler) handler(rw http.ResponseWriter, r *http.Request) { +func (ch *devcontainersHandler) handler(rw http.ResponseWriter, r *http.Request) { ct, err := ch.getContainers(r.Context()) if err != nil { + if errors.Is(err, context.Canceled) { + httpapi.Write(r.Context(), rw, http.StatusRequestTimeout, codersdk.Response{ + Message: "Could not get containers.", + Detail: "Took too long to list containers.", + }) + return + } httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{ Message: "Could not get containers.", Detail: err.Error(), @@ -44,9 +56,21 @@ func (ch *containersHandler) handler(rw http.ResponseWriter, r *http.Request) { httpapi.Write(r.Context(), rw, http.StatusOK, ct) } -func (ch *containersHandler) getContainers(ctx context.Context) ([]codersdk.WorkspaceAgentContainer, error) { - ch.mu.Lock() - defer ch.mu.Unlock() +func (ch *devcontainersHandler) getContainers(ctx context.Context) (codersdk.WorkspaceAgentListContainersResponse, error) { + ch.initLockOnce.Do(func() { + if ch.lockCh == nil { + ch.lockCh = make(chan struct{}, 1) + } + }) + select { + case <-ctx.Done(): + return codersdk.WorkspaceAgentListContainersResponse{}, ctx.Err() + default: + ch.lockCh <- struct{}{} + } + defer func() { + <-ch.lockCh + }() // make zero-value usable if ch.cacheDuration == 0 { @@ -58,7 +82,7 @@ func (ch *containersHandler) getContainers(ctx context.Context) ([]codersdk.Work ch.cl = &dockerCLIContainerLister{} } if ch.containers == nil { - ch.containers = make([]codersdk.WorkspaceAgentContainer, 0) + ch.containers = &codersdk.WorkspaceAgentListContainersResponse{} } if ch.clock == nil { ch.clock = quartz.NewReal() @@ -66,26 +90,34 @@ func (ch *containersHandler) getContainers(ctx context.Context) ([]codersdk.Work now := ch.clock.Now() if now.Sub(ch.mtime) < ch.cacheDuration { - cpy := make([]codersdk.WorkspaceAgentContainer, len(ch.containers)) - copy(cpy, ch.containers) + // Return a copy of the cached data to avoid accidental modification by the caller. + cpy := codersdk.WorkspaceAgentListContainersResponse{ + Containers: slices.Clone(ch.containers.Containers), + } return cpy, nil } - cancelCtx, cancelFunc := context.WithTimeout(ctx, getContainersTimeout) - defer cancelFunc() - updated, err := ch.cl.List(cancelCtx) + timeoutCtx, timeoutCancel := context.WithTimeout(ctx, getContainersTimeout) + defer timeoutCancel() + updated, err := ch.cl.List(timeoutCtx) if err != nil { - return nil, xerrors.Errorf("get containers: %w", err) + return codersdk.WorkspaceAgentListContainersResponse{}, xerrors.Errorf("get containers: %w", err) } - ch.containers = updated + ch.containers = &updated ch.mtime = now - // return a copy - cpy := make([]codersdk.WorkspaceAgentContainer, len(ch.containers)) - copy(cpy, ch.containers) + // Return a copy of the cached data to avoid accidental modification by the + // caller. + cpy := codersdk.WorkspaceAgentListContainersResponse{ + Containers: slices.Clone(ch.containers.Containers), + } return cpy, nil } +// ContainerLister is an interface for listing containers visible to the +// workspace agent. type ContainerLister interface { - List(ctx context.Context) ([]codersdk.WorkspaceAgentContainer, error) + // 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) } diff --git a/agent/containers_dockercli.go b/agent/containers_dockercli.go index ed6c1a2f7a43a..4f3bccd405ded 100644 --- a/agent/containers_dockercli.go +++ b/agent/containers_dockercli.go @@ -1,6 +1,7 @@ package agent import ( + "bufio" "bytes" "context" "encoding/json" @@ -22,46 +23,68 @@ type dockerCLIContainerLister struct{} var _ ContainerLister = &dockerCLIContainerLister{} -func (*dockerCLIContainerLister) List(ctx context.Context) ([]codersdk.WorkspaceAgentContainer, error) { - var buf bytes.Buffer +func (*dockerCLIContainerLister) List(ctx context.Context) (codersdk.WorkspaceAgentListContainersResponse, error) { + var stdoutBuf, stderrBuf bytes.Buffer // List all container IDs, one per line, with no truncation cmd := exec.CommandContext(ctx, "docker", "ps", "--all", "--quiet", "--no-trunc") - cmd.Stdout = &buf + cmd.Stdout = &stdoutBuf + cmd.Stderr = &stderrBuf if err := cmd.Run(); err != nil { - return nil, xerrors.Errorf("run docker ps: %w", err) + // TODO(Cian): detect specific errors: + // - docker not installed + // - docker not running + // - no permissions to talk to docker + return codersdk.WorkspaceAgentListContainersResponse{}, xerrors.Errorf("run docker ps: %w: %q", err, strings.TrimSpace(stderrBuf.String())) } ids := make([]string, 0) - for _, line := range strings.Split(buf.String(), "\n") { - tmp := strings.TrimSpace(line) + scanner := bufio.NewScanner(&stdoutBuf) + for scanner.Scan() { + tmp := strings.TrimSpace(scanner.Text()) if tmp == "" { continue } ids = append(ids, tmp) } + if err := scanner.Err(); err != nil { + return codersdk.WorkspaceAgentListContainersResponse{}, xerrors.Errorf("scan docker ps output: %w", err) + } // now we can get the detailed information for each container // Run `docker inspect` on each container ID - buf.Reset() - execArgs := []string{"inspect"} - execArgs = append(execArgs, ids...) - cmd = exec.CommandContext(ctx, "docker", execArgs...) - cmd.Stdout = &buf + stdoutBuf.Reset() + stderrBuf.Reset() + // nolint: gosec // We are not executing user input, these IDs come from + // `docker ps`. + cmd = exec.CommandContext(ctx, "docker", append([]string{"inspect"}, ids...)...) + cmd.Stdout = &stdoutBuf + cmd.Stderr = &stderrBuf if err := cmd.Run(); err != nil { - return nil, xerrors.Errorf("run docker inspect: %w", err) + return codersdk.WorkspaceAgentListContainersResponse{}, xerrors.Errorf("run docker inspect: %w: %s", err, strings.TrimSpace(stderrBuf.String())) } - ins := make([]dockerInspect, 0) - if err := json.NewDecoder(&buf).Decode(&ins); err != nil { - return nil, xerrors.Errorf("decode docker inspect output: %w", err) + // NOTE: There is an unavoidable potential race condition where a + // container is removed between `docker ps` and `docker inspect`. + // In this case, stderr will contain an error message but stdout + // 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. + ins := make([]dockerInspect, 0, len(ids)) + if err := json.NewDecoder(&stdoutBuf).Decode(&ins); err != nil { + // However, if we just get invalid JSON, we should absolutely return an error. + return codersdk.WorkspaceAgentListContainersResponse{}, xerrors.Errorf("decode docker inspect output: %w", err) } - out := make([]codersdk.WorkspaceAgentContainer, 0) - for _, in := range ins { - out = append(out, convertDockerInspect(in)) + res := codersdk.WorkspaceAgentListContainersResponse{ + Containers: make([]codersdk.WorkspaceAgentDevcontainer, len(ins)), + } + for idx, in := range ins { + out, warns := convertDockerInspect(in) + res.Warnings = append(res.Warnings, warns...) + res.Containers[idx] = out } - return out, nil + return res, nil } // To avoid a direct dependency on the Docker API, we use the docker CLI @@ -104,44 +127,47 @@ func (dis dockerInspectState) String() string { return sb.String() } -func convertDockerInspect(in dockerInspect) codersdk.WorkspaceAgentContainer { - out := codersdk.WorkspaceAgentContainer{ +func convertDockerInspect(in dockerInspect) (codersdk.WorkspaceAgentDevcontainer, []string) { + var warns []string + out := codersdk.WorkspaceAgentDevcontainer{ CreatedAt: in.Created, // Remove the leading slash from the container name FriendlyName: strings.TrimPrefix(in.Name, "/"), ID: in.ID, Image: in.Config.Image, Labels: in.Config.Labels, - Ports: make([]codersdk.WorkspaceAgentListeningPort, 0), + Ports: make([]codersdk.WorkspaceAgentListeningPort, 0, len(in.Config.ExposedPorts)), Running: in.State.Running, Status: in.State.String(), - Volumes: make(map[string]string), + Volumes: make(map[string]string, len(in.Config.Volumes)), } // sort the keys for deterministic output portKeys := maps.Keys(in.Config.ExposedPorts) sort.Strings(portKeys) for _, p := range portKeys { - port, network, err := convertDockerPort(p) - if err != nil { - // ignore invalid ports - continue + if port, network, err := convertDockerPort(p); err != nil { + warns = append(warns, err.Error()) + } else { + out.Ports = append(out.Ports, codersdk.WorkspaceAgentListeningPort{ + Network: network, + Port: port, + }) } - out.Ports = append(out.Ports, codersdk.WorkspaceAgentListeningPort{ - Network: network, - Port: port, - }) } // sort the keys for deterministic output volKeys := maps.Keys(in.Config.Volumes) sort.Strings(volKeys) for _, k := range volKeys { - v0, v1 := convertDockerVolume(k) - out.Volumes[v0] = v1 + if v0, v1, err := convertDockerVolume(k); err != nil { + warns = append(warns, err.Error()) + } else { + out.Volumes[v0] = v1 + } } - return out + return out, warns } // convertDockerPort converts a Docker port string to a port number and network @@ -151,8 +177,6 @@ func convertDockerInspect(in dockerInspect) codersdk.WorkspaceAgentContainer { func convertDockerPort(in string) (uint16, string, error) { parts := strings.Split(in, "/") switch len(parts) { - case 0: - return 0, "", xerrors.Errorf("invalid port format: %s", in) case 1: // assume it's a TCP port p, err := strconv.Atoi(parts[0]) @@ -160,12 +184,14 @@ func convertDockerPort(in string) (uint16, string, error) { return 0, "", xerrors.Errorf("invalid port format: %s", in) } return uint16(p), "tcp", nil - default: + case 2: p, err := strconv.Atoi(parts[0]) if err != nil { return 0, "", xerrors.Errorf("invalid port format: %s", in) } return uint16(p), parts[1], nil + default: + return 0, "", xerrors.Errorf("invalid port format: %s", in) } } @@ -175,14 +201,14 @@ func convertDockerPort(in string) (uint16, string, error) { // example: "/host/path=/container/path" -> "/host/path", "/container/path" // // "/container/path" -> "/container/path", "/container/path" -func convertDockerVolume(in string) (hostPath, containerPath string) { +func convertDockerVolume(in string) (hostPath, containerPath string, err error) { parts := strings.Split(in, "=") switch len(parts) { - case 0: - return in, in case 1: - return parts[0], parts[0] + return parts[0], parts[0], nil + case 2: + return parts[0], parts[1], nil default: - return parts[0], parts[1] + return "", "", xerrors.Errorf("invalid volume format: %s", in) } } diff --git a/agent/containers_internal_test.go b/agent/containers_internal_test.go index a77bc65fb3310..6f9e15cb0b9fb 100644 --- a/agent/containers_internal_test.go +++ b/agent/containers_internal_test.go @@ -1,6 +1,7 @@ package agent import ( + "os/exec" "runtime" "strings" "testing" @@ -28,6 +29,11 @@ func TestDockerCLIContainerLister(t *testing.T) { t.Skip("creating containers on non-linux runners is slow and flaky") } + // Conditionally skip if Docker is not available. + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker not found in PATH") + } + pool, err := dockertest.NewPool("") require.NoError(t, err, "Could not connect to docker") testLabelValue := uuid.New().String() @@ -42,15 +48,16 @@ func TestDockerCLIContainerLister(t *testing.T) { }) require.NoError(t, err, "Could not start test docker container") t.Cleanup(func() { - assert.NoError(t, pool.Purge(ct), "Could not purge resource") + assert.NoError(t, pool.Purge(ct), "Could not purge resource %q", ct.Container.Name) }) dcl := dockerCLIContainerLister{} ctx := testutil.Context(t, testutil.WaitShort) actual, err := dcl.List(ctx) require.NoError(t, err, "Could not list containers") + require.Empty(t, actual.Warnings, "Expected no warnings") var found bool - for _, foundContainer := range actual { + for _, foundContainer := range actual.Containers { if foundContainer.ID == ct.Container.ID { found = true assert.Equal(t, ct.Container.Created, foundContainer.CreatedAt) @@ -79,12 +86,15 @@ func TestContainersHandler(t *testing.T) { fakeCt := fakeContainer(t) fakeCt2 := fakeContainer(t) + makeResponse := func(cts ...codersdk.WorkspaceAgentDevcontainer) 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.WorkspaceAgentContainer + cacheData codersdk.WorkspaceAgentListContainersResponse // duration of cache cacheDur time.Duration // relative age of the cached data @@ -92,50 +102,50 @@ func TestContainersHandler(t *testing.T) { // function to set up expectations for the mock setupMock func(*MockContainerLister) // expected result - expected []codersdk.WorkspaceAgentContainer + expected codersdk.WorkspaceAgentListContainersResponse // expected error expectedErr string }{ { name: "no cache", setupMock: func(mcl *MockContainerLister) { - mcl.EXPECT().List(gomock.Any()).Return([]codersdk.WorkspaceAgentContainer{fakeCt}, nil).AnyTimes() + mcl.EXPECT().List(gomock.Any()).Return(makeResponse(fakeCt), nil).AnyTimes() }, - expected: []codersdk.WorkspaceAgentContainer{fakeCt}, + expected: makeResponse(fakeCt), }, { name: "no data", - cacheData: nil, + cacheData: makeResponse(), cacheAge: 2 * time.Second, cacheDur: time.Second, setupMock: func(mcl *MockContainerLister) { - mcl.EXPECT().List(gomock.Any()).Return([]codersdk.WorkspaceAgentContainer{fakeCt}, nil).AnyTimes() + mcl.EXPECT().List(gomock.Any()).Return(makeResponse(fakeCt), nil).AnyTimes() }, - expected: []codersdk.WorkspaceAgentContainer{fakeCt}, + expected: makeResponse(fakeCt), }, { name: "cached data", cacheAge: time.Second, - cacheData: []codersdk.WorkspaceAgentContainer{fakeCt}, + cacheData: makeResponse(fakeCt), cacheDur: 2 * time.Second, - expected: []codersdk.WorkspaceAgentContainer{fakeCt}, + expected: makeResponse(fakeCt), }, { name: "lister error", setupMock: func(mcl *MockContainerLister) { - mcl.EXPECT().List(gomock.Any()).Return(nil, assert.AnError).AnyTimes() + mcl.EXPECT().List(gomock.Any()).Return(makeResponse(), assert.AnError).AnyTimes() }, expectedErr: assert.AnError.Error(), }, { name: "stale cache", cacheAge: 2 * time.Second, - cacheData: []codersdk.WorkspaceAgentContainer{fakeCt}, + cacheData: makeResponse(fakeCt), cacheDur: time.Second, setupMock: func(mcl *MockContainerLister) { - mcl.EXPECT().List(gomock.Any()).Return([]codersdk.WorkspaceAgentContainer{fakeCt2}, nil).AnyTimes() + mcl.EXPECT().List(gomock.Any()).Return(makeResponse(fakeCt2), nil).AnyTimes() }, - expected: []codersdk.WorkspaceAgentContainer{fakeCt2}, + expected: makeResponse(fakeCt2), }, } { tc := tc @@ -147,11 +157,11 @@ func TestContainersHandler(t *testing.T) { ctrl = gomock.NewController(t) mockLister = NewMockContainerLister(ctrl) now = time.Now().UTC() - ch = containersHandler{ + ch = devcontainersHandler{ cacheDuration: tc.cacheDur, cl: mockLister, clock: clk, - containers: tc.cacheData, + containers: &tc.cacheData, } ) if tc.cacheAge != 0 { @@ -179,9 +189,115 @@ func TestContainersHandler(t *testing.T) { }) } -func fakeContainer(t *testing.T, mut ...func(*codersdk.WorkspaceAgentContainer)) codersdk.WorkspaceAgentContainer { +func TestConvertDockerPort(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + in string + expectPort uint16 + expectNetwork string + expectError string + }{ + { + name: "empty port", + in: "", + expectError: "invalid port", + }, + { + name: "valid tcp port", + in: "8080/tcp", + expectPort: 8080, + expectNetwork: "tcp", + }, + { + name: "valid udp port", + in: "8080/udp", + expectPort: 8080, + expectNetwork: "udp", + }, + { + name: "valid port no network", + in: "8080", + expectPort: 8080, + expectNetwork: "tcp", + }, + { + name: "invalid port", + in: "invalid/tcp", + expectError: "invalid port", + }, + { + name: "invalid port no network", + in: "invalid", + expectError: "invalid port", + }, + { + name: "multiple network", + in: "8080/tcp/udp", + expectError: "invalid port", + }, + } { + tc := tc // not needed anymore but makes the linter happy + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + actualPort, actualNetwork, actualErr := convertDockerPort(tc.in) + if tc.expectError != "" { + assert.Zero(t, actualPort, "expected no port") + assert.Empty(t, actualNetwork, "expected no network") + assert.ErrorContains(t, actualErr, tc.expectError) + } else { + assert.NoError(t, actualErr, "expected no error") + assert.Equal(t, tc.expectPort, actualPort, "expected port to match") + assert.Equal(t, tc.expectNetwork, actualNetwork, "expected network to match") + } + }) + } +} + +func TestConvertDockerVolume(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + in string + expectHostPath string + expectContainerPath string + expectError string + }{ + { + name: "empty volume", + in: "", + expectError: "invalid volume", + }, + { + name: "length 1 volume", + in: "/path/to/something", + expectHostPath: "/path/to/something", + expectContainerPath: "/path/to/something", + }, + { + name: "length 2 volume", + in: "/path/to/something=/path/to/something/else", + expectHostPath: "/path/to/something", + expectContainerPath: "/path/to/something/else", + }, + { + name: "invalid length volume", + in: "/path/to/something=/path/to/something/else=/path/to/something/else/else", + expectError: "invalid volume", + }, + } { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + }) + } +} + +func fakeContainer(t *testing.T, mut ...func(*codersdk.WorkspaceAgentDevcontainer)) codersdk.WorkspaceAgentDevcontainer { t.Helper() - ct := codersdk.WorkspaceAgentContainer{ + ct := codersdk.WorkspaceAgentDevcontainer{ CreatedAt: time.Now().UTC(), ID: uuid.New().String(), FriendlyName: testutil.GetRandomName(t), diff --git a/agent/containers_mock.go b/agent/containers_mock.go index 0c723ff9cce06..392627bbf1bbf 100644 --- a/agent/containers_mock.go +++ b/agent/containers_mock.go @@ -21,6 +21,7 @@ import ( type MockContainerLister struct { ctrl *gomock.Controller recorder *MockContainerListerMockRecorder + isgomock struct{} } // MockContainerListerMockRecorder is the mock recorder for MockContainerLister. @@ -41,16 +42,16 @@ func (m *MockContainerLister) EXPECT() *MockContainerListerMockRecorder { } // List mocks base method. -func (m *MockContainerLister) List(arg0 context.Context) ([]codersdk.WorkspaceAgentContainer, error) { +func (m *MockContainerLister) List(ctx context.Context) (codersdk.WorkspaceAgentListContainersResponse, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "List", arg0) - ret0, _ := ret[0].([]codersdk.WorkspaceAgentContainer) + ret := m.ctrl.Call(m, "List", ctx) + ret0, _ := ret[0].(codersdk.WorkspaceAgentListContainersResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // List indicates an expected call of List. -func (mr *MockContainerListerMockRecorder) List(arg0 any) *gomock.Call { +func (mr *MockContainerListerMockRecorder) List(ctx any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockContainerLister)(nil).List), arg0) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockContainerLister)(nil).List), ctx) } diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index b2226f6d3e30c..c7d8601b3aaba 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -15651,7 +15651,7 @@ const docTemplate = `{ } } }, - "codersdk.WorkspaceAgentContainer": { + "codersdk.WorkspaceAgentDevcontainer": { "type": "object", "properties": { "created_at": { @@ -15746,9 +15746,17 @@ const docTemplate = `{ "type": "object", "properties": { "containers": { + "description": "Containers is a list of containers visible to the workspace agent.", "type": "array", "items": { - "$ref": "#/definitions/codersdk.WorkspaceAgentContainer" + "$ref": "#/definitions/codersdk.WorkspaceAgentDevcontainer" + } + }, + "warnings": { + "description": "Warnings is a list of warnings that may have occurred during the\nprocess of listing containers. This should not include fatal errors.", + "type": "array", + "items": { + "type": "string" } } } diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 2d0989130b88e..3a11126423cf4 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -14254,7 +14254,7 @@ } } }, - "codersdk.WorkspaceAgentContainer": { + "codersdk.WorkspaceAgentDevcontainer": { "type": "object", "properties": { "created_at": { @@ -14349,9 +14349,17 @@ "type": "object", "properties": { "containers": { + "description": "Containers is a list of containers visible to the workspace agent.", "type": "array", "items": { - "$ref": "#/definitions/codersdk.WorkspaceAgentContainer" + "$ref": "#/definitions/codersdk.WorkspaceAgentDevcontainer" + } + }, + "warnings": { + "description": "Warnings is a list of warnings that may have occurred during the\nprocess of listing containers. This should not include fatal errors.", + "type": "array", + "items": { + "type": "string" } } } diff --git a/coderd/util/maps/maps.go b/coderd/util/maps/maps.go index 6e3e7f698d657..6d3d31717d33b 100644 --- a/coderd/util/maps/maps.go +++ b/coderd/util/maps/maps.go @@ -10,9 +10,7 @@ import ( // in b and have the same values. func Subset[T, U comparable](a, b map[T]U) bool { for ka, va := range a { - if vb, ok := b[ka]; !ok { - return false - } else if va != vb { + if vb, ok := b[ka]; !ok || va != vb { return false } } diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index 7abe68663236a..97acda7544c90 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -696,7 +696,7 @@ func (api *API) workspaceAgentListContainers(rw http.ResponseWriter, r *http.Req if !ok { labelParam = []string{} } - labels := make(map[string]string) + labels := make(map[string]string, len(labelParam)/2) for _, label := range labelParam { kvs := strings.Split(label, "=") if len(kvs) != 2 { @@ -750,6 +750,13 @@ func (api *API) workspaceAgentListContainers(rw http.ResponseWriter, r *http.Req // Get a list of containers that the agent is able to detect cts, err := agentConn.ListContainers(ctx) if err != nil { + if errors.Is(err, context.Canceled) { + httpapi.Write(ctx, rw, http.StatusRequestTimeout, codersdk.Response{ + Message: "Failed to fetch containers from agent.", + Detail: "Request timed out.", + }) + return + } httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error fetching containers.", Detail: err.Error(), @@ -758,7 +765,7 @@ func (api *API) workspaceAgentListContainers(rw http.ResponseWriter, r *http.Req } // Filter in-place by labels - filtered := slices.DeleteFunc(cts, func(ct codersdk.WorkspaceAgentContainer) bool { + filtered := slices.DeleteFunc(cts.Containers, func(ct codersdk.WorkspaceAgentDevcontainer) bool { return !maputil.Subset(labels, ct.Labels) }) diff --git a/coderd/workspaceagents_test.go b/coderd/workspaceagents_test.go index c9b69e8cf5a91..1a71ef42ffab2 100644 --- a/coderd/workspaceagents_test.go +++ b/coderd/workspaceagents_test.go @@ -1070,7 +1070,7 @@ func TestWorkspaceAgentContainers(t *testing.T) { ct, err := pool.RunWithOptions(&dockertest.RunOptions{ Repository: "busybox", Tag: "latest", - Cmd: []string{"sleep", "infnity"}, + Cmd: []string{"sleep", "infinity"}, Labels: testLabels, }, func(config *docker.HostConfig) { config.AutoRemove = true @@ -1078,14 +1078,14 @@ func TestWorkspaceAgentContainers(t *testing.T) { }) require.NoError(t, err, "Could not start test docker container") t.Cleanup(func() { - assert.NoError(t, pool.Purge(ct), "Could not purge resource") + assert.NoError(t, pool.Purge(ct), "Could not purge resource %q", ct.Container.Name) }) // Start another container which we will expect to ignore. ct2, err := pool.RunWithOptions(&dockertest.RunOptions{ Repository: "busybox", Tag: "latest", - Cmd: []string{"sleep", "infnity"}, + Cmd: []string{"sleep", "infinity"}, Labels: map[string]string{"com.coder.test": "ignoreme"}, }, func(config *docker.HostConfig) { config.AutoRemove = true @@ -1093,7 +1093,7 @@ func TestWorkspaceAgentContainers(t *testing.T) { }) require.NoError(t, err, "Could not start second test docker container") t.Cleanup(func() { - assert.NoError(t, pool.Purge(ct2), "Could not purge resource") + assert.NoError(t, pool.Purge(ct2), "Could not purge resource %q", ct2.Container.Name) }) client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{}) diff --git a/codersdk/workspaceagents.go b/codersdk/workspaceagents.go index 543f435509cc1..8e2209fa8072b 100644 --- a/codersdk/workspaceagents.go +++ b/codersdk/workspaceagents.go @@ -392,9 +392,11 @@ func (c *Client) WorkspaceAgentListeningPorts(ctx context.Context, agentID uuid. return listeningPorts, json.NewDecoder(res.Body).Decode(&listeningPorts) } -// WorkspaceAgentContainer describes a container of some sort that is visible -// to the workspace agent. -type WorkspaceAgentContainer struct { +// WorkspaceAgentDevcontainer describes a devcontainer of some sort +// that is visible to the workspace agent. This struct is an abstraction +// of potentially multiple implementations, and the fields will be +// somewhat implementation-dependent. +type WorkspaceAgentDevcontainer struct { // CreatedAt is the time the container was created. CreatedAt time.Time `json:"created_at" format:"date-time"` // ID is the unique identifier of the container. @@ -421,12 +423,14 @@ type WorkspaceAgentContainer struct { // WorkspaceAgentListContainersResponse is the response to the list containers // request. type WorkspaceAgentListContainersResponse struct { - Containers []WorkspaceAgentContainer `json:"containers"` + // Containers is a list of containers visible to the workspace agent. + Containers []WorkspaceAgentDevcontainer `json:"containers"` + // Warnings is a list of warnings that may have occurred during the + // process of listing containers. This should not include fatal errors. + Warnings []string `json:"warnings,omitempty"` } -// WorkspaceAgentContainersLabelFilter is a RequestOption for filtering -// listing containers by labels. -func WorkspaceAgentContainersLabelFilter(kvs map[string]string) RequestOption { +func workspaceAgentContainersLabelFilter(kvs map[string]string) RequestOption { return func(r *http.Request) { q := r.URL.Query() for k, v := range kvs { @@ -440,7 +444,7 @@ func WorkspaceAgentContainersLabelFilter(kvs map[string]string) RequestOption { // WorkspaceAgentListContainers returns a list of containers that are currently // running on a Docker daemon accessible to the workspace agent. func (c *Client) WorkspaceAgentListContainers(ctx context.Context, agentID uuid.UUID, labels map[string]string) (WorkspaceAgentListContainersResponse, error) { - lf := WorkspaceAgentContainersLabelFilter(labels) + lf := workspaceAgentContainersLabelFilter(labels) res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspaceagents/%s/containers", agentID), nil, lf) if err != nil { return WorkspaceAgentListContainersResponse{}, err diff --git a/codersdk/workspacesdk/agentconn.go b/codersdk/workspacesdk/agentconn.go index 6a2f313f71471..f803f8736a6fa 100644 --- a/codersdk/workspacesdk/agentconn.go +++ b/codersdk/workspacesdk/agentconn.go @@ -337,18 +337,18 @@ func (c *AgentConn) PrometheusMetrics(ctx context.Context) ([]byte, error) { } // ListContainers returns a response from the agent's containers endpoint -func (c *AgentConn) ListContainers(ctx context.Context) ([]codersdk.WorkspaceAgentContainer, error) { +func (c *AgentConn) ListContainers(ctx context.Context) (codersdk.WorkspaceAgentListContainersResponse, error) { ctx, span := tracing.StartSpan(ctx) defer span.End() res, err := c.apiRequest(ctx, http.MethodGet, "/api/v0/containers", nil) if err != nil { - return nil, xerrors.Errorf("do request: %w", err) + return codersdk.WorkspaceAgentListContainersResponse{}, xerrors.Errorf("do request: %w", err) } defer res.Body.Close() if res.StatusCode != http.StatusOK { - return nil, codersdk.ReadBodyAsError(res) + return codersdk.WorkspaceAgentListContainersResponse{}, codersdk.ReadBodyAsError(res) } - var resp []codersdk.WorkspaceAgentContainer + var resp codersdk.WorkspaceAgentListContainersResponse return resp, json.NewDecoder(res.Body).Decode(&resp) } diff --git a/docs/reference/api/agents.md b/docs/reference/api/agents.md index 7387a915c5cea..38e30c35e18cd 100644 --- a/docs/reference/api/agents.md +++ b/docs/reference/api/agents.md @@ -688,6 +688,9 @@ curl -X GET http://coder-server:8080/api/v2/workspaceagents/{workspaceagent}/con "property2": "string" } } + ], + "warnings": [ + "string" ] } ``` diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index f15f49e3c042d..223cf302dc75f 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -7561,7 +7561,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| | `updated_at` | string | false | | | | `version` | string | false | | | -## codersdk.WorkspaceAgentContainer +## codersdk.WorkspaceAgentDevcontainer ```json { @@ -7671,15 +7671,19 @@ If the schedule is empty, the user will be updated to use the default schedule.| "property2": "string" } } + ], + "warnings": [ + "string" ] } ``` ### Properties -| Name | Type | Required | Restrictions | Description | -|--------------|-------------------------------------------------------------------------------|----------|--------------|-------------| -| `containers` | array of [codersdk.WorkspaceAgentContainer](#codersdkworkspaceagentcontainer) | false | | | +| Name | Type | Required | Restrictions | Description | +|--------------|-------------------------------------------------------------------------------------|----------|--------------|---------------------------------------------------------------------------------------------------------------------------------------| +| `containers` | array of [codersdk.WorkspaceAgentDevcontainer](#codersdkworkspaceagentdevcontainer) | false | | Containers is a list of containers visible to the workspace agent. | +| `warnings` | array of string | false | | Warnings is a list of warnings that may have occurred during the process of listing containers. This should not include fatal errors. | ## codersdk.WorkspaceAgentListeningPort diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index deee5cb8336f1..6a776da17c490 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -2927,7 +2927,7 @@ export interface WorkspaceAgent { } // From codersdk/workspaceagents.go -export interface WorkspaceAgentContainer { +export interface WorkspaceAgentDevcontainer { readonly created_at: string; readonly id: string; readonly name: string; @@ -2971,7 +2971,8 @@ export const WorkspaceAgentLifecycles: WorkspaceAgentLifecycle[] = [ // From codersdk/workspaceagents.go export interface WorkspaceAgentListContainersResponse { - readonly containers: readonly WorkspaceAgentContainer[]; + readonly containers: readonly WorkspaceAgentDevcontainer[]; + readonly warnings?: readonly string[]; } // From codersdk/workspaceagents.go From fe179409b808e2ff5e9d32d8a6405b79de36adf0 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Thu, 6 Feb 2025 18:23:00 +0000 Subject: [PATCH 05/13] move to agentcontainers package --- agent/agent.go | 7 +++ agent/{ => agentcontainers}/containers.go | 38 ++++++++++--- .../containers_dockercli.go | 10 ++-- .../containers_internal_test.go | 16 +++--- agent/agentcontainers/containers_mock.go | 57 +++++++++++++++++++ agent/api.go | 7 +-- agent/containers_mock.go | 57 ------------------- coderd/workspaceagents.go | 13 +++-- 8 files changed, 118 insertions(+), 87 deletions(-) rename agent/{ => agentcontainers}/containers.go (77%) rename agent/{ => agentcontainers}/containers_dockercli.go (95%) rename agent/{ => agentcontainers}/containers_internal_test.go (96%) create mode 100644 agent/agentcontainers/containers_mock.go delete mode 100644 agent/containers_mock.go diff --git a/agent/agent.go b/agent/agent.go index 2daba701b4e89..84e131102876f 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -33,6 +33,7 @@ import ( "tailscale.com/util/clientmetric" "cdr.dev/slog" + "github.com/coder/coder/v2/agent/agentcontainers" "github.com/coder/coder/v2/agent/agentexec" "github.com/coder/coder/v2/agent/agentscripts" "github.com/coder/coder/v2/agent/agentssh" @@ -82,6 +83,7 @@ type Options struct { ServiceBannerRefreshInterval time.Duration BlockFileTransfer bool Execer agentexec.Execer + ContainerLister agentcontainers.Lister } type Client interface { @@ -144,6 +146,9 @@ func New(options Options) Agent { if options.Execer == nil { options.Execer = agentexec.DefaultExecer } + if options.ContainerLister == nil { + options.ContainerLister = &agentcontainers.DockerCLILister{} + } hardCtx, hardCancel := context.WithCancel(context.Background()) gracefulCtx, gracefulCancel := context.WithCancel(hardCtx) @@ -178,6 +183,7 @@ func New(options Options) Agent { prometheusRegistry: prometheusRegistry, metrics: newAgentMetrics(prometheusRegistry), execer: options.Execer, + lister: options.ContainerLister, } // Initially, we have a closed channel, reflecting the fact that we are not initially connected. // Each time we connect we replace the channel (while holding the closeMutex) with a new one @@ -247,6 +253,7 @@ type agent struct { // labeled in Coder with the agent + workspace. metrics *agentMetrics execer agentexec.Execer + lister agentcontainers.Lister } func (a *agent) TailnetConn() *tailnet.Conn { diff --git a/agent/containers.go b/agent/agentcontainers/containers.go similarity index 77% rename from agent/containers.go rename to agent/agentcontainers/containers.go index c33943904a52c..e09aad0b85c24 100644 --- a/agent/containers.go +++ b/agent/agentcontainers/containers.go @@ -1,6 +1,6 @@ -package agent +package agentcontainers -//go:generate mockgen -destination ./containers_mock.go -package agent . ContainerLister +//go:generate mockgen -destination ./containers_mock.go -package agentcontainers . Lister import ( "context" @@ -25,7 +25,7 @@ const ( type devcontainersHandler struct { cacheDuration time.Duration - cl ContainerLister + cl Lister clock quartz.Clock initLockOnce sync.Once // ensures we don't get a race when initializing lockCh @@ -36,7 +36,27 @@ type devcontainersHandler struct { mtime time.Time } -func (ch *devcontainersHandler) handler(rw http.ResponseWriter, r *http.Request) { +// WithLister sets the agentcontainers.Lister implementation to use. +// The default implementation uses the Docker CLI to list containers. +func WithLister(cl Lister) Option { + return func(ch *devcontainersHandler) { + ch.cl = cl + } +} + +// Option is a functional option for devcontainersHandler. +type Option func(*devcontainersHandler) + +// New returns a new devcontainersHandler with the given options applied. +func New(options ...Option) http.Handler { + ch := &devcontainersHandler{} + for _, opt := range options { + opt(ch) + } + return ch +} + +func (ch *devcontainersHandler) ServeHTTP(rw http.ResponseWriter, r *http.Request) { ct, err := ch.getContainers(r.Context()) if err != nil { if errors.Is(err, context.Canceled) { @@ -77,9 +97,7 @@ func (ch *devcontainersHandler) getContainers(ctx context.Context) (codersdk.Wor ch.cacheDuration = defaultGetContainersCacheDuration } if ch.cl == nil { - // TODO(cian): we may need some way to select the desired - // implementation, but for now there is only one. - ch.cl = &dockerCLIContainerLister{} + ch.cl = &DockerCLILister{} } if ch.containers == nil { ch.containers = &codersdk.WorkspaceAgentListContainersResponse{} @@ -93,6 +111,7 @@ func (ch *devcontainersHandler) getContainers(ctx context.Context) (codersdk.Wor // Return a copy of the cached data to avoid accidental modification by the caller. cpy := codersdk.WorkspaceAgentListContainersResponse{ Containers: slices.Clone(ch.containers.Containers), + Warnings: slices.Clone(ch.containers.Warnings), } return cpy, nil } @@ -110,13 +129,14 @@ func (ch *devcontainersHandler) getContainers(ctx context.Context) (codersdk.Wor // caller. cpy := codersdk.WorkspaceAgentListContainersResponse{ Containers: slices.Clone(ch.containers.Containers), + Warnings: slices.Clone(ch.containers.Warnings), } return cpy, nil } -// ContainerLister is an interface for listing containers visible to the +// Lister is an interface for listing containers visible to the // workspace agent. -type ContainerLister interface { +type Lister 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) diff --git a/agent/containers_dockercli.go b/agent/agentcontainers/containers_dockercli.go similarity index 95% rename from agent/containers_dockercli.go rename to agent/agentcontainers/containers_dockercli.go index 4f3bccd405ded..cc7efa827e90f 100644 --- a/agent/containers_dockercli.go +++ b/agent/agentcontainers/containers_dockercli.go @@ -1,4 +1,4 @@ -package agent +package agentcontainers import ( "bufio" @@ -18,12 +18,12 @@ import ( "golang.org/x/xerrors" ) -// dockerCLIContainerLister is a ContainerLister that lists containers using the docker CLI -type dockerCLIContainerLister struct{} +// DockerCLILister is a ContainerLister that lists containers using the docker CLI +type DockerCLILister struct{} -var _ ContainerLister = &dockerCLIContainerLister{} +var _ Lister = &DockerCLILister{} -func (*dockerCLIContainerLister) List(ctx context.Context) (codersdk.WorkspaceAgentListContainersResponse, error) { +func (*DockerCLILister) List(ctx context.Context) (codersdk.WorkspaceAgentListContainersResponse, error) { var stdoutBuf, stderrBuf bytes.Buffer // List all container IDs, one per line, with no truncation cmd := exec.CommandContext(ctx, "docker", "ps", "--all", "--quiet", "--no-trunc") diff --git a/agent/containers_internal_test.go b/agent/agentcontainers/containers_internal_test.go similarity index 96% rename from agent/containers_internal_test.go rename to agent/agentcontainers/containers_internal_test.go index 6f9e15cb0b9fb..41662352e1764 100644 --- a/agent/containers_internal_test.go +++ b/agent/agentcontainers/containers_internal_test.go @@ -1,4 +1,4 @@ -package agent +package agentcontainers import ( "os/exec" @@ -51,7 +51,7 @@ func TestDockerCLIContainerLister(t *testing.T) { assert.NoError(t, pool.Purge(ct), "Could not purge resource %q", ct.Container.Name) }) - dcl := dockerCLIContainerLister{} + dcl := DockerCLILister{} ctx := testutil.Context(t, testutil.WaitShort) actual, err := dcl.List(ctx) require.NoError(t, err, "Could not list containers") @@ -100,7 +100,7 @@ func TestContainersHandler(t *testing.T) { // relative age of the cached data cacheAge time.Duration // function to set up expectations for the mock - setupMock func(*MockContainerLister) + setupMock func(*MockLister) // expected result expected codersdk.WorkspaceAgentListContainersResponse // expected error @@ -108,7 +108,7 @@ func TestContainersHandler(t *testing.T) { }{ { name: "no cache", - setupMock: func(mcl *MockContainerLister) { + setupMock: func(mcl *MockLister) { mcl.EXPECT().List(gomock.Any()).Return(makeResponse(fakeCt), nil).AnyTimes() }, expected: makeResponse(fakeCt), @@ -118,7 +118,7 @@ func TestContainersHandler(t *testing.T) { cacheData: makeResponse(), cacheAge: 2 * time.Second, cacheDur: time.Second, - setupMock: func(mcl *MockContainerLister) { + setupMock: func(mcl *MockLister) { mcl.EXPECT().List(gomock.Any()).Return(makeResponse(fakeCt), nil).AnyTimes() }, expected: makeResponse(fakeCt), @@ -132,7 +132,7 @@ func TestContainersHandler(t *testing.T) { }, { name: "lister error", - setupMock: func(mcl *MockContainerLister) { + setupMock: func(mcl *MockLister) { mcl.EXPECT().List(gomock.Any()).Return(makeResponse(), assert.AnError).AnyTimes() }, expectedErr: assert.AnError.Error(), @@ -142,7 +142,7 @@ func TestContainersHandler(t *testing.T) { cacheAge: 2 * time.Second, cacheData: makeResponse(fakeCt), cacheDur: time.Second, - setupMock: func(mcl *MockContainerLister) { + setupMock: func(mcl *MockLister) { mcl.EXPECT().List(gomock.Any()).Return(makeResponse(fakeCt2), nil).AnyTimes() }, expected: makeResponse(fakeCt2), @@ -155,7 +155,7 @@ func TestContainersHandler(t *testing.T) { ctx = testutil.Context(t, testutil.WaitShort) clk = quartz.NewMock(t) ctrl = gomock.NewController(t) - mockLister = NewMockContainerLister(ctrl) + mockLister = NewMockLister(ctrl) now = time.Now().UTC() ch = devcontainersHandler{ cacheDuration: tc.cacheDur, diff --git a/agent/agentcontainers/containers_mock.go b/agent/agentcontainers/containers_mock.go new file mode 100644 index 0000000000000..c5a65e0e0e272 --- /dev/null +++ b/agent/agentcontainers/containers_mock.go @@ -0,0 +1,57 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/coder/coder/v2/agent/agentcontainers (interfaces: Lister) +// +// Generated by this command: +// +// mockgen -destination ./containers_mock.go -package agentcontainers . Lister +// + +// Package agentcontainers is a generated GoMock package. +package agentcontainers + +import ( + context "context" + reflect "reflect" + + codersdk "github.com/coder/coder/v2/codersdk" + gomock "go.uber.org/mock/gomock" +) + +// MockLister is a mock of Lister interface. +type MockLister struct { + ctrl *gomock.Controller + recorder *MockListerMockRecorder + isgomock struct{} +} + +// MockListerMockRecorder is the mock recorder for MockLister. +type MockListerMockRecorder struct { + mock *MockLister +} + +// NewMockLister creates a new mock instance. +func NewMockLister(ctrl *gomock.Controller) *MockLister { + mock := &MockLister{ctrl: ctrl} + mock.recorder = &MockListerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockLister) EXPECT() *MockListerMockRecorder { + return m.recorder +} + +// List mocks base method. +func (m *MockLister) List(ctx context.Context) (codersdk.WorkspaceAgentListContainersResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "List", ctx) + ret0, _ := ret[0].(codersdk.WorkspaceAgentListContainersResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// List indicates an expected call of List. +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) +} diff --git a/agent/api.go b/agent/api.go index 128df26df2f98..a3241feb3b7ee 100644 --- a/agent/api.go +++ b/agent/api.go @@ -7,6 +7,7 @@ import ( "github.com/go-chi/chi/v5" + "github.com/coder/coder/v2/agent/agentcontainers" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/codersdk" ) @@ -35,11 +36,9 @@ func (a *agent) apiHandler() http.Handler { ignorePorts: cpy, cacheDuration: cacheDuration, } - ch := &devcontainersHandler{ - cacheDuration: defaultGetContainersCacheDuration, - } + ch := agentcontainers.New(agentcontainers.WithLister(a.lister)) promHandler := PrometheusMetricsHandler(a.prometheusRegistry, a.logger) - r.Get("/api/v0/containers", ch.handler) + r.Get("/api/v0/containers", ch.ServeHTTP) r.Get("/api/v0/listening-ports", lp.handler) r.Get("/api/v0/netcheck", a.HandleNetcheck) r.Get("/debug/logs", a.HandleHTTPDebugLogs) diff --git a/agent/containers_mock.go b/agent/containers_mock.go deleted file mode 100644 index 392627bbf1bbf..0000000000000 --- a/agent/containers_mock.go +++ /dev/null @@ -1,57 +0,0 @@ -// Code generated by MockGen. DO NOT EDIT. -// Source: github.com/coder/coder/v2/agent (interfaces: ContainerLister) -// -// Generated by this command: -// -// mockgen -destination ./containers_mock.go -package agent . ContainerLister -// - -// Package agent is a generated GoMock package. -package agent - -import ( - context "context" - reflect "reflect" - - codersdk "github.com/coder/coder/v2/codersdk" - gomock "go.uber.org/mock/gomock" -) - -// MockContainerLister is a mock of ContainerLister interface. -type MockContainerLister struct { - ctrl *gomock.Controller - recorder *MockContainerListerMockRecorder - isgomock struct{} -} - -// MockContainerListerMockRecorder is the mock recorder for MockContainerLister. -type MockContainerListerMockRecorder struct { - mock *MockContainerLister -} - -// NewMockContainerLister creates a new mock instance. -func NewMockContainerLister(ctrl *gomock.Controller) *MockContainerLister { - mock := &MockContainerLister{ctrl: ctrl} - mock.recorder = &MockContainerListerMockRecorder{mock} - return mock -} - -// EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockContainerLister) EXPECT() *MockContainerListerMockRecorder { - return m.recorder -} - -// List mocks base method. -func (m *MockContainerLister) List(ctx context.Context) (codersdk.WorkspaceAgentListContainersResponse, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "List", ctx) - ret0, _ := ret[0].(codersdk.WorkspaceAgentListContainersResponse) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// List indicates an expected call of List. -func (mr *MockContainerListerMockRecorder) List(ctx any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockContainerLister)(nil).List), ctx) -} diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index 97acda7544c90..cc6716740a81c 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -765,11 +765,16 @@ func (api *API) workspaceAgentListContainers(rw http.ResponseWriter, r *http.Req } // Filter in-place by labels - filtered := slices.DeleteFunc(cts.Containers, func(ct codersdk.WorkspaceAgentDevcontainer) bool { - return !maputil.Subset(labels, ct.Labels) - }) + for idx, ct := range cts.Containers { + if !maputil.Subset(labels, ct.Labels) { + cts.Containers = append(cts.Containers[:idx], cts.Containers[idx+1:]...) + } + } + // filtered := slices.DeleteFunc(cts.Containers, func(ct codersdk.WorkspaceAgentDevcontainer) bool { + // return !maputil.Subset(labels, ct.Labels) + // }) - httpapi.Write(ctx, rw, http.StatusOK, codersdk.WorkspaceAgentListContainersResponse{Containers: filtered}) + httpapi.Write(ctx, rw, http.StatusOK, cts) } // @Summary Get connection info for workspace agent From ca25f69612d1da00c7758db261155eae77cec7f3 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Thu, 6 Feb 2025 18:43:17 +0000 Subject: [PATCH 06/13] remove docker dependency from tests --- coderd/workspaceagents.go | 11 +- coderd/workspaceagents_test.go | 242 +++++++++++++++++++++++---------- 2 files changed, 176 insertions(+), 77 deletions(-) diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index cc6716740a81c..8132da9bd7bfa 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -765,14 +765,9 @@ func (api *API) workspaceAgentListContainers(rw http.ResponseWriter, r *http.Req } // Filter in-place by labels - for idx, ct := range cts.Containers { - if !maputil.Subset(labels, ct.Labels) { - cts.Containers = append(cts.Containers[:idx], cts.Containers[idx+1:]...) - } - } - // filtered := slices.DeleteFunc(cts.Containers, func(ct codersdk.WorkspaceAgentDevcontainer) bool { - // return !maputil.Subset(labels, ct.Labels) - // }) + cts.Containers = slices.DeleteFunc(cts.Containers, func(ct codersdk.WorkspaceAgentDevcontainer) bool { + return !maputil.Subset(labels, ct.Labels) + }) httpapi.Write(ctx, rw, http.StatusOK, cts) } diff --git a/coderd/workspaceagents_test.go b/coderd/workspaceagents_test.go index 1a71ef42ffab2..799fa44bfd045 100644 --- a/coderd/workspaceagents_test.go +++ b/coderd/workspaceagents_test.go @@ -7,6 +7,7 @@ import ( "maps" "net" "net/http" + "os" "runtime" "strconv" "strings" @@ -15,11 +16,13 @@ import ( "time" "github.com/go-jose/go-jose/v4/jwt" + "github.com/google/go-cmp/cmp" "github.com/google/uuid" "github.com/ory/dockertest/v3" "github.com/ory/dockertest/v3/docker" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" "golang.org/x/xerrors" "google.golang.org/protobuf/types/known/timestamppb" "tailscale.com/tailcfg" @@ -27,6 +30,7 @@ import ( "cdr.dev/slog" "cdr.dev/slog/sloggers/slogtest" "github.com/coder/coder/v2/agent" + "github.com/coder/coder/v2/agent/agentcontainers" "github.com/coder/coder/v2/agent/agenttest" agentproto "github.com/coder/coder/v2/agent/proto" "github.com/coder/coder/v2/coderd/coderdtest" @@ -1058,82 +1062,182 @@ func TestWorkspaceAgentListeningPorts(t *testing.T) { func TestWorkspaceAgentContainers(t *testing.T) { t.Parallel() - if runtime.GOOS != "linux" { - t.Skip("this test creates containers, which is flaky on non-linux runners") - } + // This test will not normally run in CI, but is kept here as a semi-manual + // test for local development. Run it as follows: + // CODER_TEST_USE_DOCKER=1 go test -run TestWorkspaceAgentContainers/Docker ./coderd + t.Run("Docker", func(t *testing.T) { + t.Parallel() + 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") - testLabels := map[string]string{ - "com.coder.test": uuid.New().String(), - } - ct, err := pool.RunWithOptions(&dockertest.RunOptions{ - Repository: "busybox", - Tag: "latest", - Cmd: []string{"sleep", "infinity"}, - Labels: testLabels, - }, func(config *docker.HostConfig) { - config.AutoRemove = true - config.RestartPolicy = docker.RestartPolicy{Name: "no"} - }) - require.NoError(t, err, "Could not start test docker container") - t.Cleanup(func() { - assert.NoError(t, pool.Purge(ct), "Could not purge resource %q", ct.Container.Name) - }) + pool, err := dockertest.NewPool("") + require.NoError(t, err, "Could not connect to docker") + testLabels := map[string]string{ + "com.coder.test": uuid.New().String(), + } + ct, err := pool.RunWithOptions(&dockertest.RunOptions{ + Repository: "busybox", + Tag: "latest", + Cmd: []string{"sleep", "infinity"}, + Labels: testLabels, + }, func(config *docker.HostConfig) { + config.AutoRemove = true + config.RestartPolicy = docker.RestartPolicy{Name: "no"} + }) + require.NoError(t, err, "Could not start test docker container") + t.Cleanup(func() { + assert.NoError(t, pool.Purge(ct), "Could not purge resource %q", ct.Container.Name) + }) - // Start another container which we will expect to ignore. - ct2, err := pool.RunWithOptions(&dockertest.RunOptions{ - Repository: "busybox", - Tag: "latest", - Cmd: []string{"sleep", "infinity"}, - Labels: map[string]string{"com.coder.test": "ignoreme"}, - }, func(config *docker.HostConfig) { - config.AutoRemove = true - config.RestartPolicy = docker.RestartPolicy{Name: "no"} - }) - require.NoError(t, err, "Could not start second test docker container") - t.Cleanup(func() { - assert.NoError(t, pool.Purge(ct2), "Could not purge resource %q", ct2.Container.Name) + // Start another container which we will expect to ignore. + ct2, err := pool.RunWithOptions(&dockertest.RunOptions{ + Repository: "busybox", + Tag: "latest", + Cmd: []string{"sleep", "infinity"}, + Labels: map[string]string{"com.coder.test": "ignoreme"}, + }, func(config *docker.HostConfig) { + config.AutoRemove = true + config.RestartPolicy = docker.RestartPolicy{Name: "no"} + }) + require.NoError(t, err, "Could not start second test docker container") + t.Cleanup(func() { + assert.NoError(t, pool.Purge(ct2), "Could not purge resource %q", ct2.Container.Name) + }) + + 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(opts *agent.Options) { + opts.ContainerLister = &agentcontainers.DockerCLILister{} + }) + 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) + + // If we filter by testLabels, we should only get one container back. + res, err := client.WorkspaceAgentListContainers(ctx, agentID, testLabels) + require.NoError(t, err, "failed to list containers filtered by test label") + require.Len(t, res.Containers, 1, "expected exactly one container") + assert.Equal(t, ct.Container.ID, res.Containers[0].ID, "expected container ID to match") + assert.Equal(t, "busybox:latest", res.Containers[0].Image, "expected container image to match") + assert.Equal(t, ct.Container.Config.Labels, res.Containers[0].Labels, "expected container labels to match") + assert.Equal(t, strings.TrimPrefix(ct.Container.Name, "/"), res.Containers[0].FriendlyName, "expected container name to match") + assert.True(t, res.Containers[0].Running, "expected container to be running") + assert.Equal(t, "running", res.Containers[0].Status, "expected container status to be running") + + // List all containers and ensure we get at least both (there may be more). + res, err = client.WorkspaceAgentListContainers(ctx, agentID, nil) + require.NoError(t, err, "failed to list all containers") + require.NotEmpty(t, res.Containers, "expected to find containers") + var found []string + for _, c := range res.Containers { + found = append(found, c.ID) + } + require.Contains(t, found, ct.Container.ID, "expected to find first container without label filter") + require.Contains(t, found, ct2.Container.ID, "expected to find first container without label filter") }) - client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{}) + // This test will normally run in CI. It uses a mock implementation of + // agentcontainers.Lister instead of introducing a hard dependency on Docker. + t.Run("Mock", func(t *testing.T) { + t.Parallel() - 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(_ *agent.Options) {}) - 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 + // begin test fixtures + testLabels := map[string]string{ + "com.coder.test": uuid.New().String(), + } + testResponse := codersdk.WorkspaceAgentListContainersResponse{ + Containers: []codersdk.WorkspaceAgentDevcontainer{ + { + ID: uuid.NewString(), + CreatedAt: dbtime.Now(), + FriendlyName: testutil.GetRandomName(t), + Image: "busybox:latest", + Labels: testLabels, + Running: true, + Status: "running", + Ports: []codersdk.WorkspaceAgentListeningPort{ + { + Network: "tcp", + Port: 80, + }, + }, + Volumes: map[string]string{ + "/host": "/container", + }, + }, + }, + } + // end test fixtures - ctx := testutil.Context(t, testutil.WaitLong) + for _, tc := range []struct { + name string + setupMock func(*agentcontainers.MockLister) (codersdk.WorkspaceAgentListContainersResponse, error) + }{ + { + name: "test response", + setupMock: func(mcl *agentcontainers.MockLister) (codersdk.WorkspaceAgentListContainersResponse, error) { + mcl.EXPECT().List(gomock.Any()).Return(testResponse, nil).Times(1) + return testResponse, nil + }, + }, + { + name: "error response", + setupMock: func(mcl *agentcontainers.MockLister) (codersdk.WorkspaceAgentListContainersResponse, error) { + mcl.EXPECT().List(gomock.Any()).Return(codersdk.WorkspaceAgentListContainersResponse{}, assert.AnError).Times(1) + return codersdk.WorkspaceAgentListContainersResponse{}, assert.AnError + }, + }, + } { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() - // If we filter by testLabels, we should only get one container back. - res, err := client.WorkspaceAgentListContainers(ctx, agentID, testLabels) - require.NoError(t, err, "failed to list containers filtered by test label") - require.Len(t, res.Containers, 1, "expected exactly one container") - assert.Equal(t, ct.Container.ID, res.Containers[0].ID, "expected container ID to match") - assert.Equal(t, "busybox:latest", res.Containers[0].Image, "expected container image to match") - assert.Equal(t, ct.Container.Config.Labels, res.Containers[0].Labels, "expected container labels to match") - assert.Equal(t, strings.TrimPrefix(ct.Container.Name, "/"), res.Containers[0].FriendlyName, "expected container name to match") - assert.True(t, res.Containers[0].Running, "expected container to be running") - assert.Equal(t, "running", res.Containers[0].Status, "expected container status to be running") - - // List all containers and ensure we get at least both (there may be more). - res, err = client.WorkspaceAgentListContainers(ctx, agentID, nil) - require.NoError(t, err, "failed to list all containers") - require.NotEmpty(t, res.Containers, "expected to find containers") - var found []string - for _, c := range res.Containers { - found = append(found, c.ID) - } - require.Contains(t, found, ct.Container.ID, "expected to find first container without label filter") - require.Contains(t, found, ct2.Container.ID, "expected to find first container without label filter") + ctrl := gomock.NewController(t) + mcl := agentcontainers.NewMockLister(ctrl) + expected, expectedErr := tc.setupMock(mcl) + 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(opts *agent.Options) { + opts.ContainerLister = mcl + }) + 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) + + // List containers and ensure we get the expected mocked response. + res, err := client.WorkspaceAgentListContainers(ctx, agentID, nil) + if expectedErr != nil { + require.Contains(t, err.Error(), expectedErr.Error(), "unexpected error") + require.Empty(t, res, "expected empty response") + } else { + require.NoError(t, err, "failed to list all containers") + if diff := cmp.Diff(expected, res); diff != "" { + t.Fatalf("unexpected response (-want +got):\n%s", diff) + } + } + }) + } + }) } func TestWorkspaceAgentAppHealth(t *testing.T) { From 7f0b97037faac2280c30bec218123e2857eafd33 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Thu, 6 Feb 2025 18:45:54 +0000 Subject: [PATCH 07/13] update .gitattributes --- .gitattributes | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitattributes b/.gitattributes index ca878291fe0b5..50fb133a7b9c8 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,4 +1,5 @@ # Generated files +agent/agentcontainers/containers_mock.go linguist-generated=true coderd/apidoc/docs.go linguist-generated=true docs/reference/api/*.md linguist-generated=true docs/reference/cli/*.md linguist-generated=true From ab1469fd84c02cc289ec1d83474d4499b35b9cb9 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 7 Feb 2025 21:17:37 +0000 Subject: [PATCH 08/13] handle r.Context() canceled --- agent/agentcontainers/containers.go | 30 +++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/agent/agentcontainers/containers.go b/agent/agentcontainers/containers.go index e09aad0b85c24..6373d26a5be7f 100644 --- a/agent/agentcontainers/containers.go +++ b/agent/agentcontainers/containers.go @@ -57,23 +57,29 @@ func New(options ...Option) http.Handler { } func (ch *devcontainersHandler) ServeHTTP(rw http.ResponseWriter, r *http.Request) { - ct, err := ch.getContainers(r.Context()) - if err != nil { - if errors.Is(err, context.Canceled) { - httpapi.Write(r.Context(), rw, http.StatusRequestTimeout, codersdk.Response{ + select { + case <-r.Context().Done(): + // Client went away. + return + default: + ct, err := ch.getContainers(r.Context()) + if err != nil { + if errors.Is(err, context.Canceled) { + httpapi.Write(r.Context(), rw, http.StatusRequestTimeout, codersdk.Response{ + Message: "Could not get containers.", + Detail: "Took too long to list containers.", + }) + return + } + httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{ Message: "Could not get containers.", - Detail: "Took too long to list containers.", + Detail: err.Error(), }) return } - httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Could not get containers.", - Detail: err.Error(), - }) - return - } - httpapi.Write(r.Context(), rw, http.StatusOK, ct) + httpapi.Write(r.Context(), rw, http.StatusOK, ct) + } } func (ch *devcontainersHandler) getContainers(ctx context.Context) (codersdk.WorkspaceAgentListContainersResponse, error) { From 51e59761b78f4ecc7bfdc1c96c0a4ff432053c5f Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 7 Feb 2025 21:52:34 +0000 Subject: [PATCH 09/13] move mock to its own package --- .gitattributes | 2 +- Makefile | 6 +++++- .../{containers_mock.go => acmock/acmock.go} | 8 ++++---- agent/agentcontainers/acmock/doc.go | 4 ++++ agent/agentcontainers/containers.go | 8 +++----- agent/agentcontainers/containers_internal_test.go | 13 +++++++------ 6 files changed, 24 insertions(+), 17 deletions(-) rename agent/agentcontainers/{containers_mock.go => acmock/acmock.go} (85%) create mode 100644 agent/agentcontainers/acmock/doc.go diff --git a/.gitattributes b/.gitattributes index 50fb133a7b9c8..003a35b526213 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,5 +1,5 @@ # Generated files -agent/agentcontainers/containers_mock.go linguist-generated=true +agent/agentcontainers/acmock/acmock.go linguist-generated=true coderd/apidoc/docs.go linguist-generated=true docs/reference/api/*.md linguist-generated=true docs/reference/cli/*.md linguist-generated=true diff --git a/Makefile b/Makefile index d71b1173f36b7..fe553324cd339 100644 --- a/Makefile +++ b/Makefile @@ -563,7 +563,8 @@ GEN_FILES := \ site/e2e/provisionerGenerated.ts \ examples/examples.gen.json \ $(TAILNETTEST_MOCKS) \ - coderd/database/pubsub/psmock/psmock.go + coderd/database/pubsub/psmock/psmock.go \ + agent/agentcontainers/acmock/acmock.go # all gen targets should be added here and to gen/mark-fresh @@ -629,6 +630,9 @@ coderd/database/dbmock/dbmock.go: coderd/database/db.go coderd/database/querier. coderd/database/pubsub/psmock/psmock.go: coderd/database/pubsub/pubsub.go go generate ./coderd/database/pubsub/psmock +agent/agentcontainers/acmock/acmock.go: agent/agentcontainers/containers.go + go generate ./agent/agentcontainers/acmock/ + $(TAILNETTEST_MOCKS): tailnet/coordinator.go tailnet/service.go go generate ./tailnet/tailnettest/ diff --git a/agent/agentcontainers/containers_mock.go b/agent/agentcontainers/acmock/acmock.go similarity index 85% rename from agent/agentcontainers/containers_mock.go rename to agent/agentcontainers/acmock/acmock.go index c5a65e0e0e272..93c84e8c54fd3 100644 --- a/agent/agentcontainers/containers_mock.go +++ b/agent/agentcontainers/acmock/acmock.go @@ -1,13 +1,13 @@ // Code generated by MockGen. DO NOT EDIT. -// Source: github.com/coder/coder/v2/agent/agentcontainers (interfaces: Lister) +// Source: .. (interfaces: Lister) // // Generated by this command: // -// mockgen -destination ./containers_mock.go -package agentcontainers . Lister +// mockgen -destination ./acmock.go -package acmock .. Lister // -// Package agentcontainers is a generated GoMock package. -package agentcontainers +// Package acmock is a generated GoMock package. +package acmock import ( context "context" diff --git a/agent/agentcontainers/acmock/doc.go b/agent/agentcontainers/acmock/doc.go new file mode 100644 index 0000000000000..47679708b0fc8 --- /dev/null +++ b/agent/agentcontainers/acmock/doc.go @@ -0,0 +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 diff --git a/agent/agentcontainers/containers.go b/agent/agentcontainers/containers.go index 6373d26a5be7f..c6e01f0db9909 100644 --- a/agent/agentcontainers/containers.go +++ b/agent/agentcontainers/containers.go @@ -1,7 +1,5 @@ package agentcontainers -//go:generate mockgen -destination ./containers_mock.go -package agentcontainers . Lister - import ( "context" "errors" @@ -36,6 +34,9 @@ type devcontainersHandler struct { mtime time.Time } +// Option is a functional option for devcontainersHandler. +type Option func(*devcontainersHandler) + // WithLister sets the agentcontainers.Lister implementation to use. // The default implementation uses the Docker CLI to list containers. func WithLister(cl Lister) Option { @@ -44,9 +45,6 @@ func WithLister(cl Lister) Option { } } -// Option is a functional option for devcontainersHandler. -type Option func(*devcontainersHandler) - // New returns a new devcontainersHandler with the given options applied. func New(options ...Option) http.Handler { ch := &devcontainersHandler{} diff --git a/agent/agentcontainers/containers_internal_test.go b/agent/agentcontainers/containers_internal_test.go index 41662352e1764..95f9a9bc71c80 100644 --- a/agent/agentcontainers/containers_internal_test.go +++ b/agent/agentcontainers/containers_internal_test.go @@ -14,6 +14,7 @@ import ( "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" + "github.com/coder/coder/v2/agent/agentcontainers/acmock" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/testutil" "github.com/coder/quartz" @@ -100,7 +101,7 @@ func TestContainersHandler(t *testing.T) { // relative age of the cached data cacheAge time.Duration // function to set up expectations for the mock - setupMock func(*MockLister) + setupMock func(*acmock.MockLister) // expected result expected codersdk.WorkspaceAgentListContainersResponse // expected error @@ -108,7 +109,7 @@ func TestContainersHandler(t *testing.T) { }{ { name: "no cache", - setupMock: func(mcl *MockLister) { + setupMock: func(mcl *acmock.MockLister) { mcl.EXPECT().List(gomock.Any()).Return(makeResponse(fakeCt), nil).AnyTimes() }, expected: makeResponse(fakeCt), @@ -118,7 +119,7 @@ func TestContainersHandler(t *testing.T) { cacheData: makeResponse(), cacheAge: 2 * time.Second, cacheDur: time.Second, - setupMock: func(mcl *MockLister) { + setupMock: func(mcl *acmock.MockLister) { mcl.EXPECT().List(gomock.Any()).Return(makeResponse(fakeCt), nil).AnyTimes() }, expected: makeResponse(fakeCt), @@ -132,7 +133,7 @@ func TestContainersHandler(t *testing.T) { }, { name: "lister error", - setupMock: func(mcl *MockLister) { + setupMock: func(mcl *acmock.MockLister) { mcl.EXPECT().List(gomock.Any()).Return(makeResponse(), assert.AnError).AnyTimes() }, expectedErr: assert.AnError.Error(), @@ -142,7 +143,7 @@ func TestContainersHandler(t *testing.T) { cacheAge: 2 * time.Second, cacheData: makeResponse(fakeCt), cacheDur: time.Second, - setupMock: func(mcl *MockLister) { + setupMock: func(mcl *acmock.MockLister) { mcl.EXPECT().List(gomock.Any()).Return(makeResponse(fakeCt2), nil).AnyTimes() }, expected: makeResponse(fakeCt2), @@ -155,7 +156,7 @@ func TestContainersHandler(t *testing.T) { ctx = testutil.Context(t, testutil.WaitShort) clk = quartz.NewMock(t) ctrl = gomock.NewController(t) - mockLister = NewMockLister(ctrl) + mockLister = acmock.NewMockLister(ctrl) now = time.Now().UTC() ch = devcontainersHandler{ cacheDuration: tc.cacheDur, From b53552d2053f5cbd94f650c52173551a5935e026 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 7 Feb 2025 21:54:52 +0000 Subject: [PATCH 10/13] init lockCh in New(), remove sync.Once --- agent/agentcontainers/containers.go | 11 +++-------- agent/agentcontainers/containers_internal_test.go | 1 + 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/agent/agentcontainers/containers.go b/agent/agentcontainers/containers.go index c6e01f0db9909..8578f03337fbe 100644 --- a/agent/agentcontainers/containers.go +++ b/agent/agentcontainers/containers.go @@ -5,7 +5,6 @@ import ( "errors" "net/http" "slices" - "sync" "time" "golang.org/x/xerrors" @@ -26,7 +25,6 @@ type devcontainersHandler struct { cl Lister clock quartz.Clock - initLockOnce sync.Once // ensures we don't get a race when initializing lockCh // lockCh protects the below fields. We use a channel instead of a mutex so we // can handle cancellation properly. lockCh chan struct{} @@ -47,7 +45,9 @@ func WithLister(cl Lister) Option { // New returns a new devcontainersHandler with the given options applied. func New(options ...Option) http.Handler { - ch := &devcontainersHandler{} + ch := &devcontainersHandler{ + lockCh: make(chan struct{}, 1), + } for _, opt := range options { opt(ch) } @@ -81,11 +81,6 @@ func (ch *devcontainersHandler) ServeHTTP(rw http.ResponseWriter, r *http.Reques } func (ch *devcontainersHandler) getContainers(ctx context.Context) (codersdk.WorkspaceAgentListContainersResponse, error) { - ch.initLockOnce.Do(func() { - if ch.lockCh == nil { - ch.lockCh = make(chan struct{}, 1) - } - }) select { case <-ctx.Done(): return codersdk.WorkspaceAgentListContainersResponse{}, ctx.Err() diff --git a/agent/agentcontainers/containers_internal_test.go b/agent/agentcontainers/containers_internal_test.go index 95f9a9bc71c80..36f0994d0fc29 100644 --- a/agent/agentcontainers/containers_internal_test.go +++ b/agent/agentcontainers/containers_internal_test.go @@ -163,6 +163,7 @@ func TestContainersHandler(t *testing.T) { cl: mockLister, clock: clk, containers: &tc.cacheData, + lockCh: make(chan struct{}, 1), } ) if tc.cacheAge != 0 { From 1dbcf0f3174a9ad047f12b688cd57183c5f3beb6 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 7 Feb 2025 22:09:09 +0000 Subject: [PATCH 11/13] use agentexec --- agent/agent.go | 4 ++-- agent/agentcontainers/containers_dockercli.go | 18 +++++++++++++----- .../containers_internal_test.go | 3 ++- coderd/workspaceagents_test.go | 12 +++++++----- 4 files changed, 24 insertions(+), 13 deletions(-) diff --git a/agent/agent.go b/agent/agent.go index 84e131102876f..cfaa0a6e638ee 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -124,7 +124,7 @@ func New(options Options) Agent { options.ScriptDataDir = options.TempDir } if options.ExchangeToken == nil { - options.ExchangeToken = func(ctx context.Context) (string, error) { + options.ExchangeToken = func(_ context.Context) (string, error) { return "", nil } } @@ -147,7 +147,7 @@ func New(options Options) Agent { options.Execer = agentexec.DefaultExecer } if options.ContainerLister == nil { - options.ContainerLister = &agentcontainers.DockerCLILister{} + options.ContainerLister = agentcontainers.NewDocker(options.Execer) } hardCtx, hardCancel := context.WithCancel(context.Background()) diff --git a/agent/agentcontainers/containers_dockercli.go b/agent/agentcontainers/containers_dockercli.go index cc7efa827e90f..6a1e983df3c37 100644 --- a/agent/agentcontainers/containers_dockercli.go +++ b/agent/agentcontainers/containers_dockercli.go @@ -6,12 +6,12 @@ import ( "context" "encoding/json" "fmt" - "os/exec" "sort" "strconv" "strings" "time" + "github.com/coder/coder/v2/agent/agentexec" "github.com/coder/coder/v2/codersdk" "golang.org/x/exp/maps" @@ -19,14 +19,22 @@ import ( ) // DockerCLILister is a ContainerLister that lists containers using the docker CLI -type DockerCLILister struct{} +type DockerCLILister struct { + execer agentexec.Execer +} var _ Lister = &DockerCLILister{} -func (*DockerCLILister) List(ctx context.Context) (codersdk.WorkspaceAgentListContainersResponse, error) { +func NewDocker(execer agentexec.Execer) Lister { + return &DockerCLILister{ + execer: agentexec.DefaultExecer, + } +} + +func (dcl *DockerCLILister) List(ctx context.Context) (codersdk.WorkspaceAgentListContainersResponse, error) { var stdoutBuf, stderrBuf bytes.Buffer // List all container IDs, one per line, with no truncation - cmd := exec.CommandContext(ctx, "docker", "ps", "--all", "--quiet", "--no-trunc") + cmd := dcl.execer.CommandContext(ctx, "docker", "ps", "--all", "--quiet", "--no-trunc") cmd.Stdout = &stdoutBuf cmd.Stderr = &stderrBuf if err := cmd.Run(); err != nil { @@ -56,7 +64,7 @@ func (*DockerCLILister) List(ctx context.Context) (codersdk.WorkspaceAgentListCo stderrBuf.Reset() // nolint: gosec // We are not executing user input, these IDs come from // `docker ps`. - cmd = exec.CommandContext(ctx, "docker", append([]string{"inspect"}, ids...)...) + cmd = dcl.execer.CommandContext(ctx, "docker", append([]string{"inspect"}, ids...)...) cmd.Stdout = &stdoutBuf cmd.Stderr = &stderrBuf if err := cmd.Run(); err != nil { diff --git a/agent/agentcontainers/containers_internal_test.go b/agent/agentcontainers/containers_internal_test.go index 36f0994d0fc29..29a2bbdd64afc 100644 --- a/agent/agentcontainers/containers_internal_test.go +++ b/agent/agentcontainers/containers_internal_test.go @@ -15,6 +15,7 @@ import ( "go.uber.org/mock/gomock" "github.com/coder/coder/v2/agent/agentcontainers/acmock" + "github.com/coder/coder/v2/agent/agentexec" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/testutil" "github.com/coder/quartz" @@ -52,7 +53,7 @@ func TestDockerCLIContainerLister(t *testing.T) { assert.NoError(t, pool.Purge(ct), "Could not purge resource %q", ct.Container.Name) }) - dcl := DockerCLILister{} + dcl := NewDocker(agentexec.DefaultExecer) ctx := testutil.Context(t, testutil.WaitShort) actual, err := dcl.List(ctx) require.NoError(t, err, "Could not list containers") diff --git a/coderd/workspaceagents_test.go b/coderd/workspaceagents_test.go index 799fa44bfd045..f7a3513d4f655 100644 --- a/coderd/workspaceagents_test.go +++ b/coderd/workspaceagents_test.go @@ -31,6 +31,8 @@ import ( "cdr.dev/slog/sloggers/slogtest" "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/agentexec" "github.com/coder/coder/v2/agent/agenttest" agentproto "github.com/coder/coder/v2/agent/proto" "github.com/coder/coder/v2/coderd/coderdtest" @@ -1115,7 +1117,7 @@ func TestWorkspaceAgentContainers(t *testing.T) { return agents }).Do() _ = agenttest.New(t, client.URL, r.AgentToken, func(opts *agent.Options) { - opts.ContainerLister = &agentcontainers.DockerCLILister{} + opts.ContainerLister = agentcontainers.NewDocker(agentexec.DefaultExecer) }) resources := coderdtest.NewWorkspaceAgentWaiter(t, client, r.Workspace.ID).Wait() require.Len(t, resources, 1, "expected one resource") @@ -1182,18 +1184,18 @@ func TestWorkspaceAgentContainers(t *testing.T) { for _, tc := range []struct { name string - setupMock func(*agentcontainers.MockLister) (codersdk.WorkspaceAgentListContainersResponse, error) + setupMock func(*acmock.MockLister) (codersdk.WorkspaceAgentListContainersResponse, error) }{ { name: "test response", - setupMock: func(mcl *agentcontainers.MockLister) (codersdk.WorkspaceAgentListContainersResponse, error) { + setupMock: func(mcl *acmock.MockLister) (codersdk.WorkspaceAgentListContainersResponse, error) { mcl.EXPECT().List(gomock.Any()).Return(testResponse, nil).Times(1) return testResponse, nil }, }, { name: "error response", - setupMock: func(mcl *agentcontainers.MockLister) (codersdk.WorkspaceAgentListContainersResponse, error) { + setupMock: func(mcl *acmock.MockLister) (codersdk.WorkspaceAgentListContainersResponse, error) { mcl.EXPECT().List(gomock.Any()).Return(codersdk.WorkspaceAgentListContainersResponse{}, assert.AnError).Times(1) return codersdk.WorkspaceAgentListContainersResponse{}, assert.AnError }, @@ -1204,7 +1206,7 @@ func TestWorkspaceAgentContainers(t *testing.T) { t.Parallel() ctrl := gomock.NewController(t) - mcl := agentcontainers.NewMockLister(ctrl) + mcl := acmock.NewMockLister(ctrl) expected, expectedErr := tc.setupMock(mcl) client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{}) user := coderdtest.CreateFirstUser(t, client) From 8b081acc6754649057967e2134e5ec7fb876bd8f Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Mon, 10 Feb 2025 11:04:23 +0000 Subject: [PATCH 12/13] correctly detect ports and volumes --- agent/agentcontainers/containers_dockercli.go | 86 ++++++++++--------- .../containers_internal_test.go | 35 ++++++-- 2 files changed, 75 insertions(+), 46 deletions(-) diff --git a/agent/agentcontainers/containers_dockercli.go b/agent/agentcontainers/containers_dockercli.go index 6a1e983df3c37..e7364125b8e0f 100644 --- a/agent/agentcontainers/containers_dockercli.go +++ b/agent/agentcontainers/containers_dockercli.go @@ -58,6 +58,8 @@ func (dcl *DockerCLILister) List(ctx context.Context) (codersdk.WorkspaceAgentLi return codersdk.WorkspaceAgentListContainersResponse{}, xerrors.Errorf("scan docker ps output: %w", err) } + dockerPsStderr := strings.TrimSpace(stderrBuf.String()) + // now we can get the detailed information for each container // Run `docker inspect` on each container ID stdoutBuf.Reset() @@ -71,6 +73,8 @@ func (dcl *DockerCLILister) List(ctx context.Context) (codersdk.WorkspaceAgentLi return codersdk.WorkspaceAgentListContainersResponse{}, xerrors.Errorf("run docker inspect: %w: %s", err, strings.TrimSpace(stderrBuf.String())) } + dockerInspectStderr := strings.TrimSpace(stderrBuf.String()) + // NOTE: There is an unavoidable potential race condition where a // container is removed between `docker ps` and `docker inspect`. // In this case, stderr will contain an error message but stdout @@ -92,24 +96,41 @@ func (dcl *DockerCLILister) List(ctx context.Context) (codersdk.WorkspaceAgentLi res.Containers[idx] = out } + if dockerPsStderr != "" { + res.Warnings = append(res.Warnings, dockerPsStderr) + } + if dockerInspectStderr != "" { + res.Warnings = append(res.Warnings, dockerInspectStderr) + } + return res, nil } // To avoid a direct dependency on the Docker API, we use the docker CLI // to fetch information about containers. type dockerInspect struct { - ID string `json:"Id"` - Created time.Time `json:"Created"` - Name string `json:"Name"` - Config dockerInspectConfig `json:"Config"` - State dockerInspectState `json:"State"` + ID string `json:"Id"` + Created time.Time `json:"Created"` + Config dockerInspectConfig `json:"Config"` + HostConfig dockerInspectHostConfig `json:"HostConfig"` + Name string `json:"Name"` + Mounts []dockerInspectMount `json:"Mounts"` + State dockerInspectState `json:"State"` } type dockerInspectConfig struct { - ExposedPorts map[string]struct{} `json:"ExposedPorts"` - Image string `json:"Image"` - Labels map[string]string `json:"Labels"` - Volumes map[string]struct{} `json:"Volumes"` + Image string `json:"Image"` + Labels map[string]string `json:"Labels"` +} + +type dockerInspectHostConfig struct { + PortBindings map[string]any `json:"PortBindings"` +} + +type dockerInspectMount struct { + Source string `json:"Source"` + Destination string `json:"Destination"` + Type string `json:"Type"` } type dockerInspectState struct { @@ -144,14 +165,17 @@ func convertDockerInspect(in dockerInspect) (codersdk.WorkspaceAgentDevcontainer ID: in.ID, Image: in.Config.Image, Labels: in.Config.Labels, - Ports: make([]codersdk.WorkspaceAgentListeningPort, 0, len(in.Config.ExposedPorts)), + Ports: make([]codersdk.WorkspaceAgentListeningPort, 0), Running: in.State.Running, Status: in.State.String(), - Volumes: make(map[string]string, len(in.Config.Volumes)), + Volumes: make(map[string]string, len(in.Mounts)), } - // sort the keys for deterministic output - portKeys := maps.Keys(in.Config.ExposedPorts) + if in.HostConfig.PortBindings == nil { + in.HostConfig.PortBindings = make(map[string]any) + } + portKeys := maps.Keys(in.HostConfig.PortBindings) + // Sort the ports for deterministic output. sort.Strings(portKeys) for _, p := range portKeys { if port, network, err := convertDockerPort(p); err != nil { @@ -164,15 +188,15 @@ func convertDockerInspect(in dockerInspect) (codersdk.WorkspaceAgentDevcontainer } } - // sort the keys for deterministic output - volKeys := maps.Keys(in.Config.Volumes) - sort.Strings(volKeys) - for _, k := range volKeys { - if v0, v1, err := convertDockerVolume(k); err != nil { - warns = append(warns, err.Error()) - } else { - out.Volumes[v0] = v1 - } + if in.Mounts == nil { + in.Mounts = []dockerInspectMount{} + } + // Sort the mounts for deterministic output. + sort.Slice(in.Mounts, func(i, j int) bool { + return in.Mounts[i].Source < in.Mounts[j].Source + }) + for _, k := range in.Mounts { + out.Volumes[k.Source] = k.Destination } return out, warns @@ -202,21 +226,3 @@ func convertDockerPort(in string) (uint16, string, error) { return 0, "", xerrors.Errorf("invalid port format: %s", in) } } - -// convertDockerVolume converts a Docker volume string to a host path and -// container path. If the host path is not specified, the container path is used -// as the host path. -// example: "/host/path=/container/path" -> "/host/path", "/container/path" -// -// "/container/path" -> "/container/path", "/container/path" -func convertDockerVolume(in string) (hostPath, containerPath string, err error) { - parts := strings.Split(in, "=") - switch len(parts) { - case 1: - return parts[0], parts[0], nil - case 2: - return parts[0], parts[1], nil - default: - return "", "", xerrors.Errorf("invalid volume format: %s", in) - } -} diff --git a/agent/agentcontainers/containers_internal_test.go b/agent/agentcontainers/containers_internal_test.go index 29a2bbdd64afc..b9f34261ddcad 100644 --- a/agent/agentcontainers/containers_internal_test.go +++ b/agent/agentcontainers/containers_internal_test.go @@ -1,8 +1,10 @@ package agentcontainers import ( + "fmt" "os/exec" "runtime" + "strconv" "strings" "testing" "time" @@ -39,18 +41,34 @@ func TestDockerCLIContainerLister(t *testing.T) { pool, err := dockertest.NewPool("") require.NoError(t, err, "Could not connect to docker") testLabelValue := uuid.New().String() + // Create a temporary directory to validate that we surface mounts correctly. + testTempDir := t.TempDir() + // Pick a random port to expose for testing port bindings. + testRandPort := testutil.RandomPortNoListen(t) ct, err := pool.RunWithOptions(&dockertest.RunOptions{ - Repository: "busybox", - Tag: "latest", - Cmd: []string{"sleep", "infnity"}, - Labels: map[string]string{"com.coder.test": testLabelValue}, + Repository: "busybox", + Tag: "latest", + Cmd: []string{"sleep", "infnity"}, + Labels: map[string]string{"com.coder.test": testLabelValue}, + Mounts: []string{testTempDir + ":" + testTempDir}, + ExposedPorts: []string{fmt.Sprintf("%d/tcp", testRandPort)}, + PortBindings: map[docker.Port][]docker.PortBinding{ + docker.Port(fmt.Sprintf("%d/tcp", testRandPort)): { + { + HostIP: "0.0.0.0", + HostPort: strconv.FormatInt(int64(testRandPort), 10), + }, + }, + }, }, 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) }) dcl := NewDocker(agentexec.DefaultExecer) @@ -70,8 +88,13 @@ func TestDockerCLIContainerLister(t *testing.T) { assert.Equal(t, ct.Container.Config.Labels, foundContainer.Labels) assert.True(t, foundContainer.Running) assert.Equal(t, "running", foundContainer.Status) - assert.Len(t, foundContainer.Ports, 0) - assert.Len(t, foundContainer.Volumes, 0) + if assert.Len(t, foundContainer.Ports, 1) { + assert.Equal(t, testRandPort, foundContainer.Ports[0].Port) + assert.Equal(t, "tcp", foundContainer.Ports[0].Network) + } + if assert.Len(t, foundContainer.Volumes, 1) { + assert.Equal(t, testTempDir, foundContainer.Volumes[testTempDir]) + } break } } From 37ebbeb86d799eda108ec03a102375e5802bf229 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Mon, 10 Feb 2025 11:16:42 +0000 Subject: [PATCH 13/13] ci: add go tools to build-dylib --- .github/workflows/ci.yaml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 7e1d811e08185..64059f413f5ad 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -961,6 +961,15 @@ jobs: - name: Setup Go uses: ./.github/actions/setup-go + # Needed to build dylibs. + - name: go install tools + run: | + go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.30 + go install storj.io/drpc/cmd/protoc-gen-go-drpc@v0.0.34 + go install golang.org/x/tools/cmd/goimports@latest + go install github.com/mikefarah/yq/v4@v4.44.3 + go install go.uber.org/mock/mockgen@v0.5.0 + - name: Install rcodesign if: ${{ github.repository_owner == 'coder' && github.ref == 'refs/heads/main' }} run: |