Skip to content

Commit 31b1ff7

Browse files
authored
feat(agent): add container list handler (#16346)
Fixes #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

File tree

22 files changed

+1654
-2
lines changed

22 files changed

+1654
-2
lines changed

.gitattributes

Lines changed: 1 addition & 0 deletions
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

Lines changed: 9 additions & 0 deletions
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/protoc-gen-go@v1.30
968+
go install storj.io/drpc/cmd/protoc-gen-go-drpc@v0.0.34
969+
go install golang.org/x/tools/cmd/goimports@latest
970+
go install github.com/mikefarah/yq/v4@v4.44.3
971+
go install go.uber.org/mock/mockgen@v0.5.0
972+
964973
- name: Install rcodesign
965974
if: ${{ github.repository_owner == 'coder' && github.ref == 'refs/heads/main' }}
966975
run: |

Makefile

Lines changed: 5 additions & 1 deletion
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

Lines changed: 8 additions & 1 deletion
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

Lines changed: 57 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

agent/agentcontainers/acmock/doc.go

Lines changed: 4 additions & 0 deletions
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

Lines changed: 142 additions & 0 deletions
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)
pFad - Phonifier reborn

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

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


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy