Skip to content

Commit a56462e

Browse files
committed
feat(agent): add container list handler
Adds an API endpoint to coderd `/api/v2/workspaceagents/:id/containers` that allows listing containers visible to the agent. This initial implementation only supports listing containers using the Docker CLI. Support for other data sources may be added at a future date.
1 parent a546a85 commit a56462e

File tree

17 files changed

+1258
-0
lines changed

17 files changed

+1258
-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: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
package agent
2+
3+
//go:generate mockgen -destination ./containers_mock.go -package agent . ContainerLister
4+
5+
import (
6+
"context"
7+
"net/http"
8+
"sync"
9+
"time"
10+
11+
"golang.org/x/xerrors"
12+
13+
"github.com/coder/coder/v2/coderd/httpapi"
14+
"github.com/coder/coder/v2/codersdk"
15+
"github.com/coder/quartz"
16+
)
17+
18+
const (
19+
defaultGetContainersCacheDuration = 10 * time.Second
20+
dockerCreatedAtTimeFormat = "2006-01-02 15:04:05 -0700 MST"
21+
getContainersTimeout = 5 * time.Second
22+
)
23+
24+
type containersHandler struct {
25+
cacheDuration time.Duration
26+
cl ContainerLister
27+
clock quartz.Clock
28+
29+
mu sync.Mutex // protects the below
30+
containers []codersdk.WorkspaceAgentContainer
31+
mtime time.Time
32+
}
33+
34+
func (ch *containersHandler) handler(rw http.ResponseWriter, r *http.Request) {
35+
ct, err := ch.getContainers(r.Context())
36+
if err != nil {
37+
httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{
38+
Message: "Could not get containers.",
39+
Detail: err.Error(),
40+
})
41+
return
42+
}
43+
44+
httpapi.Write(r.Context(), rw, http.StatusOK, ct)
45+
}
46+
47+
func (ch *containersHandler) getContainers(ctx context.Context) ([]codersdk.WorkspaceAgentContainer, error) {
48+
ch.mu.Lock()
49+
defer ch.mu.Unlock()
50+
51+
// make zero-value usable
52+
if ch.cacheDuration == 0 {
53+
ch.cacheDuration = defaultGetContainersCacheDuration
54+
}
55+
if ch.cl == nil {
56+
// TODO(cian): we may need some way to select the desired
57+
// implementation, but for now there is only one.
58+
ch.cl = &dockerCLIContainerLister{}
59+
}
60+
if ch.containers == nil {
61+
ch.containers = make([]codersdk.WorkspaceAgentContainer, 0)
62+
}
63+
if ch.clock == nil {
64+
ch.clock = quartz.NewReal()
65+
}
66+
67+
now := ch.clock.Now()
68+
if now.Sub(ch.mtime) < ch.cacheDuration {
69+
cpy := make([]codersdk.WorkspaceAgentContainer, len(ch.containers))
70+
copy(cpy, ch.containers)
71+
return cpy, nil
72+
}
73+
74+
cancelCtx, cancelFunc := context.WithTimeout(ctx, getContainersTimeout)
75+
defer cancelFunc()
76+
updated, err := ch.cl.List(cancelCtx)
77+
if err != nil {
78+
return nil, xerrors.Errorf("get containers: %w", err)
79+
}
80+
ch.containers = updated
81+
ch.mtime = now
82+
83+
// return a copy
84+
cpy := make([]codersdk.WorkspaceAgentContainer, len(ch.containers))
85+
copy(cpy, ch.containers)
86+
return cpy, nil
87+
}
88+
89+
type ContainerLister interface {
90+
List(ctx context.Context) ([]codersdk.WorkspaceAgentContainer, error)
91+
}

agent/containers_dockercli.go

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
package agent
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"fmt"
8+
"os/exec"
9+
"sort"
10+
"strconv"
11+
"strings"
12+
"time"
13+
14+
"github.com/coder/coder/v2/codersdk"
15+
16+
"golang.org/x/exp/maps"
17+
"golang.org/x/xerrors"
18+
)
19+
20+
// dockerCLIContainerLister is a ContainerLister that lists containers using the docker CLI
21+
type dockerCLIContainerLister struct{}
22+
23+
var _ ContainerLister = &dockerCLIContainerLister{}
24+
25+
func (*dockerCLIContainerLister) List(ctx context.Context) ([]codersdk.WorkspaceAgentContainer, error) {
26+
var buf bytes.Buffer
27+
// List all container IDs, one per line, with no truncation
28+
cmd := exec.CommandContext(ctx, "docker", "ps", "--all", "--quiet", "--no-trunc")
29+
cmd.Stdout = &buf
30+
if err := cmd.Run(); err != nil {
31+
return nil, xerrors.Errorf("run docker ps: %w", err)
32+
}
33+
34+
// the output is returned with a single item per line, so we have to decode it
35+
// line-by-line
36+
ids := make([]string, 0)
37+
for _, line := range strings.Split(buf.String(), "\n") {
38+
tmp := strings.TrimSpace(line)
39+
if tmp == "" {
40+
continue
41+
}
42+
ids = append(ids, tmp)
43+
}
44+
45+
// now we can get the detailed information for each container
46+
// Run `docker inspect` on each container ID
47+
buf.Reset()
48+
execArgs := []string{"inspect"}
49+
execArgs = append(execArgs, ids...)
50+
cmd = exec.CommandContext(ctx, "docker", execArgs...)
51+
cmd.Stdout = &buf
52+
if err := cmd.Run(); err != nil {
53+
return nil, xerrors.Errorf("run docker inspect: %w", err)
54+
}
55+
56+
// out := make([]codersdk.WorkspaceAgentContainer, 0)
57+
ins := make([]dockerInspect, 0)
58+
if err := json.NewDecoder(&buf).Decode(&ins); err != nil {
59+
return nil, xerrors.Errorf("decode docker inspect output: %w", err)
60+
}
61+
62+
out := make([]codersdk.WorkspaceAgentContainer, 0)
63+
for _, in := range ins {
64+
out = append(out, convertDockerInspect(in))
65+
}
66+
67+
return out, nil
68+
}
69+
70+
// To avoid a direct dependency on the Docker API, we use the docker CLI
71+
// to fetch information about containers.
72+
type dockerInspect struct {
73+
ID string `json:"Id"`
74+
Created time.Time `json:"Created"`
75+
Name string `json:"Name"`
76+
Config dockerInspectConfig `json:"Config"`
77+
State dockerInspectState `json:"State"`
78+
}
79+
80+
type dockerInspectConfig struct {
81+
ExposedPorts map[string]struct{} `json:"ExposedPorts"`
82+
Image string `json:"Image"`
83+
Labels map[string]string `json:"Labels"`
84+
Volumes map[string]struct{} `json:"Volumes"`
85+
}
86+
87+
type dockerInspectState struct {
88+
Running bool `json:"Running"`
89+
ExitCode int `json:"ExitCode"`
90+
Error string `json:"Error"`
91+
}
92+
93+
func (dis dockerInspectState) String() string {
94+
if dis.Running {
95+
return "running"
96+
}
97+
var sb strings.Builder
98+
_, _ = sb.WriteString("exited")
99+
if dis.ExitCode != 0 {
100+
_, _ = sb.WriteString(fmt.Sprintf(" with code %d", dis.ExitCode))
101+
} else {
102+
_, _ = sb.WriteString(" successfully")
103+
}
104+
if dis.Error != "" {
105+
_, _ = sb.WriteString(fmt.Sprintf(": %s", dis.Error))
106+
}
107+
return sb.String()
108+
}
109+
110+
func convertDockerInspect(in dockerInspect) codersdk.WorkspaceAgentContainer {
111+
out := codersdk.WorkspaceAgentContainer{
112+
CreatedAt: in.Created,
113+
// Remove the leading slash from the container name
114+
FriendlyName: strings.TrimPrefix(in.Name, "/"),
115+
ID: in.ID,
116+
Image: in.Config.Image,
117+
Labels: in.Config.Labels,
118+
Ports: make([]codersdk.WorkspaceAgentListeningPort, 0),
119+
Running: in.State.Running,
120+
Status: in.State.String(),
121+
Volumes: make(map[string]string),
122+
}
123+
124+
// sort the keys for deterministic output
125+
portKeys := maps.Keys(in.Config.ExposedPorts)
126+
sort.Strings(portKeys)
127+
for _, p := range portKeys {
128+
port, network, err := convertDockerPort(p)
129+
if err != nil {
130+
// ignore invalid ports
131+
continue
132+
}
133+
out.Ports = append(out.Ports, codersdk.WorkspaceAgentListeningPort{
134+
Network: network,
135+
Port: port,
136+
})
137+
}
138+
139+
// sort the keys for deterministic output
140+
volKeys := maps.Keys(in.Config.Volumes)
141+
sort.Strings(volKeys)
142+
for _, k := range volKeys {
143+
v0, v1 := convertDockerVolume(k)
144+
out.Volumes[v0] = v1
145+
}
146+
147+
return out
148+
}
149+
150+
// convertDockerPort converts a Docker port string to a port number and network
151+
// example: "8080/tcp" -> 8080, "tcp"
152+
//
153+
// "8080" -> 8080, "tcp"
154+
func convertDockerPort(in string) (uint16, string, error) {
155+
parts := strings.Split(in, "/")
156+
switch len(parts) {
157+
case 0:
158+
return 0, "", xerrors.Errorf("invalid port format: %s", in)
159+
case 1:
160+
// assume it's a TCP port
161+
p, err := strconv.Atoi(parts[0])
162+
if err != nil {
163+
return 0, "", xerrors.Errorf("invalid port format: %s", in)
164+
}
165+
return uint16(p), "tcp", nil
166+
default:
167+
p, err := strconv.Atoi(parts[0])
168+
if err != nil {
169+
return 0, "", xerrors.Errorf("invalid port format: %s", in)
170+
}
171+
return uint16(p), parts[1], nil
172+
}
173+
}
174+
175+
// convertDockerVolume converts a Docker volume string to a host path and
176+
// container path. If the host path is not specified, the container path is used
177+
// as the host path.
178+
// example: "/host/path=/container/path" -> "/host/path", "/container/path"
179+
//
180+
// "/container/path" -> "/container/path", "/container/path"
181+
func convertDockerVolume(in string) (hostPath, containerPath string) {
182+
parts := strings.Split(in, "=")
183+
switch len(parts) {
184+
case 0:
185+
return in, in
186+
case 1:
187+
return parts[0], parts[0]
188+
default:
189+
return parts[0], parts[1]
190+
}
191+
}

0 commit comments

Comments
 (0)