From 31b1ff7d3b918413e029ee2676b544424a0f645a Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Mon, 10 Feb 2025 11:29:30 +0000 Subject: [PATCH 1/9] feat(agent): add container list handler (#16346) Fixes https://github.com/coder/coder/issues/16268 - Adds `/api/v2/workspaceagents/:id/containers` coderd endpoint that allows listing containers visible to the agent. Optional filtering by labels is supported. - Adds go tools to the `coder-dylib` CI step so we can generate mocks if needed --- .gitattributes | 1 + .github/workflows/ci.yaml | 9 + Makefile | 6 +- agent/agent.go | 9 +- agent/agentcontainers/acmock/acmock.go | 57 +++ agent/agentcontainers/acmock/doc.go | 4 + agent/agentcontainers/containers.go | 142 +++++++ agent/agentcontainers/containers_dockercli.go | 228 ++++++++++++ .../containers_internal_test.go | 348 ++++++++++++++++++ agent/api.go | 3 + coderd/apidoc/docs.go | 113 ++++++ coderd/apidoc/swagger.json | 109 ++++++ coderd/coderd.go | 1 + coderd/util/maps/maps.go | 27 ++ coderd/util/maps/maps_test.go | 64 ++++ coderd/workspaceagents.go | 94 +++++ coderd/workspaceagents_test.go | 189 ++++++++++ codersdk/workspaceagents.go | 66 ++++ codersdk/workspacesdk/agentconn.go | 16 + docs/reference/api/agents.md | 65 ++++ docs/reference/api/schemas.md | 86 +++++ site/src/api/typesGenerated.ts | 19 + 22 files changed, 1654 insertions(+), 2 deletions(-) create mode 100644 agent/agentcontainers/acmock/acmock.go create mode 100644 agent/agentcontainers/acmock/doc.go create mode 100644 agent/agentcontainers/containers.go create mode 100644 agent/agentcontainers/containers_dockercli.go create mode 100644 agent/agentcontainers/containers_internal_test.go create mode 100644 coderd/util/maps/maps.go create mode 100644 coderd/util/maps/maps_test.go 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/ci.yaml b/.github/workflows/ci.yaml index 7e1d811e08..64059f413f 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -961,6 +961,15 @@ jobs: - name: Setup Go uses: ./.github/actions/setup-go + # Needed to build dylibs. + - name: go install tools + run: | + go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.30 + go install storj.io/drpc/cmd/protoc-gen-go-drpc@v0.0.34 + go install golang.org/x/tools/cmd/goimports@latest + go install github.com/mikefarah/yq/v4@v4.44.3 + go install go.uber.org/mock/mockgen@v0.5.0 + - name: Install rcodesign if: ${{ github.repository_owner == 'coder' && github.ref == 'refs/heads/main' }} run: | diff --git a/Makefile b/Makefile index d71b1173f3..fe553324cd 100644 --- a/Makefile +++ b/Makefile @@ -563,7 +563,8 @@ GEN_FILES := \ site/e2e/provisionerGenerated.ts \ examples/examples.gen.json \ $(TAILNETTEST_MOCKS) \ - coderd/database/pubsub/psmock/psmock.go + coderd/database/pubsub/psmock/psmock.go \ + agent/agentcontainers/acmock/acmock.go # all gen targets should be added here and to gen/mark-fresh @@ -629,6 +630,9 @@ coderd/database/dbmock/dbmock.go: coderd/database/db.go coderd/database/querier. coderd/database/pubsub/psmock/psmock.go: coderd/database/pubsub/pubsub.go go generate ./coderd/database/pubsub/psmock +agent/agentcontainers/acmock/acmock.go: agent/agentcontainers/containers.go + go generate ./agent/agentcontainers/acmock/ + $(TAILNETTEST_MOCKS): tailnet/coordinator.go tailnet/service.go go generate ./tailnet/tailnettest/ diff --git a/agent/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..e7364125b8 --- /dev/null +++ b/agent/agentcontainers/containers_dockercli.go @@ -0,0 +1,228 @@ +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()) + + // 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..b9f34261dd --- /dev/null +++ b/agent/agentcontainers/containers_internal_test.go @@ -0,0 +1,348 @@ +package agentcontainers + +import ( + "fmt" + "os/exec" + "runtime" + "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. +func TestDockerCLIContainerLister(t *testing.T) { + t.Parallel() + if runtime.GOOS != "linux" { + t.Skip("creating containers on non-linux runners is slow and flaky") + } + + // Conditionally skip if Docker is not available. + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker not found in PATH") + } + + pool, err := dockertest.NewPool("") + require.NoError(t, err, "Could not connect to docker") + testLabelValue := uuid.New().String() + // 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/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 98c694ab41..c7d8601b3a 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -7854,6 +7854,49 @@ const docTemplate = `{ } } }, + "/workspaceagents/{workspaceagent}/containers": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Agents" + ], + "summary": "Get running containers for workspace agent", + "operationId": "get-running-containers-for-workspace-agent", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Workspace agent ID", + "name": "workspaceagent", + "in": "path", + "required": true + }, + { + "type": "string", + "format": "key=value", + "description": "Labels", + "name": "label", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.WorkspaceAgentListContainersResponse" + } + } + } + } + }, "/workspaceagents/{workspaceagent}/coordinate": { "get": { "security": [ @@ -15608,6 +15651,57 @@ const docTemplate = `{ } } }, + "codersdk.WorkspaceAgentDevcontainer": { + "type": "object", + "properties": { + "created_at": { + "description": "CreatedAt is the time the container was created.", + "type": "string", + "format": "date-time" + }, + "id": { + "description": "ID is the unique identifier of the container.", + "type": "string" + }, + "image": { + "description": "Image is the name of the container image.", + "type": "string" + }, + "labels": { + "description": "Labels is a map of key-value pairs of container labels.", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "name": { + "description": "FriendlyName is the human-readable name of the container.", + "type": "string" + }, + "ports": { + "description": "Ports includes ports exposed by the container.", + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.WorkspaceAgentListeningPort" + } + }, + "running": { + "description": "Running is true if the container is currently running.", + "type": "boolean" + }, + "status": { + "description": "Status is the current status of the container. This is somewhat\nimplementation-dependent, but should generally be a human-readable\nstring.", + "type": "string" + }, + "volumes": { + "description": "Volumes is a map of \"things\" mounted into the container. Again, this\nis somewhat implementation-dependent.", + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + }, "codersdk.WorkspaceAgentHealth": { "type": "object", "properties": { @@ -15648,6 +15742,25 @@ const docTemplate = `{ "WorkspaceAgentLifecycleOff" ] }, + "codersdk.WorkspaceAgentListContainersResponse": { + "type": "object", + "properties": { + "containers": { + "description": "Containers is a list of containers visible to the workspace agent.", + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.WorkspaceAgentDevcontainer" + } + }, + "warnings": { + "description": "Warnings is a list of warnings that may have occurred during the\nprocess of listing containers. This should not include fatal errors.", + "type": "array", + "items": { + "type": "string" + } + } + } + }, "codersdk.WorkspaceAgentListeningPort": { "type": "object", "properties": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index afe36a8389..3a11126423 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -6930,6 +6930,45 @@ } } }, + "/workspaceagents/{workspaceagent}/containers": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Agents"], + "summary": "Get running containers for workspace agent", + "operationId": "get-running-containers-for-workspace-agent", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Workspace agent ID", + "name": "workspaceagent", + "in": "path", + "required": true + }, + { + "type": "string", + "format": "key=value", + "description": "Labels", + "name": "label", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.WorkspaceAgentListContainersResponse" + } + } + } + } + }, "/workspaceagents/{workspaceagent}/coordinate": { "get": { "security": [ @@ -14215,6 +14254,57 @@ } } }, + "codersdk.WorkspaceAgentDevcontainer": { + "type": "object", + "properties": { + "created_at": { + "description": "CreatedAt is the time the container was created.", + "type": "string", + "format": "date-time" + }, + "id": { + "description": "ID is the unique identifier of the container.", + "type": "string" + }, + "image": { + "description": "Image is the name of the container image.", + "type": "string" + }, + "labels": { + "description": "Labels is a map of key-value pairs of container labels.", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "name": { + "description": "FriendlyName is the human-readable name of the container.", + "type": "string" + }, + "ports": { + "description": "Ports includes ports exposed by the container.", + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.WorkspaceAgentListeningPort" + } + }, + "running": { + "description": "Running is true if the container is currently running.", + "type": "boolean" + }, + "status": { + "description": "Status is the current status of the container. This is somewhat\nimplementation-dependent, but should generally be a human-readable\nstring.", + "type": "string" + }, + "volumes": { + "description": "Volumes is a map of \"things\" mounted into the container. Again, this\nis somewhat implementation-dependent.", + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + }, "codersdk.WorkspaceAgentHealth": { "type": "object", "properties": { @@ -14255,6 +14345,25 @@ "WorkspaceAgentLifecycleOff" ] }, + "codersdk.WorkspaceAgentListContainersResponse": { + "type": "object", + "properties": { + "containers": { + "description": "Containers is a list of containers visible to the workspace agent.", + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.WorkspaceAgentDevcontainer" + } + }, + "warnings": { + "description": "Warnings is a list of warnings that may have occurred during the\nprocess of listing containers. This should not include fatal errors.", + "type": "array", + "items": { + "type": "string" + } + } + } + }, "codersdk.WorkspaceAgentListeningPort": { "type": "object", "properties": { diff --git a/coderd/coderd.go b/coderd/coderd.go index be55879738..4603f78acc 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1211,6 +1211,7 @@ func New(options *Options) *API { r.Get("/logs", api.workspaceAgentLogs) r.Get("/listening-ports", api.workspaceAgentListeningPorts) r.Get("/connection", api.workspaceAgentConnection) + r.Get("/containers", api.workspaceAgentListContainers) r.Get("/coordinate", api.workspaceAgentClientCoordinate) // PTY is part of workspaceAppServer. 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/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/schemas.md b/docs/reference/api/schemas.md index 082b3f3a1f..223cf302dc 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -7561,6 +7561,50 @@ If the schedule is empty, the user will be updated to use the default schedule.| | `updated_at` | string | false | | | | `version` | string | false | | | +## codersdk.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 @@ -7599,6 +7643,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 diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 2e7732c525..6a776da17c 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -2926,6 +2926,19 @@ export interface WorkspaceAgent { readonly startup_script_behavior: WorkspaceAgentStartupScriptBehavior; } +// From codersdk/workspaceagents.go +export interface 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; @@ -2956,6 +2969,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; From 140f2a9013d56de9a541d200ea086201a3d36e04 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Mon, 10 Feb 2025 12:12:32 +0000 Subject: [PATCH 2/9] chore(agent/agentcontainers): skip TestDockerCLIContainerLister by default (#16502) Addresses a test flake seen here: https://github.com/coder/coder/actions/runs/13239819615/job/36952521742 Also addresses the case where we would try to run `docker inspect` with no container IDs, which is a silly thing to do. --- agent/agentcontainers/containers_dockercli.go | 5 +++++ .../agentcontainers/containers_internal_test.go | 16 +++++++--------- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/agent/agentcontainers/containers_dockercli.go b/agent/agentcontainers/containers_dockercli.go index e7364125b8..3842735116 100644 --- a/agent/agentcontainers/containers_dockercli.go +++ b/agent/agentcontainers/containers_dockercli.go @@ -59,6 +59,11 @@ func (dcl *DockerCLILister) List(ctx context.Context) (codersdk.WorkspaceAgentLi } 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 diff --git a/agent/agentcontainers/containers_internal_test.go b/agent/agentcontainers/containers_internal_test.go index b9f34261dd..e15deae54c 100644 --- a/agent/agentcontainers/containers_internal_test.go +++ b/agent/agentcontainers/containers_internal_test.go @@ -2,8 +2,7 @@ package agentcontainers import ( "fmt" - "os/exec" - "runtime" + "os" "strconv" "strings" "testing" @@ -27,15 +26,14 @@ import ( // 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 runtime.GOOS != "linux" { - t.Skip("creating containers on non-linux runners is slow and flaky") - } - - // Conditionally skip if Docker is not available. - if _, err := exec.LookPath("docker"); err != nil { - t.Skip("docker not found in PATH") + 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("") From 9bb2e511f8dcc8075a59f6e2385aa91c1ffb89d7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 10 Feb 2025 12:33:53 +0000 Subject: [PATCH 3/9] ci: bump the github-actions group with 2 updates (#16503) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the github-actions group with 2 updates: [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) and [github/codeql-action](https://github.com/github/codeql-action). Updates `docker/setup-buildx-action` from 3.8.0 to 3.9.0
Release notes

Sourced from docker/setup-buildx-action's releases.

v3.9.0

Full Changelog: https://github.com/docker/setup-buildx-action/compare/v3.8.0...v3.9.0

Commits
  • f7ce87c Merge pull request #404 from docker/dependabot/npm_and_yarn/docker/actions-to...
  • aa1e2a0 chore: update generated content
  • 673e008 build(deps): bump @​docker/actions-toolkit from 0.53.0 to 0.54.0
  • ba31df4 Merge pull request #402 from docker/dependabot/npm_and_yarn/docker/actions-to...
  • 5475af1 chore: update generated content
  • acacad9 build(deps): bump @​docker/actions-toolkit from 0.48.0 to 0.53.0
  • 6a25f98 Merge pull request #396 from crazy-max/bake-v6
  • ca1af17 update bake-action to v6
  • See full diff in compare view

Updates `github/codeql-action` from 3.28.8 to 3.28.9
Release notes

Sourced from github/codeql-action's releases.

v3.28.9

CodeQL Action Changelog

See the releases page for the relevant changes to the CodeQL CLI and language packs.

3.28.9 - 07 Feb 2025

  • Update default CodeQL bundle version to 2.20.4. #2753

See the full CHANGELOG.md for more information.

Changelog

Sourced from github/codeql-action's changelog.

CodeQL Action Changelog

See the releases page for the relevant changes to the CodeQL CLI and language packs.

[UNRELEASED]

No user facing changes.

3.28.9 - 07 Feb 2025

  • Update default CodeQL bundle version to 2.20.4. #2753

3.28.8 - 29 Jan 2025

  • Enable support for Kotlin 2.1.10 when running with CodeQL CLI v2.20.3. #2744

3.28.7 - 29 Jan 2025

No user facing changes.

3.28.6 - 27 Jan 2025

  • Re-enable debug artifact upload for CLI versions 2.20.3 or greater. #2726

3.28.5 - 24 Jan 2025

  • Update default CodeQL bundle version to 2.20.3. #2717

3.28.4 - 23 Jan 2025

No user facing changes.

3.28.3 - 22 Jan 2025

  • Update default CodeQL bundle version to 2.20.2. #2707
  • Fix an issue downloading the CodeQL Bundle from a GitHub Enterprise Server instance which occurred when the CodeQL Bundle had been synced to the instance using the CodeQL Action sync tool and the Actions runner did not have Zstandard installed. #2710
  • Uploading debug artifacts for CodeQL analysis is temporarily disabled. #2712

3.28.2 - 21 Jan 2025

No user facing changes.

3.28.1 - 10 Jan 2025

  • CodeQL Action v2 is now deprecated, and is no longer updated or supported. For better performance, improved security, and new features, upgrade to v3. For more information, see this changelog post. #2677
  • Update default CodeQL bundle version to 2.20.1. #2678

3.28.0 - 20 Dec 2024

  • Bump the minimum CodeQL bundle version to 2.15.5. #2655

... (truncated)

Commits
  • 9e8d078 Merge pull request #2757 from github/update-v3.28.9-24e1c2d33
  • 43d9be6 Update changelog for v3.28.9
  • 24e1c2d Merge pull request #2753 from github/update-bundle/codeql-bundle-v2.20.4
  • 57a08c0 Add changelog note
  • 52189d2 Update default bundle to codeql-bundle-v2.20.4
  • 08bc0cf Merge pull request #2751 from github/henrymercer/fix-init-post-without-config
  • cf7c687 Send init-post status report in absence of config
  • ad42dbd Merge pull request #2750 from github/dependabot/npm_and_yarn/npm-768bd9b555
  • a8f5935 Merge pull request #2749 from github/dependabot/github_actions/actions-29d379...
  • 9660df3 Update checked-in dependencies
  • Additional commits viewable in compare view

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore ` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore ` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore ` will remove the ignore condition of the specified dependency and ignore conditions
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/dogfood.yaml | 2 +- .github/workflows/scorecard.yml | 2 +- .github/workflows/security.yaml | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) 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" From 238b6385912578087b84fb78ce575941297723f0 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Mon, 10 Feb 2025 12:45:44 +0000 Subject: [PATCH 4/9] ci: add missing files to gen/mark-fresh (#16504) --- .github/workflows/ci.yaml | 9 --------- Makefile | 1 + 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 64059f413f..7e1d811e08 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -961,15 +961,6 @@ jobs: - name: Setup Go uses: ./.github/actions/setup-go - # Needed to build dylibs. - - name: go install tools - run: | - go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.30 - go install storj.io/drpc/cmd/protoc-gen-go-drpc@v0.0.34 - go install golang.org/x/tools/cmd/goimports@latest - go install github.com/mikefarah/yq/v4@v4.44.3 - go install go.uber.org/mock/mockgen@v0.5.0 - - name: Install rcodesign if: ${{ github.repository_owner == 'coder' && github.ref == 'refs/heads/main' }} run: | diff --git a/Makefile b/Makefile index fe553324cd..b69e164317 100644 --- a/Makefile +++ b/Makefile @@ -599,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 From 0b4d2cf8a850a867c1dd672d23f81d1962343cb3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 10 Feb 2025 12:49:35 +0000 Subject: [PATCH 5/9] chore: bump google.golang.org/api from 0.219.0 to 0.220.0 (#16506) Bumps [google.golang.org/api](https://github.com/googleapis/google-api-go-client) from 0.219.0 to 0.220.0.
Release notes

Sourced from google.golang.org/api's releases.

v0.220.0

0.220.0 (2025-02-05)

Features

Changelog

Sourced from google.golang.org/api's changelog.

0.220.0 (2025-02-05)

Features

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=google.golang.org/api&package-manager=go_modules&previous-version=0.219.0&new-version=0.220.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 6 +++--- go.sum | 16 ++++++++-------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/go.mod b/go.mod index 7aae95f26e..405849f609 100644 --- a/go.mod +++ b/go.mod @@ -199,7 +199,7 @@ require ( golang.org/x/text v0.21.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 960117e12e..aed6746704 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= @@ -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= From e9b35616774de89590a44942e772f2cdd411bdfb Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Mon, 10 Feb 2025 10:00:34 -0300 Subject: [PATCH 6/9] refactor: return template_icon and make metadata required (#16496) --- cli/testdata/coder_list_--output_json.golden | 9 ++++- .../coder_provisioner_jobs_list_--help.golden | 2 +- ...provisioner_jobs_list_--output_json.golden | 4 +- coderd/apidoc/docs.go | 3 ++ coderd/apidoc/swagger.json | 3 ++ coderd/database/queries.sql.go | 4 ++ coderd/database/queries/provisionerjobs.sql | 2 + coderd/provisionerjobs.go | 3 +- coderd/provisionerjobs_test.go | 6 ++- codersdk/provisionerdaemons.go | 37 ++++++++++--------- docs/reference/api/builds.md | 6 +++ docs/reference/api/organizations.md | 3 ++ docs/reference/api/schemas.md | 7 ++++ docs/reference/api/templates.md | 11 ++++++ docs/reference/api/workspaces.md | 6 +++ docs/reference/cli/provisioner_jobs_list.md | 8 ++-- .../coder_provisioner_jobs_list_--help.golden | 2 +- site/src/api/typesGenerated.ts | 3 +- site/src/testHelpers/entities.ts | 9 +++++ 19 files changed, 98 insertions(+), 30 deletions(-) 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 c7d8601b3a..5e4fcb001c 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -13272,6 +13272,9 @@ const docTemplate = `{ "template_display_name": { "type": "string" }, + "template_icon": { + "type": "string" + }, "template_id": { "type": "string", "format": "uuid" diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 3a11126423..29658d0a5e 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -11989,6 +11989,9 @@ "template_display_name": { "type": "string" }, + "template_icon": { + "type": "string" + }, "template_id": { "type": "string", "format": "uuid" diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 7778be9d77..7653094a22 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -6271,6 +6271,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 @@ -6300,6 +6301,7 @@ GROUP BY t.id, t.name, t.display_name, + t.icon, w.id, w.name ORDER BY @@ -6324,6 +6326,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"` } @@ -6369,6 +6372,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/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/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/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 223cf302dc..ebccd362c9 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -4604,6 +4604,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", @@ -4717,6 +4718,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", @@ -4730,6 +4732,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 | | | @@ -6136,6 +6139,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", @@ -7209,6 +7213,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", @@ -8045,6 +8050,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", @@ -8717,6 +8723,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 49a4b3b45c..9a2a35f40f 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/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 6a776da17c..5ad807af38 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1638,7 +1638,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 @@ -1664,6 +1664,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; } 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 = { From 695d552cd0937ae8d2a1a62c55ece0c0cca0955e Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Mon, 10 Feb 2025 13:25:35 +0000 Subject: [PATCH 7/9] feat(cli): add display of open ports in coder show (#16464) Relates to https://github.com/coder/coder/issues/16418 -- devcontainers will be shown in a similar manner. Without ports (status quo): ![Screenshot 2025-02-10 at 12 50 46](https://github.com/user-attachments/assets/c25fd532-2e35-469c-bb28-26e59ded3eb4) With ports: ![Screenshot 2025-02-10 at 12 50 06](https://github.com/user-attachments/assets/a4671349-5866-4e1e-848e-a6e819479793) --- cli/cliui/resources.go | 85 ++++++++++++++++++++++++++++++------------ cli/show.go | 41 +++++++++++++++++++- 2 files changed, 100 insertions(+), 26 deletions(-) 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 +} From d0a534e30d5612858722c0964a2718869584a6e4 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Mon, 10 Feb 2025 09:31:08 -0600 Subject: [PATCH 8/9] chore: prevent authentication of non-unique oidc subjects (#16498) Any IdP returning an empty field here breaks the assumption of a unique subject id. This is defined in the OIDC spec. --- coderd/oauthpki/okidcpki_test.go | 2 ++ coderd/userauth.go | 14 ++++++++++ coderd/userauth_test.go | 41 ++++++++++++++++++++++++++++++ coderd/users_test.go | 1 + enterprise/coderd/scim_test.go | 3 +++ enterprise/coderd/userauth_test.go | 32 ++++++++++++++++++++++- 6 files changed, 92 insertions(+), 1 deletion(-) 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/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/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) From 700a453968d590e1863315018980b6e1fd131e66 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 10 Feb 2025 15:55:25 +0000 Subject: [PATCH 9/9] chore: bump golang.org/x/crypto from 0.32.0 to 0.33.0 in the x group (#16505) Bumps the x group with 1 update: [golang.org/x/crypto](https://github.com/golang/crypto). Updates `golang.org/x/crypto` from 0.32.0 to 0.33.0
Commits
  • 9290511 go.mod: update golang.org/x dependencies
  • fa5273e x509roots/fallback: update bundle
  • a8ea4be ssh: add ServerConfig.PreAuthConnCallback, ServerPreAuthConn (banner) interface
  • 71d3a4c acme: support challenges that require the ACME client to send a non-empty JSO...
  • See full diff in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=golang.org/x/crypto&package-manager=go_modules&previous-version=0.32.0&new-version=0.33.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore ` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore ` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore ` will remove the ignore condition of the specified dependency and ignore conditions
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 4 ++-- go.sum | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 405849f609..b63645a674 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,7 +196,7 @@ 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.220.0 diff --git a/go.sum b/go.sum index aed6746704..3e0e247cce 100644 --- a/go.sum +++ b/go.sum @@ -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=