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+
34241func (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.
116331type dockerInspect struct {
0 commit comments