Skip to content

Commit 6960127

Browse files
committed
[ci skip] feat(agent): add container list handler
1 parent cf96455 commit 6960127

File tree

6 files changed

+424
-0
lines changed

6 files changed

+424
-0
lines changed

agent/api.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,11 @@ func (a *agent) apiHandler() http.Handler {
3535
ignorePorts: cpy,
3636
cacheDuration: cacheDuration,
3737
}
38+
ch := &containersHandler{
39+
cacheDuration: defaultGetContainersCacheDuration,
40+
}
3841
promHandler := PrometheusMetricsHandler(a.prometheusRegistry, a.logger)
42+
r.Get("/api/v0/containers", ch.handler)
3943
r.Get("/api/v0/listening-ports", lp.handler)
4044
r.Get("/api/v0/netcheck", a.HandleNetcheck)
4145
r.Get("/debug/logs", a.HandleHTTPDebugLogs)

agent/containers.go

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
package agent
2+
3+
//go:generate mockgen -destination ./containers_mock.go -package agent . ContainerLister
4+
5+
import (
6+
"bytes"
7+
"context"
8+
"encoding/json"
9+
"net/http"
10+
"os/exec"
11+
"strings"
12+
"sync"
13+
"time"
14+
15+
"golang.org/x/xerrors"
16+
17+
"github.com/coder/coder/v2/coderd/httpapi"
18+
"github.com/coder/coder/v2/codersdk"
19+
"github.com/coder/quartz"
20+
)
21+
22+
const (
23+
defaultGetContainersCacheDuration = 10 * time.Second
24+
getContainersTimeout = 5 * time.Second
25+
)
26+
27+
type containersHandler struct {
28+
cacheDuration time.Duration
29+
cl ContainerLister
30+
clock quartz.Clock
31+
32+
mu sync.Mutex // protects the below
33+
containers []codersdk.WorkspaceAgentContainer
34+
mtime time.Time
35+
}
36+
37+
func (ch *containersHandler) handler(rw http.ResponseWriter, r *http.Request) {
38+
ct, err := ch.getContainers(r.Context())
39+
if err != nil {
40+
httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{
41+
Message: "Could not get containers.",
42+
Detail: err.Error(),
43+
})
44+
return
45+
}
46+
47+
httpapi.Write(r.Context(), rw, http.StatusOK, ct)
48+
}
49+
50+
func (ch *containersHandler) getContainers(ctx context.Context) ([]codersdk.WorkspaceAgentContainer, error) {
51+
ch.mu.Lock()
52+
defer ch.mu.Unlock()
53+
54+
// make zero-value usable
55+
if ch.cacheDuration == 0 {
56+
ch.cacheDuration = defaultGetContainersCacheDuration
57+
}
58+
if ch.cl == nil {
59+
ch.cl = &dockerCLIContainerLister{}
60+
}
61+
if ch.containers == nil {
62+
ch.containers = make([]codersdk.WorkspaceAgentContainer, 0)
63+
}
64+
if ch.clock == nil {
65+
ch.clock = quartz.NewReal()
66+
}
67+
68+
now := ch.clock.Now()
69+
if now.Sub(ch.mtime) < ch.cacheDuration {
70+
cpy := make([]codersdk.WorkspaceAgentContainer, len(ch.containers))
71+
copy(cpy, ch.containers)
72+
return cpy, nil
73+
}
74+
75+
cancelCtx, cancelFunc := context.WithTimeout(ctx, getContainersTimeout)
76+
defer cancelFunc()
77+
updated, err := ch.cl.List(cancelCtx)
78+
if err != nil {
79+
return nil, xerrors.Errorf("get containers: %w", err)
80+
}
81+
ch.containers = updated
82+
ch.mtime = now
83+
84+
// return a copy
85+
cpy := make([]codersdk.WorkspaceAgentContainer, len(ch.containers))
86+
copy(cpy, ch.containers)
87+
return cpy, nil
88+
}
89+
90+
type ContainerLister interface {
91+
List(ctx context.Context) ([]codersdk.WorkspaceAgentContainer, error)
92+
}
93+
94+
// dockerCLIContainerLister is a ContainerLister that lists containers using the docker CLI
95+
type dockerCLIContainerLister struct{}
96+
97+
type dockerCLIList struct {
98+
CreatedAt string `json:"CreatedAt"`
99+
ID string `json:"ID"`
100+
Image string `json:"Image"`
101+
Labels string `json:"Labels"`
102+
Names string `json:"Names"`
103+
}
104+
105+
func (*dockerCLIContainerLister) List(ctx context.Context) ([]codersdk.WorkspaceAgentContainer, error) {
106+
var buf bytes.Buffer
107+
cmd := exec.CommandContext(ctx, "docker", "ps", "--all", "--no-trunc", "--format=json")
108+
cmd.Stdout = &buf
109+
if err := cmd.Run(); err != nil {
110+
return nil, xerrors.Errorf("list containers: %w", err)
111+
}
112+
113+
// the output is returned with a single item per line, so we have to decode it
114+
// line-by-line
115+
out := make([]codersdk.WorkspaceAgentContainer, 0)
116+
tmp := dockerCLIList{}
117+
for _, line := range strings.Split(buf.String(), "\n") {
118+
if strings.TrimSpace(line) == "" {
119+
continue
120+
}
121+
if err := json.NewDecoder(strings.NewReader(line)).Decode(&tmp); err != nil {
122+
return nil, xerrors.Errorf("list containers: %w", err)
123+
}
124+
out = append(out, convertDockerCLIList(tmp))
125+
}
126+
return out, nil
127+
}
128+
129+
func convertDockerCLIList(in dockerCLIList) codersdk.WorkspaceAgentContainer {
130+
out := codersdk.WorkspaceAgentContainer{
131+
FriendlyName: in.Names,
132+
ID: in.ID,
133+
Image: in.Image,
134+
Labels: map[string]string{},
135+
}
136+
137+
createdAt, err := time.Parse("2006-01-02 15:04:05 -0700 MST", in.CreatedAt)
138+
if err != nil {
139+
createdAt = time.Time{} // TODO: how to handle invalid createdAt?
140+
}
141+
out.CreatedAt = createdAt
142+
143+
labels := strings.Split(in.Labels, ",")
144+
for _, label := range labels {
145+
kvs := strings.Split(label, "=")
146+
if len(kvs) != 2 {
147+
continue // TODO: how should we handle this weirdness?
148+
}
149+
out.Labels[kvs[0]] = kvs[1]
150+
}
151+
return out
152+
}

agent/containers_internal_test.go

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
package agent
2+
3+
import (
4+
"strings"
5+
"testing"
6+
"time"
7+
8+
"github.com/google/uuid"
9+
"github.com/ory/dockertest/v3"
10+
"github.com/ory/dockertest/v3/docker"
11+
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/require"
13+
"go.uber.org/mock/gomock"
14+
15+
"github.com/coder/coder/v2/codersdk"
16+
"github.com/coder/coder/v2/testutil"
17+
"github.com/coder/quartz"
18+
)
19+
20+
func TestDockerCLIContainerLister(t *testing.T) {
21+
t.Parallel()
22+
23+
pool, err := dockertest.NewPool("")
24+
require.NoError(t, err, "Could not connect to docker")
25+
testLabelValue := uuid.New().String()
26+
res, err := pool.RunWithOptions(&dockertest.RunOptions{
27+
Repository: "busybox",
28+
Tag: "latest",
29+
Cmd: []string{"sleep", "infnity"},
30+
Labels: map[string]string{"com.coder.test": testLabelValue},
31+
}, func(config *docker.HostConfig) {
32+
config.AutoRemove = true
33+
config.RestartPolicy = docker.RestartPolicy{Name: "no"}
34+
})
35+
require.NoError(t, err, "Could not start test docker container")
36+
t.Cleanup(func() {
37+
assert.NoError(t, pool.Purge(res), "Could not purge resource")
38+
})
39+
40+
expectedCt := codersdk.WorkspaceAgentContainer{
41+
CreatedAt: res.Container.Created.Local().Truncate(time.Second),
42+
ID: res.Container.ID,
43+
// For some reason, ory/dockertest pre-pends a forward slash to the container name.
44+
FriendlyName: strings.TrimPrefix(res.Container.Name, "/"),
45+
Image: res.Container.Image,
46+
Labels: res.Container.Config.Labels,
47+
}
48+
dcl := dockerCLIContainerLister{}
49+
ctx := testutil.Context(t, testutil.WaitShort)
50+
actual, err := dcl.List(ctx)
51+
require.NoError(t, err, "Could not list containers")
52+
var found bool
53+
for _, ct := range actual {
54+
if ct.Labels != nil && ct.Labels["com.coder.test"] == testLabelValue {
55+
found = true
56+
assert.Equal(t, expectedCt.CreatedAt, ct.CreatedAt)
57+
assert.Equal(t, expectedCt.FriendlyName, ct.FriendlyName)
58+
assert.Equal(t, expectedCt.ID, ct.ID)
59+
// Docker returns the sha256 digest of the image.
60+
// assert.Equal(t, expectedCt.Image, ct.Image)
61+
break
62+
}
63+
}
64+
assert.True(t, found, "Expected to find container with label 'com.coder.test=%s'", testLabelValue)
65+
}
66+
67+
func TestContainersHandler(t *testing.T) {
68+
t.Parallel()
69+
70+
t.Run("list", func(t *testing.T) {
71+
t.Parallel()
72+
73+
// Given: a containersHandler backed by a mock
74+
var (
75+
ctx = testutil.Context(t, testutil.WaitShort)
76+
clk = quartz.NewMock(t)
77+
ctrl = gomock.NewController(t)
78+
mockLister = NewMockContainerLister(ctrl)
79+
now = time.Now().UTC()
80+
ch = containersHandler{
81+
cacheDuration: time.Second,
82+
cl: mockLister,
83+
clock: clk,
84+
}
85+
expected = []codersdk.WorkspaceAgentContainer{fakeContainer(t)}
86+
)
87+
88+
clk.Set(now).MustWait(ctx)
89+
90+
// When: getContainers is called for the first time
91+
ch.mtime = time.Time{}
92+
mockLister.EXPECT().List(gomock.Any()).Return(expected, nil)
93+
actual, err := ch.getContainers(ctx)
94+
95+
// Then: the underlying lister is called and the result is returned
96+
require.NoError(t, err, "expected no error on first call")
97+
require.Equal(t, expected, actual, "expected containers to be equal on first call")
98+
// Then: the result is cached
99+
require.Equal(t, now, ch.mtime, "expected container mtime to be set on first call")
100+
require.NotEmpty(t, ch.containers, "expected cached data to not be empty on first call")
101+
102+
// When: getContainers is called again
103+
actual, err = ch.getContainers(ctx)
104+
105+
// Then: the underlying lister is not called and the cached result is
106+
// returned
107+
require.NoError(t, err, "expected no error on second call")
108+
require.Equal(t, expected, actual, "expected containers to be equal on second call")
109+
// Then: the result is cached
110+
require.Equal(t, now, ch.mtime, "expected container mtime to not have changed on second call")
111+
require.Equal(t, expected, ch.containers, "expected cached data to not have changed on second call")
112+
113+
// When: getContainers is called after the cache duration has expired
114+
expected = append(expected, fakeContainer(t))
115+
later := now.Add(defaultGetContainersCacheDuration).Add(time.Second)
116+
clk.Set(later).MustWait(ctx)
117+
mockLister.EXPECT().List(gomock.Any()).Return(expected, nil)
118+
actual, err = ch.getContainers(ctx)
119+
120+
// Then: the underlying lister is called and the result is returned
121+
require.NoError(t, err, "expected no error on third call")
122+
require.Equal(t, expected, actual, "expected containers to be equal on third call")
123+
// Then: the result is cached
124+
require.Equal(t, later, ch.mtime, "expected container mtime to later on third call")
125+
require.Equal(t, expected, ch.containers, "expected cached data to not have changed on third call")
126+
127+
// When: getContainers is called again but the underlying lister returns an error
128+
actual, err = ch.getContainers(ctx)
129+
require.NoError(t, err)
130+
131+
// Then: the cached data is not updated
132+
require.Equal(t, expected, actual, "expected containers to be equal on fourth call")
133+
require.Equal(t, later, ch.mtime, "expected container mtime to not have changed on fourth call")
134+
require.Equal(t, expected, ch.containers, "expected cached data to not have changed on fourth call")
135+
136+
// When: time advances past mtime
137+
laterlater := later.Add(defaultGetContainersCacheDuration).Add(time.Second)
138+
clk.Set(laterlater).MustWait(ctx)
139+
mockLister.EXPECT().List(gomock.Any()).Return(nil, assert.AnError)
140+
actual, err = ch.getContainers(ctx)
141+
// Then: the underlying error is returned
142+
require.ErrorContains(t, err, assert.AnError.Error(), "expected error on fifth call")
143+
require.Nil(t, actual, "expected no data to be returned on fifth call")
144+
// Then: the underlying cached data remains the same
145+
require.Equal(t, later, ch.mtime, "expected container mtime to not have changed on fifth call")
146+
require.Equal(t, expected, ch.containers, "expected cached data to not have changed on fifth call")
147+
})
148+
}
149+
150+
func fakeContainer(t testing.TB, mut ...func(*codersdk.WorkspaceAgentContainer)) codersdk.WorkspaceAgentContainer {
151+
t.Helper()
152+
ct := codersdk.WorkspaceAgentContainer{
153+
ID: uuid.New().String(),
154+
FriendlyName: testutil.GetRandomName(t),
155+
CreatedAt: time.Now().UTC(),
156+
Image: testutil.GetRandomName(t) + ":" + strings.Split(uuid.New().String(), "-")[0],
157+
Labels: map[string]string{},
158+
}
159+
for _, m := range mut {
160+
m(&ct)
161+
}
162+
return ct
163+
}

agent/containers_mock.go

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

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