Skip to content

Commit 7d5f6c7

Browse files
committed
address code review comments
1 parent d4aa89b commit 7d5f6c7

File tree

15 files changed

+325
-117
lines changed

15 files changed

+325
-117
lines changed

agent/api.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ func (a *agent) apiHandler() http.Handler {
3535
ignorePorts: cpy,
3636
cacheDuration: cacheDuration,
3737
}
38-
ch := &containersHandler{
38+
ch := &devcontainersHandler{
3939
cacheDuration: defaultGetContainersCacheDuration,
4040
}
4141
promHandler := PrometheusMetricsHandler(a.prometheusRegistry, a.logger)

agent/containers.go

Lines changed: 51 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ package agent
44

55
import (
66
"context"
7+
"errors"
78
"net/http"
9+
"slices"
810
"sync"
911
"time"
1012

@@ -21,19 +23,29 @@ const (
2123
getContainersTimeout = 5 * time.Second
2224
)
2325

24-
type containersHandler struct {
26+
type devcontainersHandler struct {
2527
cacheDuration time.Duration
2628
cl ContainerLister
2729
clock quartz.Clock
2830

29-
mu sync.Mutex // protects the below
30-
containers []codersdk.WorkspaceAgentContainer
31+
initLockOnce sync.Once // ensures we don't get a race when initializing lockCh
32+
// lockCh protects the below fields. We use a channel instead of a mutex so we
33+
// can handle cancellation properly.
34+
lockCh chan struct{}
35+
containers *codersdk.WorkspaceAgentListContainersResponse
3136
mtime time.Time
3237
}
3338

34-
func (ch *containersHandler) handler(rw http.ResponseWriter, r *http.Request) {
39+
func (ch *devcontainersHandler) handler(rw http.ResponseWriter, r *http.Request) {
3540
ct, err := ch.getContainers(r.Context())
3641
if err != nil {
42+
if errors.Is(err, context.Canceled) {
43+
httpapi.Write(r.Context(), rw, http.StatusRequestTimeout, codersdk.Response{
44+
Message: "Could not get containers.",
45+
Detail: "Took too long to list containers.",
46+
})
47+
return
48+
}
3749
httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{
3850
Message: "Could not get containers.",
3951
Detail: err.Error(),
@@ -44,9 +56,21 @@ func (ch *containersHandler) handler(rw http.ResponseWriter, r *http.Request) {
4456
httpapi.Write(r.Context(), rw, http.StatusOK, ct)
4557
}
4658

47-
func (ch *containersHandler) getContainers(ctx context.Context) ([]codersdk.WorkspaceAgentContainer, error) {
48-
ch.mu.Lock()
49-
defer ch.mu.Unlock()
59+
func (ch *devcontainersHandler) getContainers(ctx context.Context) (codersdk.WorkspaceAgentListContainersResponse, error) {
60+
ch.initLockOnce.Do(func() {
61+
if ch.lockCh == nil {
62+
ch.lockCh = make(chan struct{}, 1)
63+
}
64+
})
65+
select {
66+
case <-ctx.Done():
67+
return codersdk.WorkspaceAgentListContainersResponse{}, ctx.Err()
68+
default:
69+
ch.lockCh <- struct{}{}
70+
}
71+
defer func() {
72+
<-ch.lockCh
73+
}()
5074

5175
// make zero-value usable
5276
if ch.cacheDuration == 0 {
@@ -58,34 +82,42 @@ func (ch *containersHandler) getContainers(ctx context.Context) ([]codersdk.Work
5882
ch.cl = &dockerCLIContainerLister{}
5983
}
6084
if ch.containers == nil {
61-
ch.containers = make([]codersdk.WorkspaceAgentContainer, 0)
85+
ch.containers = &codersdk.WorkspaceAgentListContainersResponse{}
6286
}
6387
if ch.clock == nil {
6488
ch.clock = quartz.NewReal()
6589
}
6690

6791
now := ch.clock.Now()
6892
if now.Sub(ch.mtime) < ch.cacheDuration {
69-
cpy := make([]codersdk.WorkspaceAgentContainer, len(ch.containers))
70-
copy(cpy, ch.containers)
93+
// Return a copy of the cached data to avoid accidental modification by the caller.
94+
cpy := codersdk.WorkspaceAgentListContainersResponse{
95+
Containers: slices.Clone(ch.containers.Containers),
96+
}
7197
return cpy, nil
7298
}
7399

74-
cancelCtx, cancelFunc := context.WithTimeout(ctx, getContainersTimeout)
75-
defer cancelFunc()
76-
updated, err := ch.cl.List(cancelCtx)
100+
timeoutCtx, timeoutCancel := context.WithTimeout(ctx, getContainersTimeout)
101+
defer timeoutCancel()
102+
updated, err := ch.cl.List(timeoutCtx)
77103
if err != nil {
78-
return nil, xerrors.Errorf("get containers: %w", err)
104+
return codersdk.WorkspaceAgentListContainersResponse{}, xerrors.Errorf("get containers: %w", err)
79105
}
80-
ch.containers = updated
106+
ch.containers = &updated
81107
ch.mtime = now
82108

83-
// return a copy
84-
cpy := make([]codersdk.WorkspaceAgentContainer, len(ch.containers))
85-
copy(cpy, ch.containers)
109+
// Return a copy of the cached data to avoid accidental modification by the
110+
// caller.
111+
cpy := codersdk.WorkspaceAgentListContainersResponse{
112+
Containers: slices.Clone(ch.containers.Containers),
113+
}
86114
return cpy, nil
87115
}
88116

117+
// ContainerLister is an interface for listing containers visible to the
118+
// workspace agent.
89119
type ContainerLister interface {
90-
List(ctx context.Context) ([]codersdk.WorkspaceAgentContainer, error)
120+
// List returns a list of containers visible to the workspace agent.
121+
// This should include running and stopped containers.
122+
List(ctx context.Context) (codersdk.WorkspaceAgentListContainersResponse, error)
91123
}

agent/containers_dockercli.go

Lines changed: 68 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package agent
22

33
import (
4+
"bufio"
45
"bytes"
56
"context"
67
"encoding/json"
@@ -22,46 +23,68 @@ type dockerCLIContainerLister struct{}
2223

2324
var _ ContainerLister = &dockerCLIContainerLister{}
2425

25-
func (*dockerCLIContainerLister) List(ctx context.Context) ([]codersdk.WorkspaceAgentContainer, error) {
26-
var buf bytes.Buffer
26+
func (*dockerCLIContainerLister) List(ctx context.Context) (codersdk.WorkspaceAgentListContainersResponse, error) {
27+
var stdoutBuf, stderrBuf bytes.Buffer
2728
// List all container IDs, one per line, with no truncation
2829
cmd := exec.CommandContext(ctx, "docker", "ps", "--all", "--quiet", "--no-trunc")
29-
cmd.Stdout = &buf
30+
cmd.Stdout = &stdoutBuf
31+
cmd.Stderr = &stderrBuf
3032
if err := cmd.Run(); err != nil {
31-
return nil, xerrors.Errorf("run docker ps: %w", err)
33+
// TODO(Cian): detect specific errors:
34+
// - docker not installed
35+
// - docker not running
36+
// - no permissions to talk to docker
37+
return codersdk.WorkspaceAgentListContainersResponse{}, xerrors.Errorf("run docker ps: %w: %q", err, strings.TrimSpace(stderrBuf.String()))
3238
}
3339

3440
ids := make([]string, 0)
35-
for _, line := range strings.Split(buf.String(), "\n") {
36-
tmp := strings.TrimSpace(line)
41+
scanner := bufio.NewScanner(&stdoutBuf)
42+
for scanner.Scan() {
43+
tmp := strings.TrimSpace(scanner.Text())
3744
if tmp == "" {
3845
continue
3946
}
4047
ids = append(ids, tmp)
4148
}
49+
if err := scanner.Err(); err != nil {
50+
return codersdk.WorkspaceAgentListContainersResponse{}, xerrors.Errorf("scan docker ps output: %w", err)
51+
}
4252

4353
// now we can get the detailed information for each container
4454
// Run `docker inspect` on each container ID
45-
buf.Reset()
46-
execArgs := []string{"inspect"}
47-
execArgs = append(execArgs, ids...)
48-
cmd = exec.CommandContext(ctx, "docker", execArgs...)
49-
cmd.Stdout = &buf
55+
stdoutBuf.Reset()
56+
stderrBuf.Reset()
57+
// nolint: gosec // We are not executing user input, these IDs come from
58+
// `docker ps`.
59+
cmd = exec.CommandContext(ctx, "docker", append([]string{"inspect"}, ids...)...)
60+
cmd.Stdout = &stdoutBuf
61+
cmd.Stderr = &stderrBuf
5062
if err := cmd.Run(); err != nil {
51-
return nil, xerrors.Errorf("run docker inspect: %w", err)
63+
return codersdk.WorkspaceAgentListContainersResponse{}, xerrors.Errorf("run docker inspect: %w: %s", err, strings.TrimSpace(stderrBuf.String()))
5264
}
5365

54-
ins := make([]dockerInspect, 0)
55-
if err := json.NewDecoder(&buf).Decode(&ins); err != nil {
56-
return nil, xerrors.Errorf("decode docker inspect output: %w", err)
66+
// NOTE: There is an unavoidable potential race condition where a
67+
// container is removed between `docker ps` and `docker inspect`.
68+
// In this case, stderr will contain an error message but stdout
69+
// will still contain valid JSON. We will just end up missing
70+
// information about the removed container. We could potentially
71+
// log this error, but I'm not sure it's worth it.
72+
ins := make([]dockerInspect, 0, len(ids))
73+
if err := json.NewDecoder(&stdoutBuf).Decode(&ins); err != nil {
74+
// However, if we just get invalid JSON, we should absolutely return an error.
75+
return codersdk.WorkspaceAgentListContainersResponse{}, xerrors.Errorf("decode docker inspect output: %w", err)
5776
}
5877

59-
out := make([]codersdk.WorkspaceAgentContainer, 0)
60-
for _, in := range ins {
61-
out = append(out, convertDockerInspect(in))
78+
res := codersdk.WorkspaceAgentListContainersResponse{
79+
Containers: make([]codersdk.WorkspaceAgentDevcontainer, len(ins)),
80+
}
81+
for idx, in := range ins {
82+
out, warns := convertDockerInspect(in)
83+
res.Warnings = append(res.Warnings, warns...)
84+
res.Containers[idx] = out
6285
}
6386

64-
return out, nil
87+
return res, nil
6588
}
6689

6790
// To avoid a direct dependency on the Docker API, we use the docker CLI
@@ -104,44 +127,47 @@ func (dis dockerInspectState) String() string {
104127
return sb.String()
105128
}
106129

107-
func convertDockerInspect(in dockerInspect) codersdk.WorkspaceAgentContainer {
108-
out := codersdk.WorkspaceAgentContainer{
130+
func convertDockerInspect(in dockerInspect) (codersdk.WorkspaceAgentDevcontainer, []string) {
131+
var warns []string
132+
out := codersdk.WorkspaceAgentDevcontainer{
109133
CreatedAt: in.Created,
110134
// Remove the leading slash from the container name
111135
FriendlyName: strings.TrimPrefix(in.Name, "/"),
112136
ID: in.ID,
113137
Image: in.Config.Image,
114138
Labels: in.Config.Labels,
115-
Ports: make([]codersdk.WorkspaceAgentListeningPort, 0),
139+
Ports: make([]codersdk.WorkspaceAgentListeningPort, 0, len(in.Config.ExposedPorts)),
116140
Running: in.State.Running,
117141
Status: in.State.String(),
118-
Volumes: make(map[string]string),
142+
Volumes: make(map[string]string, len(in.Config.Volumes)),
119143
}
120144

121145
// sort the keys for deterministic output
122146
portKeys := maps.Keys(in.Config.ExposedPorts)
123147
sort.Strings(portKeys)
124148
for _, p := range portKeys {
125-
port, network, err := convertDockerPort(p)
126-
if err != nil {
127-
// ignore invalid ports
128-
continue
149+
if port, network, err := convertDockerPort(p); err != nil {
150+
warns = append(warns, err.Error())
151+
} else {
152+
out.Ports = append(out.Ports, codersdk.WorkspaceAgentListeningPort{
153+
Network: network,
154+
Port: port,
155+
})
129156
}
130-
out.Ports = append(out.Ports, codersdk.WorkspaceAgentListeningPort{
131-
Network: network,
132-
Port: port,
133-
})
134157
}
135158

136159
// sort the keys for deterministic output
137160
volKeys := maps.Keys(in.Config.Volumes)
138161
sort.Strings(volKeys)
139162
for _, k := range volKeys {
140-
v0, v1 := convertDockerVolume(k)
141-
out.Volumes[v0] = v1
163+
if v0, v1, err := convertDockerVolume(k); err != nil {
164+
warns = append(warns, err.Error())
165+
} else {
166+
out.Volumes[v0] = v1
167+
}
142168
}
143169

144-
return out
170+
return out, warns
145171
}
146172

147173
// convertDockerPort converts a Docker port string to a port number and network
@@ -151,21 +177,21 @@ func convertDockerInspect(in dockerInspect) codersdk.WorkspaceAgentContainer {
151177
func convertDockerPort(in string) (uint16, string, error) {
152178
parts := strings.Split(in, "/")
153179
switch len(parts) {
154-
case 0:
155-
return 0, "", xerrors.Errorf("invalid port format: %s", in)
156180
case 1:
157181
// assume it's a TCP port
158182
p, err := strconv.Atoi(parts[0])
159183
if err != nil {
160184
return 0, "", xerrors.Errorf("invalid port format: %s", in)
161185
}
162186
return uint16(p), "tcp", nil
163-
default:
187+
case 2:
164188
p, err := strconv.Atoi(parts[0])
165189
if err != nil {
166190
return 0, "", xerrors.Errorf("invalid port format: %s", in)
167191
}
168192
return uint16(p), parts[1], nil
193+
default:
194+
return 0, "", xerrors.Errorf("invalid port format: %s", in)
169195
}
170196
}
171197

@@ -175,14 +201,14 @@ func convertDockerPort(in string) (uint16, string, error) {
175201
// example: "/host/path=/container/path" -> "/host/path", "/container/path"
176202
//
177203
// "/container/path" -> "/container/path", "/container/path"
178-
func convertDockerVolume(in string) (hostPath, containerPath string) {
204+
func convertDockerVolume(in string) (hostPath, containerPath string, err error) {
179205
parts := strings.Split(in, "=")
180206
switch len(parts) {
181-
case 0:
182-
return in, in
183207
case 1:
184-
return parts[0], parts[0]
208+
return parts[0], parts[0], nil
209+
case 2:
210+
return parts[0], parts[1], nil
185211
default:
186-
return parts[0], parts[1]
212+
return "", "", xerrors.Errorf("invalid volume format: %s", in)
187213
}
188214
}

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