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: | pFad - Phonifier reborn

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

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


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy