Skip to content

Commit 31b1ff7

Browse files
authored
feat(agent): add container list handler (coder#16346)
Fixes coder#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
1 parent 7076c4e commit 31b1ff7

22 files changed

+1654
-2
lines changed

.gitattributes

+1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
# Generated files
2+
agent/agentcontainers/acmock/acmock.go linguist-generated=true
23
coderd/apidoc/docs.go linguist-generated=true
34
docs/reference/api/*.md linguist-generated=true
45
docs/reference/cli/*.md linguist-generated=true

.github/workflows/ci.yaml

+9
Original file line numberDiff line numberDiff line change
@@ -961,6 +961,15 @@ jobs:
961961
- name: Setup Go
962962
uses: ./.github/actions/setup-go
963963

964+
# Needed to build dylibs.
965+
- name: go install tools
966+
run: |
967+
go install google.golang.org/protobuf/cmd/[email protected]
968+
go install storj.io/drpc/cmd/[email protected]
969+
go install golang.org/x/tools/cmd/goimports@latest
970+
go install github.com/mikefarah/yq/[email protected]
971+
go install go.uber.org/mock/[email protected]
972+
964973
- name: Install rcodesign
965974
if: ${{ github.repository_owner == 'coder' && github.ref == 'refs/heads/main' }}
966975
run: |

Makefile

+5-1
Original file line numberDiff line numberDiff line change
@@ -563,7 +563,8 @@ GEN_FILES := \
563563
site/e2e/provisionerGenerated.ts \
564564
examples/examples.gen.json \
565565
$(TAILNETTEST_MOCKS) \
566-
coderd/database/pubsub/psmock/psmock.go
566+
coderd/database/pubsub/psmock/psmock.go \
567+
agent/agentcontainers/acmock/acmock.go
567568

568569

569570
# 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.
629630
coderd/database/pubsub/psmock/psmock.go: coderd/database/pubsub/pubsub.go
630631
go generate ./coderd/database/pubsub/psmock
631632

633+
agent/agentcontainers/acmock/acmock.go: agent/agentcontainers/containers.go
634+
go generate ./agent/agentcontainers/acmock/
635+
632636
$(TAILNETTEST_MOCKS): tailnet/coordinator.go tailnet/service.go
633637
go generate ./tailnet/tailnettest/
634638

agent/agent.go

+8-1
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import (
3333
"tailscale.com/util/clientmetric"
3434

3535
"cdr.dev/slog"
36+
"github.com/coder/coder/v2/agent/agentcontainers"
3637
"github.com/coder/coder/v2/agent/agentexec"
3738
"github.com/coder/coder/v2/agent/agentscripts"
3839
"github.com/coder/coder/v2/agent/agentssh"
@@ -82,6 +83,7 @@ type Options struct {
8283
ServiceBannerRefreshInterval time.Duration
8384
BlockFileTransfer bool
8485
Execer agentexec.Execer
86+
ContainerLister agentcontainers.Lister
8587
}
8688

8789
type Client interface {
@@ -122,7 +124,7 @@ func New(options Options) Agent {
122124
options.ScriptDataDir = options.TempDir
123125
}
124126
if options.ExchangeToken == nil {
125-
options.ExchangeToken = func(ctx context.Context) (string, error) {
127+
options.ExchangeToken = func(_ context.Context) (string, error) {
126128
return "", nil
127129
}
128130
}
@@ -144,6 +146,9 @@ func New(options Options) Agent {
144146
if options.Execer == nil {
145147
options.Execer = agentexec.DefaultExecer
146148
}
149+
if options.ContainerLister == nil {
150+
options.ContainerLister = agentcontainers.NewDocker(options.Execer)
151+
}
147152

148153
hardCtx, hardCancel := context.WithCancel(context.Background())
149154
gracefulCtx, gracefulCancel := context.WithCancel(hardCtx)
@@ -178,6 +183,7 @@ func New(options Options) Agent {
178183
prometheusRegistry: prometheusRegistry,
179184
metrics: newAgentMetrics(prometheusRegistry),
180185
execer: options.Execer,
186+
lister: options.ContainerLister,
181187
}
182188
// Initially, we have a closed channel, reflecting the fact that we are not initially connected.
183189
// Each time we connect we replace the channel (while holding the closeMutex) with a new one
@@ -247,6 +253,7 @@ type agent struct {
247253
// labeled in Coder with the agent + workspace.
248254
metrics *agentMetrics
249255
execer agentexec.Execer
256+
lister agentcontainers.Lister
250257
}
251258

252259
func (a *agent) TailnetConn() *tailnet.Conn {

agent/agentcontainers/acmock/acmock.go

+57
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

agent/agentcontainers/acmock/doc.go

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
// Package acmock contains a mock implementation of agentcontainers.Lister for use in tests.
2+
package acmock
3+
4+
//go:generate mockgen -destination ./acmock.go -package acmock .. Lister

agent/agentcontainers/containers.go

+142
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
package agentcontainers
2+
3+
import (
4+
"context"
5+
"errors"
6+
"net/http"
7+
"slices"
8+
"time"
9+
10+
"golang.org/x/xerrors"
11+
12+
"github.com/coder/coder/v2/coderd/httpapi"
13+
"github.com/coder/coder/v2/codersdk"
14+
"github.com/coder/quartz"
15+
)
16+
17+
const (
18+
defaultGetContainersCacheDuration = 10 * time.Second
19+
dockerCreatedAtTimeFormat = "2006-01-02 15:04:05 -0700 MST"
20+
getContainersTimeout = 5 * time.Second
21+
)
22+
23+
type devcontainersHandler struct {
24+
cacheDuration time.Duration
25+
cl Lister
26+
clock quartz.Clock
27+
28+
// lockCh protects the below fields. We use a channel instead of a mutex so we
29+
// can handle cancellation properly.
30+
lockCh chan struct{}
31+
containers *codersdk.WorkspaceAgentListContainersResponse
32+
mtime time.Time
33+
}
34+
35+
// Option is a functional option for devcontainersHandler.
36+
type Option func(*devcontainersHandler)
37+
38+
// WithLister sets the agentcontainers.Lister implementation to use.
39+
// The default implementation uses the Docker CLI to list containers.
40+
func WithLister(cl Lister) Option {
41+
return func(ch *devcontainersHandler) {
42+
ch.cl = cl
43+
}
44+
}
45+
46+
// New returns a new devcontainersHandler with the given options applied.
47+
func New(options ...Option) http.Handler {
48+
ch := &devcontainersHandler{
49+
lockCh: make(chan struct{}, 1),
50+
}
51+
for _, opt := range options {
52+
opt(ch)
53+
}
54+
return ch
55+
}
56+
57+
func (ch *devcontainersHandler) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
58+
select {
59+
case <-r.Context().Done():
60+
// Client went away.
61+
return
62+
default:
63+
ct, err := ch.getContainers(r.Context())
64+
if err != nil {
65+
if errors.Is(err, context.Canceled) {
66+
httpapi.Write(r.Context(), rw, http.StatusRequestTimeout, codersdk.Response{
67+
Message: "Could not get containers.",
68+
Detail: "Took too long to list containers.",
69+
})
70+
return
71+
}
72+
httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{
73+
Message: "Could not get containers.",
74+
Detail: err.Error(),
75+
})
76+
return
77+
}
78+
79+
httpapi.Write(r.Context(), rw, http.StatusOK, ct)
80+
}
81+
}
82+
83+
func (ch *devcontainersHandler) getContainers(ctx context.Context) (codersdk.WorkspaceAgentListContainersResponse, error) {
84+
select {
85+
case <-ctx.Done():
86+
return codersdk.WorkspaceAgentListContainersResponse{}, ctx.Err()
87+
default:
88+
ch.lockCh <- struct{}{}
89+
}
90+
defer func() {
91+
<-ch.lockCh
92+
}()
93+
94+
// make zero-value usable
95+
if ch.cacheDuration == 0 {
96+
ch.cacheDuration = defaultGetContainersCacheDuration
97+
}
98+
if ch.cl == nil {
99+
ch.cl = &DockerCLILister{}
100+
}
101+
if ch.containers == nil {
102+
ch.containers = &codersdk.WorkspaceAgentListContainersResponse{}
103+
}
104+
if ch.clock == nil {
105+
ch.clock = quartz.NewReal()
106+
}
107+
108+
now := ch.clock.Now()
109+
if now.Sub(ch.mtime) < ch.cacheDuration {
110+
// Return a copy of the cached data to avoid accidental modification by the caller.
111+
cpy := codersdk.WorkspaceAgentListContainersResponse{
112+
Containers: slices.Clone(ch.containers.Containers),
113+
Warnings: slices.Clone(ch.containers.Warnings),
114+
}
115+
return cpy, nil
116+
}
117+
118+
timeoutCtx, timeoutCancel := context.WithTimeout(ctx, getContainersTimeout)
119+
defer timeoutCancel()
120+
updated, err := ch.cl.List(timeoutCtx)
121+
if err != nil {
122+
return codersdk.WorkspaceAgentListContainersResponse{}, xerrors.Errorf("get containers: %w", err)
123+
}
124+
ch.containers = &updated
125+
ch.mtime = now
126+
127+
// Return a copy of the cached data to avoid accidental modification by the
128+
// caller.
129+
cpy := codersdk.WorkspaceAgentListContainersResponse{
130+
Containers: slices.Clone(ch.containers.Containers),
131+
Warnings: slices.Clone(ch.containers.Warnings),
132+
}
133+
return cpy, nil
134+
}
135+
136+
// Lister is an interface for listing containers visible to the
137+
// workspace agent.
138+
type Lister interface {
139+
// List returns a list of containers visible to the workspace agent.
140+
// This should include running and stopped containers.
141+
List(ctx context.Context) (codersdk.WorkspaceAgentListContainersResponse, error)
142+
}

0 commit comments

Comments
 (0)