Skip to content

feat(agent): add container list handler #16346

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
Feb 10, 2025
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -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
Expand Down
9 changes: 9 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
6 changes: 5 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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/

Expand Down
9 changes: 8 additions & 1 deletion agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -82,6 +83,7 @@ type Options struct {
ServiceBannerRefreshInterval time.Duration
BlockFileTransfer bool
Execer agentexec.Execer
ContainerLister agentcontainers.Lister
}

type Client interface {
Expand Down Expand Up @@ -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
}
}
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
57 changes: 57 additions & 0 deletions agent/agentcontainers/acmock/acmock.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions agent/agentcontainers/acmock/doc.go
Original file line number Diff line number Diff line change
@@ -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
142 changes: 142 additions & 0 deletions agent/agentcontainers/containers.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading