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 ) {
35242var 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
95288res := codersdk.WorkspaceAgentListContainersResponse {
@@ -111,6 +304,28 @@ func (dcl *DockerCLILister) List(ctx context.Context) (codersdk.WorkspaceAgentLi
111304return 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 {