6
6
"context"
7
7
"encoding/json"
8
8
"fmt"
9
+ "net"
9
10
"os/user"
10
11
"slices"
11
12
"sort"
@@ -164,23 +165,28 @@ func (dei *DockerEnvInfoer) ModifyCommand(cmd string, args ...string) (string, [
164
165
// devcontainerEnv is a helper function that inspects the container labels to
165
166
// find the required environment variables for running a command in the container.
166
167
func devcontainerEnv (ctx context.Context , execer agentexec.Execer , container string ) ([]string , error ) {
167
- ins , stderr , err := runDockerInspect (ctx , execer , container )
168
+ stdout , stderr , err := runDockerInspect (ctx , execer , container )
168
169
if err != nil {
169
170
return nil , xerrors .Errorf ("inspect container: %w: %q" , err , stderr )
170
171
}
171
172
173
+ ins , _ , err := convertDockerInspect (stdout )
174
+ if err != nil {
175
+ return nil , xerrors .Errorf ("inspect container: %w" , err )
176
+ }
177
+
172
178
if len (ins ) != 1 {
173
179
return nil , xerrors .Errorf ("inspect container: expected 1 container, got %d" , len (ins ))
174
180
}
175
181
176
182
in := ins [0 ]
177
- if in .Config . Labels == nil {
183
+ if in .Labels == nil {
178
184
return nil , nil
179
185
}
180
186
181
187
// We want to look for the devcontainer metadata, which is in the
182
188
// value of the label `devcontainer.metadata`.
183
- rawMeta , ok := in .Config . Labels ["devcontainer.metadata" ]
189
+ rawMeta , ok := in .Labels ["devcontainer.metadata" ]
184
190
if ! ok {
185
191
return nil , nil
186
192
}
@@ -282,68 +288,63 @@ func (dcl *DockerCLILister) List(ctx context.Context) (codersdk.WorkspaceAgentLi
282
288
// will still contain valid JSON. We will just end up missing
283
289
// information about the removed container. We could potentially
284
290
// log this error, but I'm not sure it's worth it.
285
- ins , dockerInspectStderr , err := runDockerInspect (ctx , dcl .execer , ids ... )
291
+ dockerInspectStdout , dockerInspectStderr , err := runDockerInspect (ctx , dcl .execer , ids ... )
286
292
if err != nil {
287
293
return codersdk.WorkspaceAgentListContainersResponse {}, xerrors .Errorf ("run docker inspect: %w: %s" , err , dockerInspectStderr )
288
294
}
289
295
290
- for _ , in := range ins {
291
- out , warns := convertDockerInspect (in )
292
- res .Warnings = append (res .Warnings , warns ... )
293
- res .Containers = append (res .Containers , out )
296
+ if len (dockerInspectStderr ) > 0 {
297
+ res .Warnings = append (res .Warnings , string (dockerInspectStderr ))
294
298
}
295
299
296
- if dockerPsStderr != "" {
297
- res .Warnings = append (res .Warnings , dockerPsStderr )
298
- }
299
- if dockerInspectStderr != "" {
300
- res .Warnings = append (res .Warnings , dockerInspectStderr )
300
+ outs , warns , err := convertDockerInspect (dockerInspectStdout )
301
+ if err != nil {
302
+ return codersdk.WorkspaceAgentListContainersResponse {}, xerrors .Errorf ("convert docker inspect output: %w" , err )
301
303
}
304
+ res .Warnings = append (res .Warnings , warns ... )
305
+ res .Containers = append (res .Containers , outs ... )
302
306
303
307
return res , nil
304
308
}
305
309
306
310
// runDockerInspect is a helper function that runs `docker inspect` on the given
307
311
// container IDs and returns the parsed output.
308
312
// The stderr output is also returned for logging purposes.
309
- func runDockerInspect (ctx context.Context , execer agentexec.Execer , ids ... string ) ([] dockerInspect , string , error ) {
313
+ func runDockerInspect (ctx context.Context , execer agentexec.Execer , ids ... string ) (stdout , stderr [] byte , err error ) {
310
314
var stdoutBuf , stderrBuf bytes.Buffer
311
315
cmd := execer .CommandContext (ctx , "docker" , append ([]string {"inspect" }, ids ... )... )
312
316
cmd .Stdout = & stdoutBuf
313
317
cmd .Stderr = & stderrBuf
314
- err := cmd .Run ()
315
- stderr := strings .TrimSpace (stderrBuf .String ())
318
+ err = cmd .Run ()
319
+ stdout = bytes .TrimSpace (stdoutBuf .Bytes ())
320
+ stderr = bytes .TrimSpace (stderrBuf .Bytes ())
316
321
if err != nil {
317
- return nil , stderr , err
318
- }
319
-
320
- var ins []dockerInspect
321
- if err := json .NewDecoder (& stdoutBuf ).Decode (& ins ); err != nil {
322
- return nil , stderr , xerrors .Errorf ("decode docker inspect output: %w" , err )
322
+ return stdout , stderr , err
323
323
}
324
324
325
- return ins , stderr , nil
325
+ return stdout , stderr , nil
326
326
}
327
327
328
328
// To avoid a direct dependency on the Docker API, we use the docker CLI
329
329
// to fetch information about containers.
330
330
type dockerInspect struct {
331
- ID string `json:"Id"`
332
- Created time.Time `json:"Created"`
333
- Config dockerInspectConfig `json:"Config"`
334
- HostConfig dockerInspectHostConfig `json:"HostConfig "`
335
- Name string `json:"Name "`
336
- Mounts [] dockerInspectMount `json:"Mounts "`
337
- State dockerInspectState `json:"State "`
331
+ ID string `json:"Id"`
332
+ Created time.Time `json:"Created"`
333
+ Config dockerInspectConfig `json:"Config"`
334
+ Name string `json:"Name "`
335
+ Mounts [] dockerInspectMount `json:"Mounts "`
336
+ State dockerInspectState `json:"State "`
337
+ NetworkSettings dockerInspectNetworkSettings `json:"NetworkSettings "`
338
338
}
339
339
340
340
type dockerInspectConfig struct {
341
341
Image string `json:"Image"`
342
342
Labels map [string ]string `json:"Labels"`
343
343
}
344
344
345
- type dockerInspectHostConfig struct {
346
- PortBindings map [string ]any `json:"PortBindings"`
345
+ type dockerInspectPort struct {
346
+ HostIP string `json:"HostIp"`
347
+ HostPort string `json:"HostPort"`
347
348
}
348
349
349
350
type dockerInspectMount struct {
@@ -358,6 +359,10 @@ type dockerInspectState struct {
358
359
Error string `json:"Error"`
359
360
}
360
361
362
+ type dockerInspectNetworkSettings struct {
363
+ Ports map [string ][]dockerInspectPort `json:"Ports"`
364
+ }
365
+
361
366
func (dis dockerInspectState ) String () string {
362
367
if dis .Running {
363
368
return "running"
@@ -375,50 +380,108 @@ func (dis dockerInspectState) String() string {
375
380
return sb .String ()
376
381
}
377
382
378
- func convertDockerInspect (in dockerInspect ) (codersdk.WorkspaceAgentDevcontainer , []string ) {
383
+ func convertDockerInspect (raw [] byte ) ([] codersdk.WorkspaceAgentDevcontainer , []string , error ) {
379
384
var warns []string
380
- out := codersdk.WorkspaceAgentDevcontainer {
381
- CreatedAt : in .Created ,
382
- // Remove the leading slash from the container name
383
- FriendlyName : strings .TrimPrefix (in .Name , "/" ),
384
- ID : in .ID ,
385
- Image : in .Config .Image ,
386
- Labels : in .Config .Labels ,
387
- Ports : make ([]codersdk.WorkspaceAgentListeningPort , 0 ),
388
- Running : in .State .Running ,
389
- Status : in .State .String (),
390
- Volumes : make (map [string ]string , len (in .Mounts )),
391
- }
392
-
393
- if in .HostConfig .PortBindings == nil {
394
- in .HostConfig .PortBindings = make (map [string ]any )
395
- }
396
- portKeys := maps .Keys (in .HostConfig .PortBindings )
397
- // Sort the ports for deterministic output.
398
- sort .Strings (portKeys )
399
- for _ , p := range portKeys {
400
- if port , network , err := convertDockerPort (p ); err != nil {
401
- warns = append (warns , err .Error ())
402
- } else {
403
- out .Ports = append (out .Ports , codersdk.WorkspaceAgentListeningPort {
404
- Network : network ,
405
- Port : port ,
406
- })
385
+ var ins []dockerInspect
386
+ if err := json .NewDecoder (bytes .NewReader (raw )).Decode (& ins ); err != nil {
387
+ return nil , nil , xerrors .Errorf ("decode docker inspect output: %w" , err )
388
+ }
389
+ outs := make ([]codersdk.WorkspaceAgentDevcontainer , 0 , len (ins ))
390
+
391
+ // Say you have two containers:
392
+ // - Container A with Host IP 127.0.0.1:8000 mapped to container port 8001
393
+ // - Container B with Host IP [::1]:8000 mapped to container port 8001
394
+ // A request to localhost:8000 may be routed to either container.
395
+ // We don't know which one for sure, so we need to surface this to the user.
396
+ // Keep track of all host ports we see. If we see the same host port
397
+ // mapped to multiple containers on different host IPs, we need to
398
+ // warn the user about this.
399
+ // Note that we only do this for loopback or unspecified IPs.
400
+ // We'll assume that the user knows what they're doing if they bind to
401
+ // a specific IP address.
402
+ hostPortContainers := make (map [int ][]string )
403
+
404
+ for _ , in := range ins {
405
+ out := codersdk.WorkspaceAgentDevcontainer {
406
+ CreatedAt : in .Created ,
407
+ // Remove the leading slash from the container name
408
+ FriendlyName : strings .TrimPrefix (in .Name , "/" ),
409
+ ID : in .ID ,
410
+ Image : in .Config .Image ,
411
+ Labels : in .Config .Labels ,
412
+ Ports : make ([]codersdk.WorkspaceAgentDevcontainerPort , 0 ),
413
+ Running : in .State .Running ,
414
+ Status : in .State .String (),
415
+ Volumes : make (map [string ]string , len (in .Mounts )),
416
+ }
417
+
418
+ if in .NetworkSettings .Ports == nil {
419
+ in .NetworkSettings .Ports = make (map [string ][]dockerInspectPort )
420
+ }
421
+ portKeys := maps .Keys (in .NetworkSettings .Ports )
422
+ // Sort the ports for deterministic output.
423
+ sort .Strings (portKeys )
424
+ // If we see the same port bound to both ipv4 and ipv6 loopback or unspecified
425
+ // interfaces to the same container port, there is no point in adding it multiple times.
426
+ loopbackHostPortContainerPorts := make (map [int ]uint16 , 0 )
427
+ for _ , pk := range portKeys {
428
+ for _ , p := range in .NetworkSettings .Ports [pk ] {
429
+ cp , network , err := convertDockerPort (pk )
430
+ if err != nil {
431
+ warns = append (warns , fmt .Sprintf ("convert docker port: %s" , err .Error ()))
432
+ // Default network to "tcp" if we can't parse it.
433
+ network = "tcp"
434
+ }
435
+ hp , err := strconv .Atoi (p .HostPort )
436
+ if err != nil {
437
+ warns = append (warns , fmt .Sprintf ("convert docker host port: %s" , err .Error ()))
438
+ continue
439
+ }
440
+ if hp > 65535 || hp < 1 { // invalid port
441
+ warns = append (warns , fmt .Sprintf ("convert docker host port: invalid host port %d" , hp ))
442
+ continue
443
+ }
444
+
445
+ // Deduplicate host ports for loopback and unspecified IPs.
446
+ if isLoopbackOrUnspecified (p .HostIP ) {
447
+ if found , ok := loopbackHostPortContainerPorts [hp ]; ok && found == cp {
448
+ // We've already seen this port, so skip it.
449
+ continue
450
+ }
451
+ loopbackHostPortContainerPorts [hp ] = cp
452
+ // Also keep track of the host port and the container ID.
453
+ hostPortContainers [hp ] = append (hostPortContainers [hp ], in .ID )
454
+ }
455
+ out .Ports = append (out .Ports , codersdk.WorkspaceAgentDevcontainerPort {
456
+ Network : network ,
457
+ Port : cp ,
458
+ HostPort : uint16 (hp ),
459
+ HostIP : p .HostIP ,
460
+ })
461
+ }
407
462
}
408
- }
409
463
410
- if in .Mounts == nil {
411
- in .Mounts = []dockerInspectMount {}
464
+ if in .Mounts == nil {
465
+ in .Mounts = []dockerInspectMount {}
466
+ }
467
+ // Sort the mounts for deterministic output.
468
+ sort .Slice (in .Mounts , func (i , j int ) bool {
469
+ return in .Mounts [i ].Source < in .Mounts [j ].Source
470
+ })
471
+ for _ , k := range in .Mounts {
472
+ out .Volumes [k .Source ] = k .Destination
473
+ }
474
+ outs = append (outs , out )
412
475
}
413
- // Sort the mounts for deterministic output.
414
- sort . Slice ( in . Mounts , func ( i , j int ) bool {
415
- return in . Mounts [ i ]. Source < in . Mounts [ j ]. Source
416
- })
417
- for _ , k := range in . Mounts {
418
- out . Volumes [ k . Source ] = k . Destination
476
+
477
+ // Check if any host ports are mapped to multiple containers.
478
+ for hp , ids := range hostPortContainers {
479
+ if len ( ids ) > 1 {
480
+ warns = append ( warns , fmt . Sprintf ( "host port %d is mapped to multiple containers on different interfaces: %s" , hp , strings . Join ( ids , ", " )))
481
+ }
419
482
}
420
483
421
- return out , warns
484
+ return outs , warns , nil
422
485
}
423
486
424
487
// convertDockerPort converts a Docker port string to a port number and network
@@ -445,3 +508,12 @@ func convertDockerPort(in string) (uint16, string, error) {
445
508
return 0 , "" , xerrors .Errorf ("invalid port format: %s" , in )
446
509
}
447
510
}
511
+
512
+ // convenience function to check if an IP address is loopback or unspecified
513
+ func isLoopbackOrUnspecified (ips string ) bool {
514
+ nip := net .ParseIP (ips )
515
+ if nip == nil {
516
+ return false // technically correct, I suppose
517
+ }
518
+ return nip .IsLoopback () || nip .IsUnspecified ()
519
+ }
0 commit comments