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 {
0 commit comments