feat(agent): add container list handler (#16346)

Fixes https://github.com/coder/coder/issues/16268

- Adds `/api/v2/workspaceagents/:id/containers` coderd endpoint that allows listing containers
visible to the agent. Optional filtering by labels is supported.
- Adds go tools to the `coder-dylib` CI step so we can generate mocks if needed
This commit is contained in:
Cian Johnston
2025-02-10 11:29:30 +00:00
committed by GitHub
parent 7076c4e4ab
commit 31b1ff7d3b
22 changed files with 1654 additions and 2 deletions
+113
View File
@@ -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.WorkspaceAgentDevcontainer": {
"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,25 @@ const docTemplate = `{
"WorkspaceAgentLifecycleOff"
]
},
"codersdk.WorkspaceAgentListContainersResponse": {
"type": "object",
"properties": {
"containers": {
"description": "Containers is a list of containers visible to the workspace agent.",
"type": "array",
"items": {
"$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"
}
}
}
},
"codersdk.WorkspaceAgentListeningPort": {
"type": "object",
"properties": {
+109
View File
@@ -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.WorkspaceAgentDevcontainer": {
"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,25 @@
"WorkspaceAgentLifecycleOff"
]
},
"codersdk.WorkspaceAgentListContainersResponse": {
"type": "object",
"properties": {
"containers": {
"description": "Containers is a list of containers visible to the workspace agent.",
"type": "array",
"items": {
"$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"
}
}
}
},
"codersdk.WorkspaceAgentListeningPort": {
"type": "object",
"properties": {
+1
View File
@@ -1211,6 +1211,7 @@ func New(options *Options) *API {
r.Get("/logs", api.workspaceAgentLogs)
r.Get("/listening-ports", api.workspaceAgentListeningPorts)
r.Get("/connection", api.workspaceAgentConnection)
r.Get("/containers", api.workspaceAgentListContainers)
r.Get("/coordinate", api.workspaceAgentClientCoordinate)
// PTY is part of workspaceAppServer.
+27
View File
@@ -0,0 +1,27 @@
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 || 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
}
+64
View File
@@ -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)
}
})
}
}
+94
View File
@@ -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,99 @@ 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, len(labelParam)/2)
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 {
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(),
})
return
}
// Filter in-place by 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)
}
// @Summary Get connection info for workspace agent
// @ID get-connection-info-for-workspace-agent
// @Security CoderSessionToken
+189
View File
@@ -7,6 +7,7 @@ import (
"maps"
"net"
"net/http"
"os"
"runtime"
"strconv"
"strings"
@@ -15,9 +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"
@@ -25,6 +30,9 @@ 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/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"
@@ -1053,6 +1061,187 @@ func TestWorkspaceAgentListeningPorts(t *testing.T) {
})
}
func TestWorkspaceAgentContainers(t *testing.T) {
t.Parallel()
// 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)
})
// 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.NewDocker(agentexec.DefaultExecer)
})
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")
})
// 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()
// 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
for _, tc := range []struct {
name string
setupMock func(*acmock.MockLister) (codersdk.WorkspaceAgentListContainersResponse, error)
}{
{
name: "test response",
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 *acmock.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()
ctrl := gomock.NewController(t)
mcl := acmock.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) {
t.Parallel()
client, db := coderdtest.NewWithDatabase(t, nil)