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)
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