Skip to content

Commit 8c357b4

Browse files
committed
feat(agent/agentcontainers): add ContainerEnvInfoer
1 parent 4732f08 commit 8c357b4

File tree

3 files changed

+389
-12
lines changed

3 files changed

+389
-12
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: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import (
66
"context"
77
"encoding/json"
88
"fmt"
9+
"os/user"
10+
"slices"
911
"sort"
1012
"strconv"
1113
"strings"
@@ -31,6 +33,172 @@ func NewDocker(execer agentexec.Execer) Lister {
3133
}
3234
}
3335

36+
// ContainerEnvInfoer is an implementation of agentssh.EnvInfoer that returns
37+
// information about a container.
38+
type ContainerEnvInfoer struct {
39+
container string
40+
user *user.User
41+
userShell string
42+
env []string
43+
}
44+
45+
// EnvInfo returns information about the environment of a container.
46+
func EnvInfo(ctx context.Context, execer agentexec.Execer, container, containerUser string) (*ContainerEnvInfoer, error) {
47+
var dei ContainerEnvInfoer
48+
dei.container = container
49+
50+
var stdoutBuf, stderrBuf bytes.Buffer
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+
execCmd := execer.CommandContext(ctx, cmd, args...)
56+
execCmd.Stdout = &stdoutBuf
57+
execCmd.Stderr = &stderrBuf
58+
if err := execCmd.Run(); err != nil {
59+
return nil, xerrors.Errorf("get container user: run whoami: %w: stderr: %q", err, strings.TrimSpace(stderrBuf.String()))
60+
}
61+
out := strings.TrimSpace(stdoutBuf.String())
62+
if len(out) == 0 {
63+
return nil, xerrors.Errorf("get container user: run whoami: empty output: stderr: %q", strings.TrimSpace(stderrBuf.String()))
64+
}
65+
containerUser = out
66+
stdoutBuf.Reset()
67+
stderrBuf.Reset()
68+
}
69+
// Now that we know the username, get the required info from the container.
70+
// We can't assume the presence of `getent` so we'll just have to sniff /etc/passwd.
71+
cmd, args := WrapDockerExec(container, containerUser)("cat", "/etc/passwd")
72+
execCmd := execer.CommandContext(ctx, cmd, args...)
73+
execCmd.Stdout = &stdoutBuf
74+
execCmd.Stderr = &stderrBuf
75+
if err := execCmd.Run(); err != nil {
76+
return nil, xerrors.Errorf("get container user: read /etc/passwd: %w stderr: %q", err, strings.TrimSpace(stderrBuf.String()))
77+
}
78+
79+
scanner := bufio.NewScanner(&stdoutBuf)
80+
var foundLine string
81+
for scanner.Scan() {
82+
line := strings.TrimSpace(scanner.Text())
83+
if len(line) == 0 {
84+
continue
85+
}
86+
if !strings.HasPrefix(line, containerUser+":") {
87+
continue
88+
}
89+
foundLine = line
90+
}
91+
if err := scanner.Err(); err != nil {
92+
return nil, xerrors.Errorf("get container user: scan /etc/passwd: %w", err)
93+
}
94+
if foundLine == "" {
95+
return nil, xerrors.Errorf("get container user: no matching entry for %q found in /etc/passwd", containerUser)
96+
}
97+
98+
// Parse the output of /etc/passwd. It looks like this:
99+
// postgres:x:999:999::/var/lib/postgresql:/bin/bash
100+
passwdFields := strings.Split(foundLine, ":")
101+
if len(passwdFields) < 7 {
102+
return nil, xerrors.Errorf("get container user: invalid line in /etc/passwd: %q", foundLine)
103+
}
104+
105+
// The fourth entry in /etc/passwd contains GECOS information, which is a
106+
// comma-separated list of fields. The first field is the user's full name.
107+
gecos := strings.Split(passwdFields[4], ",")
108+
fullName := ""
109+
if len(gecos) > 1 {
110+
fullName = gecos[0]
111+
}
112+
113+
dei.user = &user.User{
114+
Gid: passwdFields[3],
115+
HomeDir: passwdFields[5],
116+
Name: fullName,
117+
Uid: passwdFields[2],
118+
Username: containerUser,
119+
}
120+
dei.userShell = passwdFields[6]
121+
122+
// Finally, get the environment of the container.
123+
stdoutBuf.Reset()
124+
stderrBuf.Reset()
125+
cmd, args = WrapDockerExec(container, containerUser)("env")
126+
execCmd = execer.CommandContext(ctx, cmd, args...)
127+
execCmd.Stdout = &stdoutBuf
128+
execCmd.Stderr = &stderrBuf
129+
if err := execCmd.Run(); err != nil {
130+
return nil, xerrors.Errorf("get container environment: run env: %w stderr: %q", err, strings.TrimSpace(stderrBuf.String()))
131+
}
132+
133+
scanner = bufio.NewScanner(&stdoutBuf)
134+
for scanner.Scan() {
135+
line := strings.TrimSpace(scanner.Text())
136+
dei.env = append(dei.env, line)
137+
}
138+
if err := scanner.Err(); err != nil {
139+
return nil, xerrors.Errorf("get container environment: scan env output: %w", err)
140+
}
141+
142+
return &dei, nil
143+
}
144+
145+
func (dei *ContainerEnvInfoer) CurrentUser() (*user.User, error) {
146+
// Clone the user so that the caller can't modify it
147+
u := &user.User{
148+
Gid: dei.user.Gid,
149+
HomeDir: dei.user.HomeDir,
150+
Name: dei.user.Name,
151+
Uid: dei.user.Uid,
152+
Username: dei.user.Username,
153+
}
154+
return u, nil
155+
}
156+
157+
func (dei *ContainerEnvInfoer) Environ() []string {
158+
// Return a clone of the environment so that the caller can't modify it
159+
return slices.Clone(dei.env)
160+
}
161+
162+
func (dei *ContainerEnvInfoer) UserHomeDir() (string, error) {
163+
return dei.user.HomeDir, nil
164+
}
165+
166+
func (dei *ContainerEnvInfoer) UserShell(string) (string, error) {
167+
return dei.userShell, nil
168+
}
169+
170+
func (dei *ContainerEnvInfoer) ModifyCommand(cmd string, args ...string) (string, []string) {
171+
return WrapDockerExecPTY(dei.container, dei.user.Username)(cmd, args...)
172+
}
173+
174+
// WrapFn is a function that wraps a command and its arguments with another command and arguments.
175+
type WrapFn func(cmd string, args ...string) (string, []string)
176+
177+
// WrapDockerExec returns a WrapFn that wraps the given command and arguments
178+
// with a docker exec command that runs as the given user in the given container.
179+
func WrapDockerExec(containerName, userName string) WrapFn {
180+
return func(cmd string, args ...string) (string, []string) {
181+
dockerArgs := []string{"exec", "--interactive"}
182+
if userName != "" {
183+
dockerArgs = append(dockerArgs, "--user", userName)
184+
}
185+
dockerArgs = append(dockerArgs, containerName, cmd)
186+
return "docker", append(dockerArgs, args...)
187+
}
188+
}
189+
190+
// WrapDockerExecPTY is similar to WrapDockerExec but also allocates a PTY.
191+
func WrapDockerExecPTY(containerName, userName string) WrapFn {
192+
return func(cmd string, args ...string) (string, []string) {
193+
dockerArgs := []string{"exec", "--interactive", "--tty"}
194+
if userName != "" {
195+
dockerArgs = append(dockerArgs, "--user", userName)
196+
}
197+
dockerArgs = append(dockerArgs, containerName, cmd)
198+
return "docker", append(dockerArgs, args...)
199+
}
200+
}
201+
34202
func (dcl *DockerCLILister) List(ctx context.Context) (codersdk.WorkspaceAgentListContainersResponse, error) {
35203
var stdoutBuf, stderrBuf bytes.Buffer
36204
// List all container IDs, one per line, with no truncation

0 commit comments

Comments
 (0)