Skip to content

Commit adb3b5f

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

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)