Skip to content

Commit 304007b

Browse files
johnstcnmafredri
andauthored
feat(agent/agentcontainers): add ContainerEnvInfoer (coder#16623)
This PR adds an alternative implementation of EnvInfo (coder#16603) that reads information from a running container. --------- Co-authored-by: Mathias Fredriksson <mafredri@gmail.com>
1 parent ac88c9b commit 304007b

File tree

3 files changed

+462
-27
lines changed

3 files changed

+462
-27
lines changed

agent/agentcontainers/containers.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,8 @@ type Lister interface {
144144
// NoopLister is a Lister interface that never returns any containers.
145145
type NoopLister struct{}
146146

147+
var _ Lister = NoopLister{}
148+
147149
func (NoopLister) List(_ context.Context) (codersdk.WorkspaceAgentListContainersResponse, error) {
148150
return codersdk.WorkspaceAgentListContainersResponse{}, nil
149151
}

agent/agentcontainers/containers_dockercli.go

Lines changed: 233 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ import (
66
"context"
77
"encoding/json"
88
"fmt"
9+
"os"
10+
"os/user"
11+
"slices"
912
"sort"
1013
"strconv"
1114
"strings"
@@ -31,6 +34,210 @@ func NewDocker(execer agentexec.Execer) Lister {
3134
}
3235
}
3336

37+
// DockerEnvInfoer is an implementation of agentssh.EnvInfoer that returns
38+
// information about a container.
39+
type DockerEnvInfoer struct {
40+
container string
41+
user *user.User
42+
userShell string
43+
env []string
44+
}
45+
46+
// EnvInfo returns information about the environment of a container.
47+
func EnvInfo(ctx context.Context, execer agentexec.Execer, container, containerUser string) (*DockerEnvInfoer, error) {
48+
var dei DockerEnvInfoer
49+
dei.container = container
50+
51+
if containerUser == "" {
52+
// Get the "default" user of the container if no user is specified.
53+
// TODO: handle different container runtimes.
54+
cmd, args := wrapDockerExec(container, "", "whoami")
55+
stdout, stderr, err := run(ctx, execer, cmd, args...)
56+
if err != nil {
57+
return nil, xerrors.Errorf("get container user: run whoami: %w: %s", err, stderr)
58+
}
59+
if len(stdout) == 0 {
60+
return nil, xerrors.Errorf("get container user: run whoami: empty output")
61+
}
62+
containerUser = stdout
63+
}
64+
// Now that we know the username, get the required info from the container.
65+
// We can't assume the presence of `getent` so we'll just have to sniff /etc/passwd.
66+
cmd, args := wrapDockerExec(container, containerUser, "cat", "/etc/passwd")
67+
stdout, stderr, err := run(ctx, execer, cmd, args...)
68+
if err != nil {
69+
return nil, xerrors.Errorf("get container user: read /etc/passwd: %w: %q", err, stderr)
70+
}
71+
72+
scanner := bufio.NewScanner(strings.NewReader(stdout))
73+
var foundLine string
74+
for scanner.Scan() {
75+
line := strings.TrimSpace(scanner.Text())
76+
if !strings.HasPrefix(line, containerUser+":") {
77+
continue
78+
}
79+
foundLine = line
80+
break
81+
}
82+
if err := scanner.Err(); err != nil {
83+
return nil, xerrors.Errorf("get container user: scan /etc/passwd: %w", err)
84+
}
85+
if foundLine == "" {
86+
return nil, xerrors.Errorf("get container user: no matching entry for %q found in /etc/passwd", containerUser)
87+
}
88+
89+
// Parse the output of /etc/passwd. It looks like this:
90+
// postgres:x:999:999::/var/lib/postgresql:/bin/bash
91+
passwdFields := strings.Split(foundLine, ":")
92+
if len(passwdFields) != 7 {
93+
return nil, xerrors.Errorf("get container user: invalid line in /etc/passwd: %q", foundLine)
94+
}
95+
96+
// The fifth entry in /etc/passwd contains GECOS information, which is a
97+
// comma-separated list of fields. The first field is the user's full name.
98+
gecos := strings.Split(passwdFields[4], ",")
99+
fullName := ""
100+
if len(gecos) > 1 {
101+
fullName = gecos[0]
102+
}
103+
104+
dei.user = &user.User{
105+
Gid: passwdFields[3],
106+
HomeDir: passwdFields[5],
107+
Name: fullName,
108+
Uid: passwdFields[2],
109+
Username: containerUser,
110+
}
111+
dei.userShell = passwdFields[6]
112+
113+
// We need to inspect the container labels for remoteEnv and append these to
114+
// the resulting docker exec command.
115+
// ref: https://code.visualstudio.com/docs/devcontainers/attach-container
116+
env, err := devcontainerEnv(ctx, execer, container)
117+
if err != nil { // best effort.
118+
return nil, xerrors.Errorf("read devcontainer remoteEnv: %w", err)
119+
}
120+
dei.env = env
121+
122+
return &dei, nil
123+
}
124+
125+
func (dei *DockerEnvInfoer) CurrentUser() (*user.User, error) {
126+
// Clone the user so that the caller can't modify it
127+
u := *dei.user
128+
return &u, nil
129+
}
130+
131+
func (*DockerEnvInfoer) Environ() []string {
132+
// Return a clone of the environment so that the caller can't modify it
133+
return os.Environ()
134+
}
135+
136+
func (*DockerEnvInfoer) UserHomeDir() (string, error) {
137+
// We default the working directory of the command to the user's home
138+
// directory. Since this came from inside the container, we cannot guarantee
139+
// that this exists on the host. Return the "real" home directory of the user
140+
// instead.
141+
return os.UserHomeDir()
142+
}
143+
144+
func (dei *DockerEnvInfoer) UserShell(string) (string, error) {
145+
return dei.userShell, nil
146+
}
147+
148+
func (dei *DockerEnvInfoer) ModifyCommand(cmd string, args ...string) (string, []string) {
149+
// Wrap the command with `docker exec` and run it as the container user.
150+
// There is some additional munging here regarding the container user and environment.
151+
dockerArgs := []string{
152+
"exec",
153+
// The assumption is that this command will be a shell command, so allocate a PTY.
154+
"--interactive",
155+
"--tty",
156+
// Run the command as the user in the container.
157+
"--user",
158+
dei.user.Username,
159+
// Set the working directory to the user's home directory as a sane default.
160+
"--workdir",
161+
dei.user.HomeDir,
162+
}
163+
164+
// Append the environment variables from the container.
165+
for _, e := range dei.env {
166+
dockerArgs = append(dockerArgs, "--env", e)
167+
}
168+
169+
// Append the container name and the command.
170+
dockerArgs = append(dockerArgs, dei.container, cmd)
171+
return "docker", append(dockerArgs, args...)
172+
}
173+
174+
// devcontainerEnv is a helper function that inspects the container labels to
175+
// find the required environment variables for running a command in the container.
176+
func devcontainerEnv(ctx context.Context, execer agentexec.Execer, container string) ([]string, error) {
177+
ins, stderr, err := runDockerInspect(ctx, execer, container)
178+
if err != nil {
179+
return nil, xerrors.Errorf("inspect container: %w: %q", err, stderr)
180+
}
181+
182+
if len(ins) != 1 {
183+
return nil, xerrors.Errorf("inspect container: expected 1 container, got %d", len(ins))
184+
}
185+
186+
in := ins[0]
187+
if in.Config.Labels == nil {
188+
return nil, nil
189+
}
190+
191+
// We want to look for the devcontainer metadata, which is in the
192+
// value of the label `devcontainer.metadata`.
193+
rawMeta, ok := in.Config.Labels["devcontainer.metadata"]
194+
if !ok {
195+
return nil, nil
196+
}
197+
meta := struct {
198+
RemoteEnv map[string]string `json:"remoteEnv"`
199+
}{}
200+
if err := json.Unmarshal([]byte(rawMeta), &meta); err != nil {
201+
return nil, xerrors.Errorf("unmarshal devcontainer.metadata: %w", err)
202+
}
203+
204+
// The environment variables are stored in the `remoteEnv` key.
205+
env := make([]string, 0, len(meta.RemoteEnv))
206+
for k, v := range meta.RemoteEnv {
207+
env = append(env, fmt.Sprintf("%s=%s", k, v))
208+
}
209+
slices.Sort(env)
210+
return env, nil
211+
}
212+
213+
// wrapDockerExec is a helper function that wraps the given command and arguments
214+
// with a docker exec command that runs as the given user in the given
215+
// container. This is used to fetch information about a container prior to
216+
// running the actual command.
217+
func wrapDockerExec(containerName, userName, cmd string, args ...string) (string, []string) {
218+
dockerArgs := []string{"exec", "--interactive"}
219+
if userName != "" {
220+
dockerArgs = append(dockerArgs, "--user", userName)
221+
}
222+
dockerArgs = append(dockerArgs, containerName, cmd)
223+
return "docker", append(dockerArgs, args...)
224+
}
225+
226+
// Helper function to run a command and return its stdout and stderr.
227+
// We want to differentiate stdout and stderr instead of using CombinedOutput.
228+
// We also want to differentiate between a command running successfully with
229+
// output to stderr and a non-zero exit code.
230+
func run(ctx context.Context, execer agentexec.Execer, cmd string, args ...string) (stdout, stderr string, err error) {
231+
var stdoutBuf, stderrBuf strings.Builder
232+
execCmd := execer.CommandContext(ctx, cmd, args...)
233+
execCmd.Stdout = &stdoutBuf
234+
execCmd.Stderr = &stderrBuf
235+
err = execCmd.Run()
236+
stdout = strings.TrimSpace(stdoutBuf.String())
237+
stderr = strings.TrimSpace(stderrBuf.String())
238+
return stdout, stderr, err
239+
}
240+
34241
func (dcl *DockerCLILister) List(ctx context.Context) (codersdk.WorkspaceAgentListContainersResponse, error) {
35242
var stdoutBuf, stderrBuf bytes.Buffer
36243
// List all container IDs, one per line, with no truncation
@@ -66,30 +273,16 @@ func (dcl *DockerCLILister) List(ctx context.Context) (codersdk.WorkspaceAgentLi
66273
}
67274

68275
// now we can get the detailed information for each container
69-
// Run `docker inspect` on each container ID
70-
stdoutBuf.Reset()
71-
stderrBuf.Reset()
72-
// nolint: gosec // We are not executing user input, these IDs come from
73-
// `docker ps`.
74-
cmd = dcl.execer.CommandContext(ctx, "docker", append([]string{"inspect"}, ids...)...)
75-
cmd.Stdout = &stdoutBuf
76-
cmd.Stderr = &stderrBuf
77-
if err := cmd.Run(); err != nil {
78-
return codersdk.WorkspaceAgentListContainersResponse{}, xerrors.Errorf("run docker inspect: %w: %s", err, strings.TrimSpace(stderrBuf.String()))
79-
}
80-
81-
dockerInspectStderr := strings.TrimSpace(stderrBuf.String())
82-
276+
// Run `docker inspect` on each container ID.
83277
// NOTE: There is an unavoidable potential race condition where a
84278
// container is removed between `docker ps` and `docker inspect`.
85279
// In this case, stderr will contain an error message but stdout
86280
// will still contain valid JSON. We will just end up missing
87281
// information about the removed container. We could potentially
88282
// log this error, but I'm not sure it's worth it.
89-
ins := make([]dockerInspect, 0, len(ids))
90-
if err := json.NewDecoder(&stdoutBuf).Decode(&ins); err != nil {
91-
// However, if we just get invalid JSON, we should absolutely return an error.
92-
return codersdk.WorkspaceAgentListContainersResponse{}, xerrors.Errorf("decode docker inspect output: %w", err)
283+
ins, dockerInspectStderr, err := runDockerInspect(ctx, dcl.execer, ids...)
284+
if err != nil {
285+
return codersdk.WorkspaceAgentListContainersResponse{}, xerrors.Errorf("run docker inspect: %w", err)
93286
}
94287

95288
res := codersdk.WorkspaceAgentListContainersResponse{
@@ -111,6 +304,28 @@ func (dcl *DockerCLILister) List(ctx context.Context) (codersdk.WorkspaceAgentLi
111304
return res, nil
112305
}
113306

307+
// runDockerInspect is a helper function that runs `docker inspect` on the given
308+
// container IDs and returns the parsed output.
309+
// The stderr output is also returned for logging purposes.
310+
func runDockerInspect(ctx context.Context, execer agentexec.Execer, ids ...string) ([]dockerInspect, string, error) {
311+
var stdoutBuf, stderrBuf bytes.Buffer
312+
cmd := execer.CommandContext(ctx, "docker", append([]string{"inspect"}, ids...)...)
313+
cmd.Stdout = &stdoutBuf
314+
cmd.Stderr = &stderrBuf
315+
err := cmd.Run()
316+
stderr := strings.TrimSpace(stderrBuf.String())
317+
if err != nil {
318+
return nil, stderr, err
319+
}
320+
321+
var ins []dockerInspect
322+
if err := json.NewDecoder(&stdoutBuf).Decode(&ins); err != nil {
323+
return nil, stderr, xerrors.Errorf("decode docker inspect output: %w", err)
324+
}
325+
326+
return ins, stderr, nil
327+
}
328+
114329
// To avoid a direct dependency on the Docker API, we use the docker CLI
115330
// to fetch information about containers.
116331
type dockerInspect struct {

0 commit comments

Comments
 (0)