6
6
"context"
7
7
"encoding/json"
8
8
"fmt"
9
+ "os"
10
+ "os/user"
11
+ "slices"
9
12
"sort"
10
13
"strconv"
11
14
"strings"
@@ -31,6 +34,210 @@ func NewDocker(execer agentexec.Execer) Lister {
31
34
}
32
35
}
33
36
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
+
34
241
func (dcl * DockerCLILister )List (ctx context.Context ) (codersdk.WorkspaceAgentListContainersResponse ,error ) {
35
242
var stdoutBuf ,stderrBuf bytes.Buffer
36
243
// List all container IDs, one per line, with no truncation
@@ -66,30 +273,16 @@ func (dcl *DockerCLILister) List(ctx context.Context) (codersdk.WorkspaceAgentLi
66
273
}
67
274
68
275
// 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.
83
277
// NOTE: There is an unavoidable potential race condition where a
84
278
// container is removed between `docker ps` and `docker inspect`.
85
279
// In this case, stderr will contain an error message but stdout
86
280
// will still contain valid JSON. We will just end up missing
87
281
// information about the removed container. We could potentially
88
282
// 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 )
93
286
}
94
287
95
288
res := codersdk.WorkspaceAgentListContainersResponse {
@@ -111,6 +304,28 @@ func (dcl *DockerCLILister) List(ctx context.Context) (codersdk.WorkspaceAgentLi
111
304
return res ,nil
112
305
}
113
306
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
+
114
329
// To avoid a direct dependency on the Docker API, we use the docker CLI
115
330
// to fetch information about containers.
116
331
type dockerInspect struct {