diff --git a/.gitattributes b/.gitattributes
index ca878291fe..003a35b526 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1,4 +1,5 @@
# Generated files
+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/.github/workflows/dogfood.yaml b/.github/workflows/dogfood.yaml
index 71e9d6e969..44e23ad626 100644
--- a/.github/workflows/dogfood.yaml
+++ b/.github/workflows/dogfood.yaml
@@ -53,7 +53,7 @@ jobs:
uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@6524bf65af31da8d45b59e8c27de4bd072b392f5 # v3.8.0
+ uses: docker/setup-buildx-action@f7ce87c1d6bead3e36075b2ce75da1f6cc28aaca # v3.9.0
- name: Login to DockerHub
if: github.ref == 'refs/heads/main'
diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml
index 83e8efce4d..44dec01907 100644
--- a/.github/workflows/scorecard.yml
+++ b/.github/workflows/scorecard.yml
@@ -47,6 +47,6 @@ jobs:
# Upload the results to GitHub's code scanning dashboard.
- name: "Upload to code-scanning"
- uses: github/codeql-action/upload-sarif@dd746615b3b9d728a6a37ca2045b68ca76d4841a # v3.28.8
+ uses: github/codeql-action/upload-sarif@9e8d0789d4a0fa9ceb6b1738f7e269594bdd67f0 # v3.28.9
with:
sarif_file: results.sarif
diff --git a/.github/workflows/security.yaml b/.github/workflows/security.yaml
index 9e241d6e81..575adf205b 100644
--- a/.github/workflows/security.yaml
+++ b/.github/workflows/security.yaml
@@ -38,7 +38,7 @@ jobs:
uses: ./.github/actions/setup-go
- name: Initialize CodeQL
- uses: github/codeql-action/init@dd746615b3b9d728a6a37ca2045b68ca76d4841a # v3.28.8
+ uses: github/codeql-action/init@9e8d0789d4a0fa9ceb6b1738f7e269594bdd67f0 # v3.28.9
with:
languages: go, javascript
@@ -48,7 +48,7 @@ jobs:
rm Makefile
- name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@dd746615b3b9d728a6a37ca2045b68ca76d4841a # v3.28.8
+ uses: github/codeql-action/analyze@9e8d0789d4a0fa9ceb6b1738f7e269594bdd67f0 # v3.28.9
- name: Send Slack notification on failure
if: ${{ failure() }}
@@ -144,7 +144,7 @@ jobs:
severity: "CRITICAL,HIGH"
- name: Upload Trivy scan results to GitHub Security tab
- uses: github/codeql-action/upload-sarif@dd746615b3b9d728a6a37ca2045b68ca76d4841a # v3.28.8
+ uses: github/codeql-action/upload-sarif@9e8d0789d4a0fa9ceb6b1738f7e269594bdd67f0 # v3.28.9
with:
sarif_file: trivy-results.sarif
category: "Trivy"
diff --git a/Makefile b/Makefile
index d71b1173f3..b69e164317 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
@@ -598,6 +599,7 @@ gen/mark-fresh:
examples/examples.gen.json \
$(TAILNETTEST_MOCKS) \
coderd/database/pubsub/psmock/psmock.go \
+ agent/agentcontainers/acmock/acmock.go \
"
for file in $$files; do
@@ -629,6 +631,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/agent.go b/agent/agent.go
index 2daba701b4..cfaa0a6e63 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 {
@@ -122,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
}
}
@@ -144,6 +146,9 @@ func New(options Options) Agent {
if options.Execer == nil {
options.Execer = agentexec.DefaultExecer
}
+ if options.ContainerLister == nil {
+ options.ContainerLister = agentcontainers.NewDocker(options.Execer)
+ }
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/agentcontainers/acmock/acmock.go b/agent/agentcontainers/acmock/acmock.go
new file mode 100644
index 0000000000..93c84e8c54
--- /dev/null
+++ b/agent/agentcontainers/acmock/acmock.go
@@ -0,0 +1,57 @@
+// Code generated by MockGen. DO NOT EDIT.
+// Source: .. (interfaces: Lister)
+//
+// Generated by this command:
+//
+// mockgen -destination ./acmock.go -package acmock .. Lister
+//
+
+// Package acmock is a generated GoMock package.
+package acmock
+
+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/agentcontainers/acmock/doc.go b/agent/agentcontainers/acmock/doc.go
new file mode 100644
index 0000000000..47679708b0
--- /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
new file mode 100644
index 0000000000..8578f03337
--- /dev/null
+++ b/agent/agentcontainers/containers.go
@@ -0,0 +1,142 @@
+package agentcontainers
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "slices"
+ "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 devcontainersHandler struct {
+ cacheDuration time.Duration
+ cl Lister
+ clock quartz.Clock
+
+ // 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
+}
+
+// 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 {
+ return func(ch *devcontainersHandler) {
+ ch.cl = cl
+ }
+}
+
+// New returns a new devcontainersHandler with the given options applied.
+func New(options ...Option) http.Handler {
+ ch := &devcontainersHandler{
+ lockCh: make(chan struct{}, 1),
+ }
+ for _, opt := range options {
+ opt(ch)
+ }
+ return ch
+}
+
+func (ch *devcontainersHandler) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
+ 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: err.Error(),
+ })
+ return
+ }
+
+ httpapi.Write(r.Context(), rw, http.StatusOK, ct)
+ }
+}
+
+func (ch *devcontainersHandler) getContainers(ctx context.Context) (codersdk.WorkspaceAgentListContainersResponse, error) {
+ 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 {
+ ch.cacheDuration = defaultGetContainersCacheDuration
+ }
+ if ch.cl == nil {
+ ch.cl = &DockerCLILister{}
+ }
+ if ch.containers == nil {
+ ch.containers = &codersdk.WorkspaceAgentListContainersResponse{}
+ }
+ if ch.clock == nil {
+ ch.clock = quartz.NewReal()
+ }
+
+ now := ch.clock.Now()
+ if now.Sub(ch.mtime) < ch.cacheDuration {
+ // 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
+ }
+
+ timeoutCtx, timeoutCancel := context.WithTimeout(ctx, getContainersTimeout)
+ defer timeoutCancel()
+ updated, err := ch.cl.List(timeoutCtx)
+ if err != nil {
+ return codersdk.WorkspaceAgentListContainersResponse{}, xerrors.Errorf("get containers: %w", err)
+ }
+ ch.containers = &updated
+ ch.mtime = now
+
+ // 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
+}
+
+// Lister is an interface for listing containers visible to the
+// workspace agent.
+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/agentcontainers/containers_dockercli.go b/agent/agentcontainers/containers_dockercli.go
new file mode 100644
index 0000000000..3842735116
--- /dev/null
+++ b/agent/agentcontainers/containers_dockercli.go
@@ -0,0 +1,233 @@
+package agentcontainers
+
+import (
+ "bufio"
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/coder/coder/v2/agent/agentexec"
+ "github.com/coder/coder/v2/codersdk"
+
+ "golang.org/x/exp/maps"
+ "golang.org/x/xerrors"
+)
+
+// DockerCLILister is a ContainerLister that lists containers using the docker CLI
+type DockerCLILister struct {
+ execer agentexec.Execer
+}
+
+var _ Lister = &DockerCLILister{}
+
+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 := dcl.execer.CommandContext(ctx, "docker", "ps", "--all", "--quiet", "--no-trunc")
+ cmd.Stdout = &stdoutBuf
+ cmd.Stderr = &stderrBuf
+ if err := cmd.Run(); err != nil {
+ // 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)
+ 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)
+ }
+
+ dockerPsStderr := strings.TrimSpace(stderrBuf.String())
+ if len(ids) == 0 {
+ return codersdk.WorkspaceAgentListContainersResponse{
+ Warnings: []string{dockerPsStderr},
+ }, nil
+ }
+
+ // now we can get the detailed information for each container
+ // Run `docker inspect` on each container ID
+ stdoutBuf.Reset()
+ stderrBuf.Reset()
+ // nolint: gosec // We are not executing user input, these IDs come from
+ // `docker ps`.
+ cmd = dcl.execer.CommandContext(ctx, "docker", append([]string{"inspect"}, ids...)...)
+ cmd.Stdout = &stdoutBuf
+ cmd.Stderr = &stderrBuf
+ if err := cmd.Run(); err != nil {
+ 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
+ // 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)
+ }
+
+ 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
+ }
+
+ 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"`
+ Config dockerInspectConfig `json:"Config"`
+ HostConfig dockerInspectHostConfig `json:"HostConfig"`
+ Name string `json:"Name"`
+ Mounts []dockerInspectMount `json:"Mounts"`
+ State dockerInspectState `json:"State"`
+}
+
+type dockerInspectConfig struct {
+ 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 {
+ 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.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),
+ Running: in.State.Running,
+ Status: in.State.String(),
+ Volumes: make(map[string]string, len(in.Mounts)),
+ }
+
+ 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 {
+ warns = append(warns, err.Error())
+ } else {
+ out.Ports = append(out.Ports, codersdk.WorkspaceAgentListeningPort{
+ Network: network,
+ Port: port,
+ })
+ }
+ }
+
+ 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
+}
+
+// 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 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
+ 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)
+ }
+}
diff --git a/agent/agentcontainers/containers_internal_test.go b/agent/agentcontainers/containers_internal_test.go
new file mode 100644
index 0000000000..e15deae54c
--- /dev/null
+++ b/agent/agentcontainers/containers_internal_test.go
@@ -0,0 +1,346 @@
+package agentcontainers
+
+import (
+ "fmt"
+ "os"
+ "strconv"
+ "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/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"
+)
+
+// 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.
+// As this test creates containers, it is skipped by default.
+// It can be run manually as follows:
+//
+// CODER_TEST_USE_DOCKER=1 go test ./agent/agentcontainers -run TestDockerCLIContainerLister
+func TestDockerCLIContainerLister(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")
+ 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},
+ 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)
+ 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.Containers {
+ 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)
+ 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
+ }
+ }
+ 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)
+ 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.WorkspaceAgentListContainersResponse
+ // 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(*acmock.MockLister)
+ // expected result
+ expected codersdk.WorkspaceAgentListContainersResponse
+ // expected error
+ expectedErr string
+ }{
+ {
+ name: "no cache",
+ setupMock: func(mcl *acmock.MockLister) {
+ mcl.EXPECT().List(gomock.Any()).Return(makeResponse(fakeCt), nil).AnyTimes()
+ },
+ expected: makeResponse(fakeCt),
+ },
+ {
+ name: "no data",
+ cacheData: makeResponse(),
+ cacheAge: 2 * time.Second,
+ cacheDur: time.Second,
+ setupMock: func(mcl *acmock.MockLister) {
+ mcl.EXPECT().List(gomock.Any()).Return(makeResponse(fakeCt), nil).AnyTimes()
+ },
+ expected: makeResponse(fakeCt),
+ },
+ {
+ name: "cached data",
+ cacheAge: time.Second,
+ cacheData: makeResponse(fakeCt),
+ cacheDur: 2 * time.Second,
+ expected: makeResponse(fakeCt),
+ },
+ {
+ name: "lister error",
+ setupMock: func(mcl *acmock.MockLister) {
+ mcl.EXPECT().List(gomock.Any()).Return(makeResponse(), assert.AnError).AnyTimes()
+ },
+ expectedErr: assert.AnError.Error(),
+ },
+ {
+ name: "stale cache",
+ cacheAge: 2 * time.Second,
+ cacheData: makeResponse(fakeCt),
+ cacheDur: time.Second,
+ setupMock: func(mcl *acmock.MockLister) {
+ mcl.EXPECT().List(gomock.Any()).Return(makeResponse(fakeCt2), nil).AnyTimes()
+ },
+ expected: makeResponse(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 = acmock.NewMockLister(ctrl)
+ now = time.Now().UTC()
+ ch = devcontainersHandler{
+ cacheDuration: tc.cacheDur,
+ cl: mockLister,
+ clock: clk,
+ containers: &tc.cacheData,
+ lockCh: make(chan struct{}, 1),
+ }
+ )
+ 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 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.WorkspaceAgentDevcontainer{
+ 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/api.go b/agent/api.go
index 2df791d6fb..a3241feb3b 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,7 +36,9 @@ func (a *agent) apiHandler() http.Handler {
ignorePorts: cpy,
cacheDuration: cacheDuration,
}
+ ch := agentcontainers.New(agentcontainers.WithLister(a.lister))
promHandler := PrometheusMetricsHandler(a.prometheusRegistry, a.logger)
+ 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/cli/cliui/resources.go b/cli/cliui/resources.go
index a9204c968c..8921033ddc 100644
--- a/cli/cliui/resources.go
+++ b/cli/cliui/resources.go
@@ -5,7 +5,9 @@ import (
"io"
"sort"
"strconv"
+ "strings"
+ "github.com/google/uuid"
"github.com/jedib0t/go-pretty/v6/table"
"golang.org/x/mod/semver"
@@ -14,12 +16,18 @@ import (
"github.com/coder/pretty"
)
+var (
+ pipeMid = "├"
+ pipeEnd = "└"
+)
+
type WorkspaceResourcesOptions struct {
WorkspaceName string
HideAgentState bool
HideAccess bool
Title string
ServerVersion string
+ ListeningPorts map[uuid.UUID]codersdk.WorkspaceAgentListeningPortsResponse
}
// WorkspaceResources displays the connection status and tree-view of provided resources.
@@ -86,32 +94,17 @@ func WorkspaceResources(writer io.Writer, resources []codersdk.WorkspaceResource
})
// Display all agents associated with the resource.
for index, agent := range resource.Agents {
- pipe := "├"
- if index == len(resource.Agents)-1 {
- pipe = "└"
- }
- row := table.Row{
- // These tree from a resource!
- fmt.Sprintf("%s─ %s (%s, %s)", pipe, agent.Name, agent.OperatingSystem, agent.Architecture),
- }
- if !options.HideAgentState {
- var agentStatus, agentHealth, agentVersion string
- if !options.HideAgentState {
- agentStatus = renderAgentStatus(agent)
- agentHealth = renderAgentHealth(agent)
- agentVersion = renderAgentVersion(agent.Version, options.ServerVersion)
+ tableWriter.AppendRow(renderAgentRow(agent, index, totalAgents, options))
+ if options.ListeningPorts != nil {
+ if lp, ok := options.ListeningPorts[agent.ID]; ok && len(lp.Ports) > 0 {
+ tableWriter.AppendRow(table.Row{
+ fmt.Sprintf(" %s─ %s", renderPipe(index, totalAgents), "Open Ports"),
+ })
+ for _, port := range lp.Ports {
+ tableWriter.AppendRow(renderPortRow(port, index, totalAgents))
+ }
}
- row = append(row, agentStatus, agentHealth, agentVersion)
}
- if !options.HideAccess {
- sshCommand := "coder ssh " + options.WorkspaceName
- if totalAgents > 1 {
- sshCommand += "." + agent.Name
- }
- sshCommand = pretty.Sprint(DefaultStyles.Code, sshCommand)
- row = append(row, sshCommand)
- }
- tableWriter.AppendRow(row)
}
tableWriter.AppendSeparator()
}
@@ -119,6 +112,43 @@ func WorkspaceResources(writer io.Writer, resources []codersdk.WorkspaceResource
return err
}
+func renderAgentRow(agent codersdk.WorkspaceAgent, index, totalAgents int, options WorkspaceResourcesOptions) table.Row {
+ row := table.Row{
+ // These tree from a resource!
+ fmt.Sprintf("%s─ %s (%s, %s)", renderPipe(index, totalAgents), agent.Name, agent.OperatingSystem, agent.Architecture),
+ }
+ if !options.HideAgentState {
+ var agentStatus, agentHealth, agentVersion string
+ if !options.HideAgentState {
+ agentStatus = renderAgentStatus(agent)
+ agentHealth = renderAgentHealth(agent)
+ agentVersion = renderAgentVersion(agent.Version, options.ServerVersion)
+ }
+ row = append(row, agentStatus, agentHealth, agentVersion)
+ }
+ if !options.HideAccess {
+ sshCommand := "coder ssh " + options.WorkspaceName
+ if totalAgents > 1 {
+ sshCommand += "." + agent.Name
+ }
+ sshCommand = pretty.Sprint(DefaultStyles.Code, sshCommand)
+ row = append(row, sshCommand)
+ }
+ return row
+}
+
+func renderPortRow(port codersdk.WorkspaceAgentListeningPort, index, totalPorts int) table.Row {
+ var sb strings.Builder
+ _, _ = sb.WriteString(" ")
+ _, _ = sb.WriteString(renderPipe(index, totalPorts))
+ _, _ = sb.WriteString("─ ")
+ _, _ = sb.WriteString(pretty.Sprintf(DefaultStyles.Code, "%5d/%s", port.Port, port.Network))
+ if port.ProcessName != "" {
+ _, _ = sb.WriteString(pretty.Sprintf(DefaultStyles.Keyword, " [%s]", port.ProcessName))
+ }
+ return table.Row{sb.String()}
+}
+
func renderAgentStatus(agent codersdk.WorkspaceAgent) string {
switch agent.Status {
case codersdk.WorkspaceAgentConnecting:
@@ -163,3 +193,10 @@ func renderAgentVersion(agentVersion, serverVersion string) string {
}
return pretty.Sprint(DefaultStyles.Keyword, agentVersion)
}
+
+func renderPipe(idx, total int) string {
+ if idx == total-1 {
+ return pipeEnd
+ }
+ return pipeMid
+}
diff --git a/cli/show.go b/cli/show.go
index 00c50292d6..7da747d6ff 100644
--- a/cli/show.go
+++ b/cli/show.go
@@ -1,8 +1,13 @@
package cli
import (
+ "sort"
+ "sync"
+
"golang.org/x/xerrors"
+ "github.com/google/uuid"
+
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/serpent"
@@ -26,10 +31,42 @@ func (r *RootCmd) show() *serpent.Command {
if err != nil {
return xerrors.Errorf("get workspace: %w", err)
}
- return cliui.WorkspaceResources(inv.Stdout, workspace.LatestBuild.Resources, cliui.WorkspaceResourcesOptions{
+
+ options := cliui.WorkspaceResourcesOptions{
WorkspaceName: workspace.Name,
ServerVersion: buildInfo.Version,
- })
+ }
+ if workspace.LatestBuild.Status == codersdk.WorkspaceStatusRunning {
+ // Get listening ports for each agent.
+ options.ListeningPorts = fetchListeningPorts(inv, client, workspace.LatestBuild.Resources...)
+ }
+ return cliui.WorkspaceResources(inv.Stdout, workspace.LatestBuild.Resources, options)
},
}
}
+
+func fetchListeningPorts(inv *serpent.Invocation, client *codersdk.Client, resources ...codersdk.WorkspaceResource) map[uuid.UUID]codersdk.WorkspaceAgentListeningPortsResponse {
+ ports := make(map[uuid.UUID]codersdk.WorkspaceAgentListeningPortsResponse)
+ var wg sync.WaitGroup
+ var mu sync.Mutex
+ for _, res := range resources {
+ for _, agent := range res.Agents {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ lp, err := client.WorkspaceAgentListeningPorts(inv.Context(), agent.ID)
+ if err != nil {
+ cliui.Warnf(inv.Stderr, "Failed to get listening ports for agent %s: %v", agent.Name, err)
+ }
+ sort.Slice(lp.Ports, func(i, j int) bool {
+ return lp.Ports[i].Port < lp.Ports[j].Port
+ })
+ mu.Lock()
+ ports[agent.ID] = lp
+ mu.Unlock()
+ }()
+ }
+ }
+ wg.Wait()
+ return ports
+}
diff --git a/cli/testdata/coder_list_--output_json.golden b/cli/testdata/coder_list_--output_json.golden
index 0ef065dd86..4b308a9468 100644
--- a/cli/testdata/coder_list_--output_json.golden
+++ b/cli/testdata/coder_list_--output_json.golden
@@ -48,7 +48,14 @@
"input": {
"workspace_build_id": "========[workspace build ID]========"
},
- "type": "workspace_build"
+ "type": "workspace_build",
+ "metadata": {
+ "template_version_name": "",
+ "template_id": "00000000-0000-0000-0000-000000000000",
+ "template_name": "",
+ "template_display_name": "",
+ "template_icon": ""
+ }
},
"reason": "initiator",
"resources": [],
diff --git a/cli/testdata/coder_provisioner_jobs_list_--help.golden b/cli/testdata/coder_provisioner_jobs_list_--help.golden
index bd29b7560e..d6eb9a7681 100644
--- a/cli/testdata/coder_provisioner_jobs_list_--help.golden
+++ b/cli/testdata/coder_provisioner_jobs_list_--help.golden
@@ -11,7 +11,7 @@ OPTIONS:
-O, --org string, $CODER_ORGANIZATION
Select which organization (uuid or name) to use.
- -c, --column [id|created at|started at|completed at|canceled at|error|error code|status|worker id|file id|tags|queue position|queue size|organization id|template version id|workspace build id|type|available workers|template version name|template id|template name|template display name|workspace id|workspace name|organization|queue] (default: created at,id,organization,status,type,queue,tags)
+ -c, --column [id|created at|started at|completed at|canceled at|error|error code|status|worker id|file id|tags|queue position|queue size|organization id|template version id|workspace build id|type|available workers|template version name|template id|template name|template display name|template icon|workspace id|workspace name|organization|queue] (default: created at,id,organization,status,type,queue,tags)
Columns to display in table output.
-l, --limit int, $CODER_PROVISIONER_JOB_LIST_LIMIT (default: 50)
diff --git a/cli/testdata/coder_provisioner_jobs_list_--output_json.golden b/cli/testdata/coder_provisioner_jobs_list_--output_json.golden
index 9e1f56ba7b..d18e07121f 100644
--- a/cli/testdata/coder_provisioner_jobs_list_--output_json.golden
+++ b/cli/testdata/coder_provisioner_jobs_list_--output_json.golden
@@ -22,7 +22,8 @@
"template_version_name": "===========[version name]===========",
"template_id": "===========[template ID]============",
"template_name": "test-template",
- "template_display_name": ""
+ "template_display_name": "",
+ "template_icon": ""
},
"organization_name": "Coder"
},
@@ -50,6 +51,7 @@
"template_id": "===========[template ID]============",
"template_name": "test-template",
"template_display_name": "",
+ "template_icon": "",
"workspace_id": "===========[workspace ID]===========",
"workspace_name": "test-workspace"
},
diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go
index 5afc914097..3423b696fc 100644
--- a/coderd/apidoc/docs.go
+++ b/coderd/apidoc/docs.go
@@ -7930,6 +7930,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": [
@@ -13330,6 +13373,9 @@ const docTemplate = `{
"template_display_name": {
"type": "string"
},
+ "template_icon": {
+ "type": "string"
+ },
"template_id": {
"type": "string",
"format": "uuid"
@@ -15709,6 +15755,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": {
@@ -15749,6 +15846,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": {
diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json
index 2aaa3a1259..d5a836dcb3 100644
--- a/coderd/apidoc/swagger.json
+++ b/coderd/apidoc/swagger.json
@@ -6998,6 +6998,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": [
@@ -12043,6 +12082,9 @@
"template_display_name": {
"type": "string"
},
+ "template_icon": {
+ "type": "string"
+ },
"template_id": {
"type": "string",
"format": "uuid"
@@ -14308,6 +14350,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": {
@@ -14348,6 +14441,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": {
diff --git a/coderd/coderd.go b/coderd/coderd.go
index d64e27f549..3c5363181d 100644
--- a/coderd/coderd.go
+++ b/coderd/coderd.go
@@ -1215,6 +1215,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.
diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go
index 4f255840ca..6030f4a60f 100644
--- a/coderd/database/queries.sql.go
+++ b/coderd/database/queries.sql.go
@@ -6437,6 +6437,7 @@ SELECT
t.id AS template_id,
COALESCE(t.name, '') AS template_name,
COALESCE(t.display_name, '') AS template_display_name,
+ COALESCE(t.icon, '') AS template_icon,
w.id AS workspace_id,
COALESCE(w.name, '') AS workspace_name
FROM
@@ -6466,6 +6467,7 @@ GROUP BY
t.id,
t.name,
t.display_name,
+ t.icon,
w.id,
w.name
ORDER BY
@@ -6490,6 +6492,7 @@ type GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisionerRow
TemplateID uuid.NullUUID `db:"template_id" json:"template_id"`
TemplateName string `db:"template_name" json:"template_name"`
TemplateDisplayName string `db:"template_display_name" json:"template_display_name"`
+ TemplateIcon string `db:"template_icon" json:"template_icon"`
WorkspaceID uuid.NullUUID `db:"workspace_id" json:"workspace_id"`
WorkspaceName string `db:"workspace_name" json:"workspace_name"`
}
@@ -6535,6 +6538,7 @@ func (q *sqlQuerier) GetProvisionerJobsByOrganizationAndStatusWithQueuePositionA
&i.TemplateID,
&i.TemplateName,
&i.TemplateDisplayName,
+ &i.TemplateIcon,
&i.WorkspaceID,
&i.WorkspaceName,
); err != nil {
diff --git a/coderd/database/queries/provisionerjobs.sql b/coderd/database/queries/provisionerjobs.sql
index bac03f1b42..fedcc630a1 100644
--- a/coderd/database/queries/provisionerjobs.sql
+++ b/coderd/database/queries/provisionerjobs.sql
@@ -136,6 +136,7 @@ SELECT
t.id AS template_id,
COALESCE(t.name, '') AS template_name,
COALESCE(t.display_name, '') AS template_display_name,
+ COALESCE(t.icon, '') AS template_icon,
w.id AS workspace_id,
COALESCE(w.name, '') AS workspace_name
FROM
@@ -165,6 +166,7 @@ GROUP BY
t.id,
t.name,
t.display_name,
+ t.icon,
w.id,
w.name
ORDER BY
diff --git a/coderd/oauthpki/okidcpki_test.go b/coderd/oauthpki/okidcpki_test.go
index 144cb32901..509da563a9 100644
--- a/coderd/oauthpki/okidcpki_test.go
+++ b/coderd/oauthpki/okidcpki_test.go
@@ -13,6 +13,7 @@ import (
"github.com/coreos/go-oidc/v3/oidc"
"github.com/golang-jwt/jwt/v4"
+ "github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/oauth2"
@@ -169,6 +170,7 @@ func TestAzureAKPKIWithCoderd(t *testing.T) {
const email = "alice@coder.com"
claims := jwt.MapClaims{
"email": email,
+ "sub": uuid.NewString(),
}
helper := oidctest.NewLoginHelper(owner, fake)
user, _ := helper.Login(t, claims)
diff --git a/coderd/provisionerjobs.go b/coderd/provisionerjobs.go
index b8eccdb9c4..492aa50eeb 100644
--- a/coderd/provisionerjobs.go
+++ b/coderd/provisionerjobs.go
@@ -388,11 +388,12 @@ func convertProvisionerJobWithQueuePosition(pj database.GetProvisionerJobsByOrga
QueueSize: pj.QueueSize,
})
job.AvailableWorkers = pj.AvailableWorkers
- job.Metadata = &codersdk.ProvisionerJobMetadata{
+ job.Metadata = codersdk.ProvisionerJobMetadata{
TemplateVersionName: pj.TemplateVersionName,
TemplateID: pj.TemplateID.UUID,
TemplateName: pj.TemplateName,
TemplateDisplayName: pj.TemplateDisplayName,
+ TemplateIcon: pj.TemplateIcon,
WorkspaceName: pj.WorkspaceName,
}
if pj.WorkspaceID.Valid {
diff --git a/coderd/provisionerjobs_test.go b/coderd/provisionerjobs_test.go
index ba5f31e689..1c832d6825 100644
--- a/coderd/provisionerjobs_test.go
+++ b/coderd/provisionerjobs_test.go
@@ -84,11 +84,12 @@ func TestProvisionerJobs(t *testing.T) {
require.Equal(t, job.ID, job2.ID)
// Verify that job metadata is correct.
- assert.Equal(t, job2.Metadata, &codersdk.ProvisionerJobMetadata{
+ assert.Equal(t, job2.Metadata, codersdk.ProvisionerJobMetadata{
TemplateVersionName: version.Name,
TemplateID: template.ID,
TemplateName: template.Name,
TemplateDisplayName: template.DisplayName,
+ TemplateIcon: template.Icon,
WorkspaceID: &w.ID,
WorkspaceName: w.Name,
})
@@ -105,11 +106,12 @@ func TestProvisionerJobs(t *testing.T) {
require.Equal(t, version.Job.ID, job2.ID)
// Verify that job metadata is correct.
- assert.Equal(t, job2.Metadata, &codersdk.ProvisionerJobMetadata{
+ assert.Equal(t, job2.Metadata, codersdk.ProvisionerJobMetadata{
TemplateVersionName: version.Name,
TemplateID: template.ID,
TemplateName: template.Name,
TemplateDisplayName: template.DisplayName,
+ TemplateIcon: template.Icon,
})
})
})
diff --git a/coderd/userauth.go b/coderd/userauth.go
index c5e95e4499..15eea78b5b 100644
--- a/coderd/userauth.go
+++ b/coderd/userauth.go
@@ -1112,6 +1112,20 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) {
return
}
+ if idToken.Subject == "" {
+ logger.Error(ctx, "oauth2: missing 'sub' claim field in OIDC token",
+ slog.F("source", "id_token"),
+ slog.F("claim_fields", claimFields(idtokenClaims)),
+ slog.F("blank", blankFields(idtokenClaims)),
+ )
+ httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
+ Message: "OIDC token missing 'sub' claim field or 'sub' claim field is empty.",
+ Detail: "'sub' claim field is required to be unique for all users by a given issue, " +
+ "an empty field is invalid and this authentication attempt is rejected.",
+ })
+ return
+ }
+
logger.Debug(ctx, "got oidc claims",
slog.F("source", "id_token"),
slog.F("claim_fields", claimFields(idtokenClaims)),
diff --git a/coderd/userauth_test.go b/coderd/userauth_test.go
index f0668507e3..b0a4dd80ef 100644
--- a/coderd/userauth_test.go
+++ b/coderd/userauth_test.go
@@ -72,6 +72,7 @@ func TestOIDCOauthLoginWithExisting(t *testing.T) {
"email": "alice@coder.com",
"email_verified": true,
"preferred_username": username,
+ "sub": uuid.NewString(),
}
helper := oidctest.NewLoginHelper(client, fake)
@@ -899,10 +900,19 @@ func TestUserOIDC(t *testing.T) {
IgnoreEmailVerified bool
IgnoreUserInfo bool
}{
+ {
+ Name: "NoSub",
+ IDTokenClaims: jwt.MapClaims{
+ "email": "kyle@kwc.io",
+ },
+ AllowSignups: true,
+ StatusCode: http.StatusBadRequest,
+ },
{
Name: "EmailOnly",
IDTokenClaims: jwt.MapClaims{
"email": "kyle@kwc.io",
+ "sub": uuid.NewString(),
},
AllowSignups: true,
StatusCode: http.StatusOK,
@@ -915,6 +925,7 @@ func TestUserOIDC(t *testing.T) {
IDTokenClaims: jwt.MapClaims{
"email": "kyle@kwc.io",
"email_verified": false,
+ "sub": uuid.NewString(),
},
AllowSignups: true,
StatusCode: http.StatusForbidden,
@@ -924,6 +935,7 @@ func TestUserOIDC(t *testing.T) {
IDTokenClaims: jwt.MapClaims{
"email": 3.14159,
"email_verified": false,
+ "sub": uuid.NewString(),
},
AllowSignups: true,
StatusCode: http.StatusBadRequest,
@@ -933,6 +945,7 @@ func TestUserOIDC(t *testing.T) {
IDTokenClaims: jwt.MapClaims{
"email": "kyle@kwc.io",
"email_verified": false,
+ "sub": uuid.NewString(),
},
AllowSignups: true,
StatusCode: http.StatusOK,
@@ -946,6 +959,7 @@ func TestUserOIDC(t *testing.T) {
IDTokenClaims: jwt.MapClaims{
"email": "kyle@kwc.io",
"email_verified": true,
+ "sub": uuid.NewString(),
},
AllowSignups: true,
EmailDomain: []string{
@@ -958,6 +972,7 @@ func TestUserOIDC(t *testing.T) {
IDTokenClaims: jwt.MapClaims{
"email": "cian@coder.com",
"email_verified": true,
+ "sub": uuid.NewString(),
},
AllowSignups: true,
EmailDomain: []string{
@@ -970,6 +985,7 @@ func TestUserOIDC(t *testing.T) {
IDTokenClaims: jwt.MapClaims{
"email": "kyle@kwc.io",
"email_verified": true,
+ "sub": uuid.NewString(),
},
AllowSignups: true,
EmailDomain: []string{
@@ -982,6 +998,7 @@ func TestUserOIDC(t *testing.T) {
IDTokenClaims: jwt.MapClaims{
"email": "kyle@KWC.io",
"email_verified": true,
+ "sub": uuid.NewString(),
},
AllowSignups: true,
AssertUser: func(t testing.TB, u codersdk.User) {
@@ -997,6 +1014,7 @@ func TestUserOIDC(t *testing.T) {
IDTokenClaims: jwt.MapClaims{
"email": "colin@gmail.com",
"email_verified": true,
+ "sub": uuid.NewString(),
},
AllowSignups: true,
EmailDomain: []string{
@@ -1015,6 +1033,7 @@ func TestUserOIDC(t *testing.T) {
IDTokenClaims: jwt.MapClaims{
"email": "kyle@kwc.io",
"email_verified": true,
+ "sub": uuid.NewString(),
},
StatusCode: http.StatusForbidden,
},
@@ -1023,6 +1042,7 @@ func TestUserOIDC(t *testing.T) {
IDTokenClaims: jwt.MapClaims{
"email": "kyle@kwc.io",
"email_verified": true,
+ "sub": uuid.NewString(),
},
AssertUser: func(t testing.TB, u codersdk.User) {
assert.Equal(t, "kyle", u.Username)
@@ -1036,6 +1056,7 @@ func TestUserOIDC(t *testing.T) {
"email": "kyle@kwc.io",
"email_verified": true,
"preferred_username": "hotdog",
+ "sub": uuid.NewString(),
},
AssertUser: func(t testing.TB, u codersdk.User) {
assert.Equal(t, "hotdog", u.Username)
@@ -1049,6 +1070,7 @@ func TestUserOIDC(t *testing.T) {
"email": "kyle@kwc.io",
"email_verified": true,
"name": "Hot Dog",
+ "sub": uuid.NewString(),
},
AssertUser: func(t testing.TB, u codersdk.User) {
assert.Equal(t, "Hot Dog", u.Name)
@@ -1065,6 +1087,7 @@ func TestUserOIDC(t *testing.T) {
// However, we should not fail to log someone in if their name is too long.
// Just truncate it.
"name": strings.Repeat("a", 129),
+ "sub": uuid.NewString(),
},
AllowSignups: true,
StatusCode: http.StatusOK,
@@ -1080,6 +1103,7 @@ func TestUserOIDC(t *testing.T) {
// Full names must not have leading or trailing whitespace, but this is a
// daft reason to fail a login.
"name": " Bobby Whitespace ",
+ "sub": uuid.NewString(),
},
AllowSignups: true,
StatusCode: http.StatusOK,
@@ -1096,6 +1120,7 @@ func TestUserOIDC(t *testing.T) {
"email_verified": true,
"name": "Kylium Carbonate",
"preferred_username": "kyle@kwc.io",
+ "sub": uuid.NewString(),
},
AssertUser: func(t testing.TB, u codersdk.User) {
assert.Equal(t, "kyle", u.Username)
@@ -1108,6 +1133,7 @@ func TestUserOIDC(t *testing.T) {
Name: "UsernameIsEmail",
IDTokenClaims: jwt.MapClaims{
"preferred_username": "kyle@kwc.io",
+ "sub": uuid.NewString(),
},
AssertUser: func(t testing.TB, u codersdk.User) {
assert.Equal(t, "kyle", u.Username)
@@ -1123,6 +1149,7 @@ func TestUserOIDC(t *testing.T) {
"email_verified": true,
"preferred_username": "kyle",
"picture": "/example.png",
+ "sub": uuid.NewString(),
},
AssertUser: func(t testing.TB, u codersdk.User) {
assert.Equal(t, "/example.png", u.AvatarURL)
@@ -1136,6 +1163,7 @@ func TestUserOIDC(t *testing.T) {
IDTokenClaims: jwt.MapClaims{
"email": "kyle@kwc.io",
"email_verified": true,
+ "sub": uuid.NewString(),
},
UserInfoClaims: jwt.MapClaims{
"preferred_username": "potato",
@@ -1155,6 +1183,7 @@ func TestUserOIDC(t *testing.T) {
IDTokenClaims: jwt.MapClaims{
"email": "coolin@coder.com",
"groups": []string{"pingpong"},
+ "sub": uuid.NewString(),
},
AllowSignups: true,
StatusCode: http.StatusOK,
@@ -1164,6 +1193,7 @@ func TestUserOIDC(t *testing.T) {
IDTokenClaims: jwt.MapClaims{
"email": "internaluser@internal.domain",
"email_verified": false,
+ "sub": uuid.NewString(),
},
UserInfoClaims: jwt.MapClaims{
"email": "externaluser@external.domain",
@@ -1182,6 +1212,7 @@ func TestUserOIDC(t *testing.T) {
IDTokenClaims: jwt.MapClaims{
"email": "internaluser@internal.domain",
"email_verified": false,
+ "sub": uuid.NewString(),
},
UserInfoClaims: jwt.MapClaims{
"email": 1,
@@ -1197,6 +1228,7 @@ func TestUserOIDC(t *testing.T) {
"email_verified": true,
"name": "User McName",
"preferred_username": "user",
+ "sub": uuid.NewString(),
},
UserInfoClaims: jwt.MapClaims{
"email": "user.mcname@external.domain",
@@ -1216,6 +1248,7 @@ func TestUserOIDC(t *testing.T) {
IDTokenClaims: inflateClaims(t, jwt.MapClaims{
"email": "user@domain.tld",
"email_verified": true,
+ "sub": uuid.NewString(),
}, 65536),
AssertUser: func(t testing.TB, u codersdk.User) {
assert.Equal(t, "user", u.Username)
@@ -1228,6 +1261,7 @@ func TestUserOIDC(t *testing.T) {
IDTokenClaims: jwt.MapClaims{
"email": "user@domain.tld",
"email_verified": true,
+ "sub": uuid.NewString(),
},
UserInfoClaims: inflateClaims(t, jwt.MapClaims{}, 65536),
AssertUser: func(t testing.TB, u codersdk.User) {
@@ -1242,6 +1276,7 @@ func TestUserOIDC(t *testing.T) {
"iss": "https://mismatch.com",
"email": "user@domain.tld",
"email_verified": true,
+ "sub": uuid.NewString(),
},
AllowSignups: true,
StatusCode: http.StatusBadRequest,
@@ -1331,6 +1366,7 @@ func TestUserOIDC(t *testing.T) {
client, resp := fake.AttemptLogin(t, owner, jwt.MapClaims{
"email": user.Email,
+ "sub": uuid.NewString(),
})
require.Equal(t, http.StatusOK, resp.StatusCode)
@@ -1369,6 +1405,7 @@ func TestUserOIDC(t *testing.T) {
claims := jwt.MapClaims{
"email": userData.Email,
+ "sub": uuid.NewString(),
}
var err error
user.HTTPClient.Jar, err = cookiejar.New(nil)
@@ -1439,6 +1476,7 @@ func TestUserOIDC(t *testing.T) {
claims := jwt.MapClaims{
"email": userData.Email,
+ "sub": uuid.NewString(),
}
user.HTTPClient.Jar, err = cookiejar.New(nil)
require.NoError(t, err)
@@ -1509,6 +1547,7 @@ func TestUserOIDC(t *testing.T) {
numLogs := len(auditor.AuditLogs())
claims := jwt.MapClaims{
"email": "jon@coder.com",
+ "sub": uuid.NewString(),
}
userClient, _ := fake.Login(t, client, claims)
@@ -1629,6 +1668,7 @@ func TestUserOIDC(t *testing.T) {
claims := jwt.MapClaims{
"email": "user@example.com",
"email_verified": true,
+ "sub": uuid.NewString(),
}
// Perform the login
@@ -1794,6 +1834,7 @@ func TestOIDCSkipIssuer(t *testing.T) {
userClient, _ := fake.Login(t, owner, jwt.MapClaims{
"iss": secondaryURLString,
"email": "alice@coder.com",
+ "sub": uuid.NewString(),
})
found, err := userClient.User(ctx, "me")
require.NoError(t, err)
diff --git a/coderd/users_test.go b/coderd/users_test.go
index 53ec98b30d..74c27da7ef 100644
--- a/coderd/users_test.go
+++ b/coderd/users_test.go
@@ -831,6 +831,7 @@ func TestPostUsers(t *testing.T) {
// Try to log in with OIDC.
userClient, _ := fake.Login(t, client, jwt.MapClaims{
"email": email,
+ "sub": uuid.NewString(),
})
found, err := userClient.User(ctx, "me")
diff --git a/coderd/util/maps/maps.go b/coderd/util/maps/maps.go
new file mode 100644
index 0000000000..6d3d31717d
--- /dev/null
+++ b/coderd/util/maps/maps.go
@@ -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
+}
diff --git a/coderd/util/maps/maps_test.go b/coderd/util/maps/maps_test.go
new file mode 100644
index 0000000000..1858d6467e
--- /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 026c3581ff..8132da9bd7 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,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
diff --git a/coderd/workspaceagents_test.go b/coderd/workspaceagents_test.go
index c75b3f3ed5..f7a3513d4f 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,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)
diff --git a/codersdk/provisionerdaemons.go b/codersdk/provisionerdaemons.go
index 5a93ba9fca..9c8f131cca 100644
--- a/codersdk/provisionerdaemons.go
+++ b/codersdk/provisionerdaemons.go
@@ -137,6 +137,7 @@ type ProvisionerJobMetadata struct {
TemplateID uuid.UUID `json:"template_id" format:"uuid" table:"template id"`
TemplateName string `json:"template_name" table:"template name"`
TemplateDisplayName string `json:"template_display_name" table:"template display name"`
+ TemplateIcon string `json:"template_icon" table:"template icon"`
WorkspaceID *uuid.UUID `json:"workspace_id,omitempty" format:"uuid" table:"workspace id"`
WorkspaceName string `json:"workspace_name,omitempty" table:"workspace name"`
}
@@ -165,24 +166,24 @@ func JobIsMissingParameterErrorCode(code JobErrorCode) bool {
// ProvisionerJob describes the job executed by the provisioning daemon.
type ProvisionerJob struct {
- ID uuid.UUID `json:"id" format:"uuid" table:"id"`
- CreatedAt time.Time `json:"created_at" format:"date-time" table:"created at"`
- StartedAt *time.Time `json:"started_at,omitempty" format:"date-time" table:"started at"`
- CompletedAt *time.Time `json:"completed_at,omitempty" format:"date-time" table:"completed at"`
- CanceledAt *time.Time `json:"canceled_at,omitempty" format:"date-time" table:"canceled at"`
- Error string `json:"error,omitempty" table:"error"`
- ErrorCode JobErrorCode `json:"error_code,omitempty" enums:"REQUIRED_TEMPLATE_VARIABLES" table:"error code"`
- Status ProvisionerJobStatus `json:"status" enums:"pending,running,succeeded,canceling,canceled,failed" table:"status"`
- WorkerID *uuid.UUID `json:"worker_id,omitempty" format:"uuid" table:"worker id"`
- FileID uuid.UUID `json:"file_id" format:"uuid" table:"file id"`
- Tags map[string]string `json:"tags" table:"tags"`
- QueuePosition int `json:"queue_position" table:"queue position"`
- QueueSize int `json:"queue_size" table:"queue size"`
- OrganizationID uuid.UUID `json:"organization_id" format:"uuid" table:"organization id"`
- Input ProvisionerJobInput `json:"input" table:"input,recursive_inline"`
- Type ProvisionerJobType `json:"type" table:"type"`
- AvailableWorkers []uuid.UUID `json:"available_workers,omitempty" format:"uuid" table:"available workers"`
- Metadata *ProvisionerJobMetadata `json:"metadata,omitempty" table:"metadata,recursive_inline"`
+ ID uuid.UUID `json:"id" format:"uuid" table:"id"`
+ CreatedAt time.Time `json:"created_at" format:"date-time" table:"created at"`
+ StartedAt *time.Time `json:"started_at,omitempty" format:"date-time" table:"started at"`
+ CompletedAt *time.Time `json:"completed_at,omitempty" format:"date-time" table:"completed at"`
+ CanceledAt *time.Time `json:"canceled_at,omitempty" format:"date-time" table:"canceled at"`
+ Error string `json:"error,omitempty" table:"error"`
+ ErrorCode JobErrorCode `json:"error_code,omitempty" enums:"REQUIRED_TEMPLATE_VARIABLES" table:"error code"`
+ Status ProvisionerJobStatus `json:"status" enums:"pending,running,succeeded,canceling,canceled,failed" table:"status"`
+ WorkerID *uuid.UUID `json:"worker_id,omitempty" format:"uuid" table:"worker id"`
+ FileID uuid.UUID `json:"file_id" format:"uuid" table:"file id"`
+ Tags map[string]string `json:"tags" table:"tags"`
+ QueuePosition int `json:"queue_position" table:"queue position"`
+ QueueSize int `json:"queue_size" table:"queue size"`
+ OrganizationID uuid.UUID `json:"organization_id" format:"uuid" table:"organization id"`
+ Input ProvisionerJobInput `json:"input" table:"input,recursive_inline"`
+ Type ProvisionerJobType `json:"type" table:"type"`
+ AvailableWorkers []uuid.UUID `json:"available_workers,omitempty" format:"uuid" table:"available workers"`
+ Metadata ProvisionerJobMetadata `json:"metadata" table:"metadata,recursive_inline"`
}
// ProvisionerJobLog represents the provisioner log entry annotated with source and level.
diff --git a/codersdk/workspaceagents.go b/codersdk/workspaceagents.go
index 4f04b70aee..8e2209fa80 100644
--- a/codersdk/workspaceagents.go
+++ b/codersdk/workspaceagents.go
@@ -392,6 +392,72 @@ func (c *Client) WorkspaceAgentListeningPorts(ctx context.Context, agentID uuid.
return listeningPorts, json.NewDecoder(res.Body).Decode(&listeningPorts)
}
+// 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.
+ 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 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"`
+}
+
+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 4c3a9539bb..f803f8736a 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.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 codersdk.WorkspaceAgentListContainersResponse{}, xerrors.Errorf("do request: %w", err)
+ }
+ defer res.Body.Close()
+ if res.StatusCode != http.StatusOK {
+ return codersdk.WorkspaceAgentListContainersResponse{}, codersdk.ReadBodyAsError(res)
+ }
+ var resp codersdk.WorkspaceAgentListContainersResponse
+ 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 22ebe7f355..38e30c35e1 100644
--- a/docs/reference/api/agents.md
+++ b/docs/reference/api/agents.md
@@ -638,6 +638,71 @@ 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"
+ }
+ }
+ ],
+ "warnings": [
+ "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/builds.md b/docs/reference/api/builds.md
index 73fd35bb4f..26f6df4a55 100644
--- a/docs/reference/api/builds.md
+++ b/docs/reference/api/builds.md
@@ -52,6 +52,7 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/workspace/{workspacenam
},
"metadata": {
"template_display_name": "string",
+ "template_icon": "string",
"template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc",
"template_name": "string",
"template_version_name": "string",
@@ -265,6 +266,7 @@ curl -X GET http://coder-server:8080/api/v2/workspacebuilds/{workspacebuild} \
},
"metadata": {
"template_display_name": "string",
+ "template_icon": "string",
"template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc",
"template_name": "string",
"template_version_name": "string",
@@ -920,6 +922,7 @@ curl -X GET http://coder-server:8080/api/v2/workspacebuilds/{workspacebuild}/sta
},
"metadata": {
"template_display_name": "string",
+ "template_icon": "string",
"template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc",
"template_name": "string",
"template_version_name": "string",
@@ -1206,6 +1209,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaces/{workspace}/builds \
},
"metadata": {
"template_display_name": "string",
+ "template_icon": "string",
"template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc",
"template_name": "string",
"template_version_name": "string",
@@ -1397,6 +1401,7 @@ Status Code **200**
| `»»» workspace_build_id` | string(uuid) | false | | |
| `»» metadata` | [codersdk.ProvisionerJobMetadata](schemas.md#codersdkprovisionerjobmetadata) | false | | |
| `»»» template_display_name` | string | false | | |
+| `»»» template_icon` | string | false | | |
| `»»» template_id` | string(uuid) | false | | |
| `»»» template_name` | string | false | | |
| `»»» template_version_name` | string | false | | |
@@ -1646,6 +1651,7 @@ curl -X POST http://coder-server:8080/api/v2/workspaces/{workspace}/builds \
},
"metadata": {
"template_display_name": "string",
+ "template_icon": "string",
"template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc",
"template_name": "string",
"template_version_name": "string",
diff --git a/docs/reference/api/organizations.md b/docs/reference/api/organizations.md
index 52442b6258..08fceb2e29 100644
--- a/docs/reference/api/organizations.md
+++ b/docs/reference/api/organizations.md
@@ -407,6 +407,7 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/provisi
},
"metadata": {
"template_display_name": "string",
+ "template_icon": "string",
"template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc",
"template_name": "string",
"template_version_name": "string",
@@ -455,6 +456,7 @@ Status Code **200**
| `»» workspace_build_id` | string(uuid) | false | | |
| `» metadata` | [codersdk.ProvisionerJobMetadata](schemas.md#codersdkprovisionerjobmetadata) | false | | |
| `»» template_display_name` | string | false | | |
+| `»» template_icon` | string | false | | |
| `»» template_id` | string(uuid) | false | | |
| `»» template_name` | string | false | | |
| `»» template_version_name` | string | false | | |
@@ -530,6 +532,7 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/provisi
},
"metadata": {
"template_display_name": "string",
+ "template_icon": "string",
"template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc",
"template_name": "string",
"template_version_name": "string",
diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md
index ac402d1f9f..d5d471ad46 100644
--- a/docs/reference/api/schemas.md
+++ b/docs/reference/api/schemas.md
@@ -4638,6 +4638,7 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith
},
"metadata": {
"template_display_name": "string",
+ "template_icon": "string",
"template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc",
"template_name": "string",
"template_version_name": "string",
@@ -4751,6 +4752,7 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith
```json
{
"template_display_name": "string",
+ "template_icon": "string",
"template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc",
"template_name": "string",
"template_version_name": "string",
@@ -4764,6 +4766,7 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith
| Name | Type | Required | Restrictions | Description |
|-------------------------|--------|----------|--------------|-------------|
| `template_display_name` | string | false | | |
+| `template_icon` | string | false | | |
| `template_id` | string | false | | |
| `template_name` | string | false | | |
| `template_version_name` | string | false | | |
@@ -6170,6 +6173,7 @@ Restarts will only happen on weekdays in this list on weeks which line up with W
},
"metadata": {
"template_display_name": "string",
+ "template_icon": "string",
"template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc",
"template_name": "string",
"template_version_name": "string",
@@ -7243,6 +7247,7 @@ If the schedule is empty, the user will be updated to use the default schedule.|
},
"metadata": {
"template_display_name": "string",
+ "template_icon": "string",
"template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc",
"template_name": "string",
"template_version_name": "string",
@@ -7595,6 +7600,50 @@ If the schedule is empty, the user will be updated to use the default schedule.|
| `updated_at` | string | false | | |
| `version` | string | false | | |
+## codersdk.WorkspaceAgentDevcontainer
+
+```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
@@ -7633,6 +7682,48 @@ 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"
+ }
+ }
+ ],
+ "warnings": [
+ "string"
+ ]
+}
+```
+
+### Properties
+
+| 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
```json
@@ -7993,6 +8084,7 @@ If the schedule is empty, the user will be updated to use the default schedule.|
},
"metadata": {
"template_display_name": "string",
+ "template_icon": "string",
"template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc",
"template_name": "string",
"template_version_name": "string",
@@ -8665,6 +8757,7 @@ If the schedule is empty, the user will be updated to use the default schedule.|
},
"metadata": {
"template_display_name": "string",
+ "template_icon": "string",
"template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc",
"template_name": "string",
"template_version_name": "string",
diff --git a/docs/reference/api/templates.md b/docs/reference/api/templates.md
index b5c1221412..57d35a6aab 100644
--- a/docs/reference/api/templates.md
+++ b/docs/reference/api/templates.md
@@ -464,6 +464,7 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/templat
},
"metadata": {
"template_display_name": "string",
+ "template_icon": "string",
"template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc",
"template_name": "string",
"template_version_name": "string",
@@ -560,6 +561,7 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/templat
},
"metadata": {
"template_display_name": "string",
+ "template_icon": "string",
"template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc",
"template_name": "string",
"template_version_name": "string",
@@ -680,6 +682,7 @@ curl -X POST http://coder-server:8080/api/v2/organizations/{organization}/templa
},
"metadata": {
"template_display_name": "string",
+ "template_icon": "string",
"template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc",
"template_name": "string",
"template_version_name": "string",
@@ -1228,6 +1231,7 @@ curl -X GET http://coder-server:8080/api/v2/templates/{template}/versions \
},
"metadata": {
"template_display_name": "string",
+ "template_icon": "string",
"template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc",
"template_name": "string",
"template_version_name": "string",
@@ -1299,6 +1303,7 @@ Status Code **200**
| `»»» workspace_build_id` | string(uuid) | false | | |
| `»» metadata` | [codersdk.ProvisionerJobMetadata](schemas.md#codersdkprovisionerjobmetadata) | false | | |
| `»»» template_display_name` | string | false | | |
+| `»»» template_icon` | string | false | | |
| `»»» template_id` | string(uuid) | false | | |
| `»»» template_name` | string | false | | |
| `»»» template_version_name` | string | false | | |
@@ -1503,6 +1508,7 @@ curl -X GET http://coder-server:8080/api/v2/templates/{template}/versions/{templ
},
"metadata": {
"template_display_name": "string",
+ "template_icon": "string",
"template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc",
"template_name": "string",
"template_version_name": "string",
@@ -1574,6 +1580,7 @@ Status Code **200**
| `»»» workspace_build_id` | string(uuid) | false | | |
| `»» metadata` | [codersdk.ProvisionerJobMetadata](schemas.md#codersdkprovisionerjobmetadata) | false | | |
| `»»» template_display_name` | string | false | | |
+| `»»» template_icon` | string | false | | |
| `»»» template_id` | string(uuid) | false | | |
| `»»» template_name` | string | false | | |
| `»»» template_version_name` | string | false | | |
@@ -1668,6 +1675,7 @@ curl -X GET http://coder-server:8080/api/v2/templateversions/{templateversion} \
},
"metadata": {
"template_display_name": "string",
+ "template_icon": "string",
"template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc",
"template_name": "string",
"template_version_name": "string",
@@ -1773,6 +1781,7 @@ curl -X PATCH http://coder-server:8080/api/v2/templateversions/{templateversion}
},
"metadata": {
"template_display_name": "string",
+ "template_icon": "string",
"template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc",
"template_name": "string",
"template_version_name": "string",
@@ -1968,6 +1977,7 @@ curl -X POST http://coder-server:8080/api/v2/templateversions/{templateversion}/
},
"metadata": {
"template_display_name": "string",
+ "template_icon": "string",
"template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc",
"template_name": "string",
"template_version_name": "string",
@@ -2039,6 +2049,7 @@ curl -X GET http://coder-server:8080/api/v2/templateversions/{templateversion}/d
},
"metadata": {
"template_display_name": "string",
+ "template_icon": "string",
"template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc",
"template_name": "string",
"template_version_name": "string",
diff --git a/docs/reference/api/workspaces.md b/docs/reference/api/workspaces.md
index 680dec178b..7264b6dbb3 100644
--- a/docs/reference/api/workspaces.md
+++ b/docs/reference/api/workspaces.md
@@ -93,6 +93,7 @@ of the template will be used.
},
"metadata": {
"template_display_name": "string",
+ "template_icon": "string",
"template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc",
"template_name": "string",
"template_version_name": "string",
@@ -342,6 +343,7 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/workspace/{workspacenam
},
"metadata": {
"template_display_name": "string",
+ "template_icon": "string",
"template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc",
"template_name": "string",
"template_version_name": "string",
@@ -615,6 +617,7 @@ of the template will be used.
},
"metadata": {
"template_display_name": "string",
+ "template_icon": "string",
"template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc",
"template_name": "string",
"template_version_name": "string",
@@ -867,6 +870,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaces \
},
"metadata": {
"template_display_name": "string",
+ "template_icon": "string",
"template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc",
"template_name": "string",
"template_version_name": "string",
@@ -1113,6 +1117,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaces/{workspace} \
},
"metadata": {
"template_display_name": "string",
+ "template_icon": "string",
"template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc",
"template_name": "string",
"template_version_name": "string",
@@ -1478,6 +1483,7 @@ curl -X PUT http://coder-server:8080/api/v2/workspaces/{workspace}/dormant \
},
"metadata": {
"template_display_name": "string",
+ "template_icon": "string",
"template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc",
"template_name": "string",
"template_version_name": "string",
diff --git a/docs/reference/cli/provisioner_jobs_list.md b/docs/reference/cli/provisioner_jobs_list.md
index ed16448459..2cd40049e2 100644
--- a/docs/reference/cli/provisioner_jobs_list.md
+++ b/docs/reference/cli/provisioner_jobs_list.md
@@ -45,10 +45,10 @@ Select which organization (uuid or name) to use.
### -c, --column
-| | |
-|---------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
-| Type | [id\|created at\|started at\|completed at\|canceled at\|error\|error code\|status\|worker id\|file id\|tags\|queue position\|queue size\|organization id\|template version id\|workspace build id\|type\|available workers\|template version name\|template id\|template name\|template display name\|workspace id\|workspace name\|organization\|queue]
|
-| Default | created at,id,organization,status,type,queue,tags
|
+| | |
+|---------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| Type | [id\|created at\|started at\|completed at\|canceled at\|error\|error code\|status\|worker id\|file id\|tags\|queue position\|queue size\|organization id\|template version id\|workspace build id\|type\|available workers\|template version name\|template id\|template name\|template display name\|template icon\|workspace id\|workspace name\|organization\|queue]
|
+| Default | created at,id,organization,status,type,queue,tags
|
Columns to display in table output.
diff --git a/enterprise/cli/testdata/coder_provisioner_jobs_list_--help.golden b/enterprise/cli/testdata/coder_provisioner_jobs_list_--help.golden
index bd29b7560e..d6eb9a7681 100644
--- a/enterprise/cli/testdata/coder_provisioner_jobs_list_--help.golden
+++ b/enterprise/cli/testdata/coder_provisioner_jobs_list_--help.golden
@@ -11,7 +11,7 @@ OPTIONS:
-O, --org string, $CODER_ORGANIZATION
Select which organization (uuid or name) to use.
- -c, --column [id|created at|started at|completed at|canceled at|error|error code|status|worker id|file id|tags|queue position|queue size|organization id|template version id|workspace build id|type|available workers|template version name|template id|template name|template display name|workspace id|workspace name|organization|queue] (default: created at,id,organization,status,type,queue,tags)
+ -c, --column [id|created at|started at|completed at|canceled at|error|error code|status|worker id|file id|tags|queue position|queue size|organization id|template version id|workspace build id|type|available workers|template version name|template id|template name|template display name|template icon|workspace id|workspace name|organization|queue] (default: created at,id,organization,status,type,queue,tags)
Columns to display in table output.
-l, --limit int, $CODER_PROVISIONER_JOB_LIST_LIMIT (default: 50)
diff --git a/enterprise/coderd/scim_test.go b/enterprise/coderd/scim_test.go
index a8d5c67ed4..5396180b4a 100644
--- a/enterprise/coderd/scim_test.go
+++ b/enterprise/coderd/scim_test.go
@@ -10,6 +10,7 @@ import (
"testing"
"github.com/golang-jwt/jwt/v4"
+ "github.com/google/uuid"
"github.com/imulab/go-scim/pkg/v2/handlerutil"
"github.com/imulab/go-scim/pkg/v2/spec"
"github.com/stretchr/testify/assert"
@@ -568,6 +569,7 @@ func TestScim(t *testing.T) {
//nolint:bodyclose
scimUserClient, _ := fake.Login(t, client, jwt.MapClaims{
"email": sUser.Emails[0].Value,
+ "sub": uuid.NewString(),
})
scimUser, err = scimUserClient.User(ctx, codersdk.Me)
require.NoError(t, err)
@@ -836,6 +838,7 @@ func TestScim(t *testing.T) {
//nolint:bodyclose
scimUserClient, _ := fake.Login(t, client, jwt.MapClaims{
"email": sUser.Emails[0].Value,
+ "sub": uuid.NewString(),
})
scimUser, err = scimUserClient.User(ctx, codersdk.Me)
require.NoError(t, err)
diff --git a/enterprise/coderd/userauth_test.go b/enterprise/coderd/userauth_test.go
index d3e997608f..267e1168f8 100644
--- a/enterprise/coderd/userauth_test.go
+++ b/enterprise/coderd/userauth_test.go
@@ -50,6 +50,7 @@ func TestUserOIDC(t *testing.T) {
claims := jwt.MapClaims{
"email": "alice@coder.com",
+ "sub": uuid.NewString(),
}
// Login a new client that signs up
@@ -82,6 +83,7 @@ func TestUserOIDC(t *testing.T) {
claims := jwt.MapClaims{
"email": "alice@coder.com",
+ "sub": uuid.NewString(),
}
// Login a new client that signs up
@@ -152,9 +154,11 @@ func TestUserOIDC(t *testing.T) {
require.NoError(t, err)
require.Equal(t, expectedSettings.Field, settings.Field)
+ sub := uuid.NewString()
claims := jwt.MapClaims{
"email": "alice@coder.com",
"organization": []string{"first", "second"},
+ "sub": sub,
}
// Then: a new user logs in with claims "second" and "third", they
@@ -169,7 +173,7 @@ func TestUserOIDC(t *testing.T) {
fields, err := runner.AdminClient.GetAvailableIDPSyncFields(ctx)
require.NoError(t, err)
require.ElementsMatch(t, []string{
- "aud", "exp", "iss", // Always included from jwt
+ "sub", "aud", "exp", "iss", // Always included from jwt
"email", "organization",
}, fields)
@@ -204,6 +208,7 @@ func TestUserOIDC(t *testing.T) {
runner.Login(t, jwt.MapClaims{
"email": "alice@coder.com",
"organization": []string{"second"},
+ "sub": sub,
})
runner.AssertOrganizations(t, "alice", true, []uuid.UUID{orgTwo.ID})
})
@@ -238,10 +243,12 @@ func TestUserOIDC(t *testing.T) {
})
fourth := dbgen.Organization(t, runner.API.Database, database.Organization{})
+ sub := uuid.NewString()
ctx := testutil.Context(t, testutil.WaitMedium)
claims := jwt.MapClaims{
"email": "alice@coder.com",
"organization": []string{"second", "third"},
+ "sub": sub,
}
// Then: a new user logs in with claims "second" and "third", they
@@ -265,6 +272,7 @@ func TestUserOIDC(t *testing.T) {
runner.Login(t, jwt.MapClaims{
"email": "alice@coder.com",
"organization": []string{"third"},
+ "sub": sub,
})
runner.AssertOrganizations(t, "alice", false, []uuid.UUID{third})
})
@@ -289,6 +297,7 @@ func TestUserOIDC(t *testing.T) {
claims := jwt.MapClaims{
"email": "alice@coder.com",
+ "sub": uuid.NewString(),
}
// Login a new client that signs up
client, resp := runner.Login(t, claims)
@@ -328,6 +337,7 @@ func TestUserOIDC(t *testing.T) {
// This is sent as a **string** intentionally instead
// of an array.
"roles": oidcRoleName,
+ "sub": uuid.NewString(),
})
require.Equal(t, http.StatusOK, resp.StatusCode)
runner.AssertRoles(t, "alice", []string{rbac.RoleTemplateAdmin().String()})
@@ -398,9 +408,11 @@ func TestUserOIDC(t *testing.T) {
})
// User starts with the owner role
+ sub := uuid.NewString()
_, resp := runner.Login(t, jwt.MapClaims{
"email": "alice@coder.com",
"roles": []string{"random", oidcRoleName, rbac.RoleOwner().String()},
+ "sub": sub,
})
require.Equal(t, http.StatusOK, resp.StatusCode)
runner.AssertRoles(t, "alice", []string{rbac.RoleTemplateAdmin().String(), rbac.RoleUserAdmin().String(), rbac.RoleOwner().String()})
@@ -409,6 +421,7 @@ func TestUserOIDC(t *testing.T) {
_, resp = runner.Login(t, jwt.MapClaims{
"email": "alice@coder.com",
"roles": []string{"random"},
+ "sub": sub,
})
require.Equal(t, http.StatusOK, resp.StatusCode)
@@ -429,9 +442,11 @@ func TestUserOIDC(t *testing.T) {
},
})
+ sub := uuid.NewString()
_, resp := runner.Login(t, jwt.MapClaims{
"email": "alice@coder.com",
"roles": []string{},
+ "sub": sub,
})
require.Equal(t, http.StatusOK, resp.StatusCode)
// Try to manually update user roles, even though controlled by oidc
@@ -476,6 +491,7 @@ func TestUserOIDC(t *testing.T) {
_, resp := runner.Login(t, jwt.MapClaims{
"email": "alice@coder.com",
groupClaim: []string{groupName},
+ "sub": uuid.New(),
})
require.Equal(t, http.StatusOK, resp.StatusCode)
runner.AssertGroups(t, "alice", []string{groupName})
@@ -510,6 +526,7 @@ func TestUserOIDC(t *testing.T) {
_, resp := runner.Login(t, jwt.MapClaims{
"email": "alice@coder.com",
groupClaim: []string{oidcGroupName},
+ "sub": uuid.New(),
})
require.Equal(t, http.StatusOK, resp.StatusCode)
runner.AssertGroups(t, "alice", []string{coderGroupName})
@@ -546,6 +563,7 @@ func TestUserOIDC(t *testing.T) {
client, resp := runner.Login(t, jwt.MapClaims{
"email": "alice@coder.com",
groupClaim: []string{groupName},
+ "sub": uuid.New(),
})
require.Equal(t, http.StatusOK, resp.StatusCode)
runner.AssertGroups(t, "alice", []string{groupName})
@@ -579,9 +597,11 @@ func TestUserOIDC(t *testing.T) {
require.NoError(t, err)
require.Len(t, group.Members, 0)
+ sub := uuid.NewString()
_, resp := runner.Login(t, jwt.MapClaims{
"email": "alice@coder.com",
groupClaim: []string{groupName},
+ "sub": sub,
})
require.Equal(t, http.StatusOK, resp.StatusCode)
runner.AssertGroups(t, "alice", []string{groupName})
@@ -589,6 +609,7 @@ func TestUserOIDC(t *testing.T) {
// Refresh without the group claim
_, resp = runner.Login(t, jwt.MapClaims{
"email": "alice@coder.com",
+ "sub": sub,
})
require.Equal(t, http.StatusOK, resp.StatusCode)
runner.AssertGroups(t, "alice", []string{})
@@ -612,6 +633,7 @@ func TestUserOIDC(t *testing.T) {
_, resp := runner.Login(t, jwt.MapClaims{
"email": "alice@coder.com",
groupClaim: []string{"not-exists"},
+ "sub": uuid.New(),
})
require.Equal(t, http.StatusOK, resp.StatusCode)
runner.AssertGroups(t, "alice", []string{})
@@ -637,6 +659,7 @@ func TestUserOIDC(t *testing.T) {
_, resp := runner.Login(t, jwt.MapClaims{
"email": "alice@coder.com",
groupClaim: []string{groupName},
+ "sub": uuid.New(),
})
require.Equal(t, http.StatusOK, resp.StatusCode)
runner.AssertGroups(t, "alice", []string{groupName})
@@ -665,6 +688,7 @@ func TestUserOIDC(t *testing.T) {
// This is sent as a **string** intentionally instead
// of an array.
groupClaim: groupName,
+ "sub": uuid.New(),
})
require.Equal(t, http.StatusOK, resp.StatusCode)
runner.AssertGroups(t, "alice", []string{groupName})
@@ -686,9 +710,11 @@ func TestUserOIDC(t *testing.T) {
})
// Test forbidden
+ sub := uuid.NewString()
_, resp := runner.AttemptLogin(t, jwt.MapClaims{
"email": "alice@coder.com",
groupClaim: []string{"not-allowed"},
+ "sub": sub,
})
require.Equal(t, http.StatusForbidden, resp.StatusCode)
@@ -696,6 +722,7 @@ func TestUserOIDC(t *testing.T) {
client, _ := runner.Login(t, jwt.MapClaims{
"email": "alice@coder.com",
groupClaim: []string{allowedGroup},
+ "sub": sub,
})
ctx := testutil.Context(t, testutil.WaitShort)
@@ -719,6 +746,7 @@ func TestUserOIDC(t *testing.T) {
claims := jwt.MapClaims{
"email": "alice@coder.com",
+ "sub": uuid.NewString(),
}
// Login a new client that signs up
client, resp := runner.Login(t, claims)
@@ -747,6 +775,7 @@ func TestUserOIDC(t *testing.T) {
claims := jwt.MapClaims{
"email": "alice@coder.com",
+ "sub": uuid.NewString(),
}
// Login a new client that signs up
client, resp := runner.Login(t, claims)
@@ -921,6 +950,7 @@ func TestGroupSync(t *testing.T) {
require.NoError(t, err, "user must be oidc type")
// Log in the new user
+ tc.claims["sub"] = uuid.NewString()
tc.claims["email"] = user.Email
_, resp := runner.Login(t, tc.claims)
require.Equal(t, http.StatusOK, resp.StatusCode)
diff --git a/go.mod b/go.mod
index 9aa8df2514..81d9f9adf2 100644
--- a/go.mod
+++ b/go.mod
@@ -188,7 +188,7 @@ require (
go.uber.org/goleak v1.3.1-0.20240429205332-517bace7cc29
go.uber.org/mock v0.5.0
go4.org/netipx v0.0.0-20230728180743-ad4cb58a6516
- golang.org/x/crypto v0.32.0
+ golang.org/x/crypto v0.33.0
golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa
golang.org/x/mod v0.23.0
golang.org/x/net v0.34.0
@@ -196,10 +196,10 @@ require (
golang.org/x/sync v0.11.0
golang.org/x/sys v0.30.0
golang.org/x/term v0.29.0
- golang.org/x/text v0.21.0 // indirect
+ golang.org/x/text v0.22.0 // indirect
golang.org/x/tools v0.29.0
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da
- google.golang.org/api v0.219.0
+ google.golang.org/api v0.220.0
google.golang.org/grpc v1.70.0
google.golang.org/protobuf v1.36.4
gopkg.in/DataDog/dd-trace-go.v1 v1.71.0
@@ -212,7 +212,7 @@ require (
)
require (
- cloud.google.com/go/auth v0.14.0 // indirect
+ cloud.google.com/go/auth v0.14.1 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.7 // indirect
cloud.google.com/go/logging v1.12.0 // indirect
cloud.google.com/go/longrunning v0.6.2 // indirect
@@ -460,7 +460,7 @@ require (
google.golang.org/appengine v1.6.8 // indirect
google.golang.org/genproto v0.0.0-20241118233622-e639e219e697 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250115164207-1a7da9e5054f // indirect
- google.golang.org/genproto/googleapis/rpc v0.0.0-20250124145028-65684f501c47 // indirect
+ google.golang.org/genproto/googleapis/rpc v0.0.0-20250127172529-29210b9bc287 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
howett.net/plist v1.0.0 // indirect
diff --git a/go.sum b/go.sum
index fbde404c51..3ce9301f91 100644
--- a/go.sum
+++ b/go.sum
@@ -1,7 +1,7 @@
cdr.dev/slog v1.6.2-0.20241112041820-0ec81e6e67bb h1:4MKA8lBQLnCqj2myJCb5Lzoa65y0tABO4gHrxuMdsCQ=
cdr.dev/slog v1.6.2-0.20241112041820-0ec81e6e67bb/go.mod h1:NaoTA7KwopCrnaSb0JXTC0PTp/O/Y83Lndnq0OEV3ZQ=
-cloud.google.com/go/auth v0.14.0 h1:A5C4dKV/Spdvxcl0ggWwWEzzP7AZMJSEIgrkngwhGYM=
-cloud.google.com/go/auth v0.14.0/go.mod h1:CYsoRL1PdiDuqeQpZE0bP2pnPrGqFcOkI0nldEQis+A=
+cloud.google.com/go/auth v0.14.1 h1:AwoJbzUdxA/whv1qj3TLKwh3XX5sikny2fc40wUl+h0=
+cloud.google.com/go/auth v0.14.1/go.mod h1:4JHUxlGXisL0AW8kXPtUF6ztuOksyfUQNFjfsOCXkPM=
cloud.google.com/go/auth/oauth2adapt v0.2.7 h1:/Lc7xODdqcEw8IrZ9SvwnlLX6j9FHQM74z6cBk9Rw6M=
cloud.google.com/go/auth/oauth2adapt v0.2.7/go.mod h1:NTbTTzfvPl1Y3V1nPpOgl2w6d/FjO7NNUQaWSox6ZMc=
cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I=
@@ -998,8 +998,8 @@ go.opentelemetry.io/collector/semconv v0.104.0/go.mod h1:yMVUCNoQPZVq/IPfrHrnntZ
go.opentelemetry.io/contrib v1.0.0/go.mod h1:EH4yDYeNoaTqn/8yCWQmfNB78VHfGX2Jt2bvnvzBlGM=
go.opentelemetry.io/contrib v1.19.0 h1:rnYI7OEPMWFeM4QCqWQ3InMJ0arWMR1i0Cx9A5hcjYM=
go.opentelemetry.io/contrib v1.19.0/go.mod h1:gIzjwWFoGazJmtCaDgViqOSJPde2mCWzv60o0bWPcZs=
-go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 h1:r6I7RJCN86bpD/FQwedZ0vSixDpwuWREjW9oRMsmqDc=
-go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0/go.mod h1:B9yO6b04uB80CzjedvewuqDhxJxi11s7/GtiGa8bAjI=
+go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.58.0 h1:PS8wXpbyaDJQ2VDHHncMe9Vct0Zn1fEjpsjrLxGJoSc=
+go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.58.0/go.mod h1:HDBUsEjOuRC0EzKZ1bSaRGZWUBAzo+MhAcUUORSr4D0=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 h1:CV7UdSGJt/Ao6Gp4CXckLxVRRsRgDHoI8XjbL3PDl8s=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0/go.mod h1:FRmFuRJfag1IZ2dPkHnEoSFVgTVPUd2qf5Vi69hLb8I=
go.opentelemetry.io/otel v1.3.0/go.mod h1:PWIKzi6JCp7sM0k9yZ43VX+T345uNbAkDKwHVjb2PTs=
@@ -1050,8 +1050,8 @@ golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5y
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
-golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
-golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
+golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
+golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa h1:ELnwvuAXPNtPk1TJRuGkI9fDTwym6AYBu0qzT8AcHdI=
golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa/go.mod h1:akd2r19cwCdwSwWeIdzYQGa/EZZyqcOdwWiwj5L5eKQ=
golang.org/x/image v0.22.0 h1:UtK5yLUzilVrkjMAZAZ34DXGpASN8i8pj8g+O+yd10g=
@@ -1147,8 +1147,8 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
-golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
-golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
+golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
+golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -1172,8 +1172,8 @@ golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6 h1:CawjfCvY
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6/go.mod h1:3rxYc4HtVcSG9gVaTs2GEBdehh+sYPOwKtyUWEOTb80=
golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus8eIuExIE=
golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI=
-google.golang.org/api v0.219.0 h1:nnKIvxKs/06jWawp2liznTBnMRQBEPpGo7I+oEypTX0=
-google.golang.org/api v0.219.0/go.mod h1:K6OmjGm+NtLrIkHxv1U3a0qIf/0JOvAHd5O/6AoyKYE=
+google.golang.org/api v0.220.0 h1:3oMI4gdBgB72WFVwE1nerDD8W3HUOS4kypK6rRLbGns=
+google.golang.org/api v0.220.0/go.mod h1:26ZAlY6aN/8WgpCzjPNy18QpYaz7Zgg1h0qe1GkZEmY=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=
google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
@@ -1181,8 +1181,8 @@ google.golang.org/genproto v0.0.0-20241118233622-e639e219e697 h1:ToEetK57OidYuqD
google.golang.org/genproto v0.0.0-20241118233622-e639e219e697/go.mod h1:JJrvXBWRZaFMxBufik1a4RpFw4HhgVtBBWQeQgUj2cc=
google.golang.org/genproto/googleapis/api v0.0.0-20250115164207-1a7da9e5054f h1:gap6+3Gk41EItBuyi4XX/bp4oqJ3UwuIMl25yGinuAA=
google.golang.org/genproto/googleapis/api v0.0.0-20250115164207-1a7da9e5054f/go.mod h1:Ic02D47M+zbarjYYUlK57y316f2MoN0gjAwI3f2S95o=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20250124145028-65684f501c47 h1:91mG8dNTpkC0uChJUQ9zCiRqx3GEEFOWaRZ0mI6Oj2I=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20250124145028-65684f501c47/go.mod h1:+2Yz8+CLJbIfL9z73EW45avw8Lmge3xVElCP9zEKi50=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20250127172529-29210b9bc287 h1:J1H9f+LEdWAfHcez/4cvaVBox7cOYT+IU6rgqj5x++8=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20250127172529-29210b9bc287/go.mod h1:8BS3B93F/U1juMFq9+EDk+qOT5CO1R9IzXxG3PTqiRk=
google.golang.org/grpc v1.70.0 h1:pWFv03aZoHzlRKHWicjsZytKAiYCtNS0dHbXnIdq7jQ=
google.golang.org/grpc v1.70.0/go.mod h1:ofIJqVKDXx/JiXrwr2IG4/zwdH9txy3IlF40RmcJSQw=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts
index 7bcf0cf800..9595f22d89 100644
--- a/site/src/api/typesGenerated.ts
+++ b/site/src/api/typesGenerated.ts
@@ -1651,7 +1651,7 @@ export interface ProvisionerJob {
readonly input: ProvisionerJobInput;
readonly type: ProvisionerJobType;
readonly available_workers?: readonly string[];
- readonly metadata?: ProvisionerJobMetadata;
+ readonly metadata: ProvisionerJobMetadata;
}
// From codersdk/provisionerdaemons.go
@@ -1677,6 +1677,7 @@ export interface ProvisionerJobMetadata {
readonly template_id: string;
readonly template_name: string;
readonly template_display_name: string;
+ readonly template_icon: string;
readonly workspace_id?: string;
readonly workspace_name?: string;
}
@@ -2939,6 +2940,19 @@ export interface WorkspaceAgent {
readonly startup_script_behavior: WorkspaceAgentStartupScriptBehavior;
}
+// From codersdk/workspaceagents.go
+export interface WorkspaceAgentDevcontainer {
+ 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;
@@ -2969,6 +2983,12 @@ export const WorkspaceAgentLifecycles: WorkspaceAgentLifecycle[] = [
"starting",
];
+// From codersdk/workspaceagents.go
+export interface WorkspaceAgentListContainersResponse {
+ readonly containers: readonly WorkspaceAgentDevcontainer[];
+ readonly warnings?: readonly string[];
+}
+
// From codersdk/workspaceagents.go
export interface WorkspaceAgentListeningPort {
readonly process_name: string;
diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts
index ee2158394c..a607df6bb8 100644
--- a/site/src/testHelpers/entities.ts
+++ b/site/src/testHelpers/entities.ts
@@ -660,6 +660,15 @@ export const MockProvisionerJob: TypesGen.ProvisionerJob = {
},
organization_id: MockOrganization.id,
type: "template_version_dry_run",
+ metadata: {
+ workspace_id: "test-workspace",
+ template_display_name: "Test Template",
+ template_icon: "/icon/code.svg",
+ template_id: "test-template",
+ template_name: "test-template",
+ template_version_name: "test-version",
+ workspace_name: "test-workspace",
+ },
};
export const MockFailedProvisionerJob: TypesGen.ProvisionerJob = {