Skip to content

Commit f52142f

Browse files
committed
fix(agent/agentcontainers): return host port instead of container port
1 parent 4b1da9b commit f52142f

File tree

2 files changed

+203
-88
lines changed

2 files changed

+203
-88
lines changed

agent/agentcontainers/containers_dockercli.go

Lines changed: 61 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -320,13 +320,14 @@ func runDockerInspect(ctx context.Context, execer agentexec.Execer, ids ...strin
320320
// To avoid a direct dependency on the Docker API, we use the docker CLI
321321
// to fetch information about containers.
322322
type dockerInspect struct {
323-
ID string `json:"Id"`
324-
Created time.Time `json:"Created"`
325-
Config dockerInspectConfig `json:"Config"`
326-
HostConfig dockerInspectHostConfig `json:"HostConfig"`
327-
Name string `json:"Name"`
328-
Mounts []dockerInspectMount `json:"Mounts"`
329-
State dockerInspectState `json:"State"`
323+
ID string `json:"Id"`
324+
Created time.Time `json:"Created"`
325+
Config dockerInspectConfig `json:"Config"`
326+
HostConfig dockerInspectHostConfig `json:"HostConfig"`
327+
Name string `json:"Name"`
328+
Mounts []dockerInspectMount `json:"Mounts"`
329+
State dockerInspectState `json:"State"`
330+
NetworkSettings dockerNetworkSettings `json:"NetworkSettings"`
330331
}
331332

332333
type dockerInspectConfig struct {
@@ -335,7 +336,16 @@ type dockerInspectConfig struct {
335336
}
336337

337338
type dockerInspectHostConfig struct {
338-
PortBindings map[string]any `json:"PortBindings"`
339+
PortBindings map[string][]dockerPortBinding `json:"PortBindings"`
340+
}
341+
342+
type dockerPortBinding struct {
343+
HostIP string `json:"HostIp"`
344+
HostPort string `json:"HostPort"`
345+
}
346+
347+
type dockerNetworkSettings struct {
348+
Ports map[string][]dockerPortBinding `json:"Ports"`
339349
}
340350

341351
type dockerInspectMount struct {
@@ -382,20 +392,54 @@ func convertDockerInspect(in dockerInspect) (codersdk.WorkspaceAgentDevcontainer
382392
Volumes: make(map[string]string, len(in.Mounts)),
383393
}
384394

385-
if in.HostConfig.PortBindings == nil {
386-
in.HostConfig.PortBindings = make(map[string]any)
395+
// Use NetworkSettings.Ports for externally accessible ports
396+
if in.NetworkSettings.Ports == nil {
397+
in.NetworkSettings.Ports = make(map[string][]dockerPortBinding)
387398
}
388-
portKeys := maps.Keys(in.HostConfig.PortBindings)
389-
// Sort the ports for deterministic output.
399+
400+
// Track host ports to avoid duplicates (same port on multiple interfaces)
401+
seenHostPorts := make(map[string]bool)
402+
403+
// Get all port mappings
404+
portKeys := maps.Keys(in.NetworkSettings.Ports)
405+
// Sort the ports for deterministic output
390406
sort.Strings(portKeys)
391-
for _, p := range portKeys {
392-
if port, network, err := convertDockerPort(p); err != nil {
393-
warns = append(warns, err.Error())
394-
} else {
407+
408+
for containerPortSpec, bindings := range in.NetworkSettings.Ports {
409+
// Skip if no bindings exist (don't expose the container port)
410+
if len(bindings) == 0 {
411+
continue
412+
}
413+
414+
// Parse the containerPortSpec (e.g., "8080/tcp")
415+
parts := strings.Split(containerPortSpec, "/")
416+
417+
// Get the network protocol
418+
network := "tcp" // Default to tcp
419+
if len(parts) == 2 {
420+
network = parts[1]
421+
}
422+
423+
// Process all bindings for this port (usually different interfaces)
424+
for _, binding := range bindings {
425+
// Skip if we've already seen this host port
426+
if seenHostPorts[binding.HostPort] {
427+
continue
428+
}
429+
430+
hostPort, err := strconv.Atoi(binding.HostPort)
431+
if err != nil {
432+
warns = append(warns, fmt.Sprintf("invalid host port format: %s", binding.HostPort))
433+
continue
434+
}
435+
395436
out.Ports = append(out.Ports, codersdk.WorkspaceAgentListeningPort{
396437
Network: network,
397-
Port: port,
438+
Port: uint16(hostPort),
398439
})
440+
441+
// Mark this host port as seen
442+
seenHostPorts[binding.HostPort] = true
399443
}
400444
}
401445

@@ -412,28 +456,3 @@ func convertDockerInspect(in dockerInspect) (codersdk.WorkspaceAgentDevcontainer
412456

413457
return out, warns
414458
}
415-
416-
// convertDockerPort converts a Docker port string to a port number and network
417-
// example: "8080/tcp" -> 8080, "tcp"
418-
//
419-
// "8080" -> 8080, "tcp"
420-
func convertDockerPort(in string) (uint16, string, error) {
421-
parts := strings.Split(in, "/")
422-
switch len(parts) {
423-
case 1:
424-
// assume it's a TCP port
425-
p, err := strconv.Atoi(parts[0])
426-
if err != nil {
427-
return 0, "", xerrors.Errorf("invalid port format: %s", in)
428-
}
429-
return uint16(p), "tcp", nil
430-
case 2:
431-
p, err := strconv.Atoi(parts[0])
432-
if err != nil {
433-
return 0, "", xerrors.Errorf("invalid port format: %s", in)
434-
}
435-
return uint16(p), parts[1], nil
436-
default:
437-
return 0, "", xerrors.Errorf("invalid port format: %s", in)
438-
}
439-
}

agent/agentcontainers/containers_internal_test.go

Lines changed: 142 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"fmt"
55
"os"
66
"slices"
7+
"sort"
78
"strconv"
89
"strings"
910
"testing"
@@ -46,7 +47,7 @@ func TestIntegrationDocker(t *testing.T) {
4647
// Create a temporary directory to validate that we surface mounts correctly.
4748
testTempDir := t.TempDir()
4849
// Pick a random port to expose for testing port bindings.
49-
testRandPort := testutil.RandomPortNoListen(t)
50+
testRandHostPort, testRandContainerPort := testutil.RandomPortNoListen(t), testutil.RandomPortNoListen(t)
5051
ct, err := pool.RunWithOptions(&dockertest.RunOptions{
5152
Repository: "busybox",
5253
Tag: "latest",
@@ -56,12 +57,12 @@ func TestIntegrationDocker(t *testing.T) {
5657
"devcontainer.metadata": `[{"remoteEnv": {"FOO": "bar", "MULTILINE": "foo\nbar\nbaz"}}]`,
5758
},
5859
Mounts: []string{testTempDir + ":" + testTempDir},
59-
ExposedPorts: []string{fmt.Sprintf("%d/tcp", testRandPort)},
60+
ExposedPorts: []string{fmt.Sprintf("%d/tcp", testRandContainerPort)},
6061
PortBindings: map[docker.Port][]docker.PortBinding{
61-
docker.Port(fmt.Sprintf("%d/tcp", testRandPort)): {
62+
docker.Port(fmt.Sprintf("%d/tcp", testRandContainerPort)): {
6263
{
6364
HostIP: "0.0.0.0",
64-
HostPort: strconv.FormatInt(int64(testRandPort), 10),
65+
HostPort: strconv.FormatInt(int64(testRandHostPort), 10),
6566
},
6667
},
6768
},
@@ -99,7 +100,7 @@ func TestIntegrationDocker(t *testing.T) {
99100
assert.True(t, foundContainer.Running)
100101
assert.Equal(t, "running", foundContainer.Status)
101102
if assert.Len(t, foundContainer.Ports, 1) {
102-
assert.Equal(t, testRandPort, foundContainer.Ports[0].Port)
103+
assert.Equal(t, testRandHostPort, foundContainer.Ports[0].Port)
103104
assert.Equal(t, "tcp", foundContainer.Ports[0].Network)
104105
}
105106
if assert.Len(t, foundContainer.Volumes, 1) {
@@ -306,67 +307,162 @@ func TestContainersHandler(t *testing.T) {
306307
})
307308
}
308309

309-
func TestConvertDockerPort(t *testing.T) {
310+
// TestDockerPortBinding tests the port binding handling in convertDockerInspect
311+
func TestDockerPortBinding(t *testing.T) {
310312
t.Parallel()
311313

312-
for _, tc := range []struct {
314+
testCases := []struct {
313315
name string
314-
in string
315-
expectPort uint16
316-
expectNetwork string
317-
expectError string
316+
networkPorts map[string][]dockerPortBinding
317+
expectedPorts []codersdk.WorkspaceAgentListeningPort
318+
expectedWarns int
318319
}{
319320
{
320-
name: "empty port",
321-
in: "",
322-
expectError: "invalid port",
321+
name: "nil",
322+
networkPorts: nil,
323+
expectedPorts: []codersdk.WorkspaceAgentListeningPort{},
323324
},
324325
{
325-
name: "valid tcp port",
326-
in: "8080/tcp",
327-
expectPort: 8080,
328-
expectNetwork: "tcp",
326+
name: "simple port binding",
327+
networkPorts: map[string][]dockerPortBinding{
328+
"8080/tcp": {
329+
{HostIP: "0.0.0.0", HostPort: "9090"},
330+
},
331+
},
332+
expectedPorts: []codersdk.WorkspaceAgentListeningPort{
333+
{Network: "tcp", Port: 9090},
334+
},
329335
},
330336
{
331-
name: "valid udp port",
332-
in: "8080/udp",
333-
expectPort: 8080,
334-
expectNetwork: "udp",
337+
name: "multiple port bindings",
338+
networkPorts: map[string][]dockerPortBinding{
339+
"8080/tcp": {
340+
{HostIP: "0.0.0.0", HostPort: "9090"},
341+
},
342+
"8081/tcp": {
343+
{HostIP: "0.0.0.0", HostPort: "9091"},
344+
},
345+
},
346+
expectedPorts: []codersdk.WorkspaceAgentListeningPort{
347+
{Network: "tcp", Port: 9090},
348+
{Network: "tcp", Port: 9091},
349+
},
335350
},
336351
{
337-
name: "valid port no network",
338-
in: "8080",
339-
expectPort: 8080,
340-
expectNetwork: "tcp",
352+
name: "duplicate host ports on different interfaces",
353+
networkPorts: map[string][]dockerPortBinding{
354+
"8080/tcp": {
355+
{HostIP: "0.0.0.0", HostPort: "9090"},
356+
{HostIP: "127.0.0.1", HostPort: "9090"},
357+
},
358+
},
359+
expectedPorts: []codersdk.WorkspaceAgentListeningPort{
360+
{Network: "tcp", Port: 9090},
361+
},
341362
},
342363
{
343-
name: "invalid port",
344-
in: "invalid/tcp",
345-
expectError: "invalid port",
364+
name: "udp protocol",
365+
networkPorts: map[string][]dockerPortBinding{
366+
"8080/udp": {
367+
{HostIP: "0.0.0.0", HostPort: "9090"},
368+
},
369+
},
370+
expectedPorts: []codersdk.WorkspaceAgentListeningPort{
371+
{Network: "udp", Port: 9090},
372+
},
346373
},
347374
{
348-
name: "invalid port no network",
349-
in: "invalid",
350-
expectError: "invalid port",
375+
name: "no protocol defaults to tcp",
376+
networkPorts: map[string][]dockerPortBinding{
377+
"8080": {
378+
{HostIP: "0.0.0.0", HostPort: "9090"},
379+
},
380+
},
381+
expectedPorts: []codersdk.WorkspaceAgentListeningPort{
382+
{Network: "tcp", Port: 9090},
383+
},
351384
},
352385
{
353-
name: "multiple network",
354-
in: "8080/tcp/udp",
355-
expectError: "invalid port",
386+
name: "no bindings should not create ports",
387+
networkPorts: map[string][]dockerPortBinding{
388+
"8080/tcp": {},
389+
},
390+
expectedPorts: []codersdk.WorkspaceAgentListeningPort{},
356391
},
357-
} {
358-
tc := tc // not needed anymore but makes the linter happy
392+
{
393+
name: "invalid host port",
394+
networkPorts: map[string][]dockerPortBinding{
395+
"8080/tcp": {
396+
{HostIP: "0.0.0.0", HostPort: "invalid"},
397+
},
398+
},
399+
expectedPorts: []codersdk.WorkspaceAgentListeningPort{},
400+
expectedWarns: 1,
401+
},
402+
{
403+
name: "mix of valid and invalid ports",
404+
networkPorts: map[string][]dockerPortBinding{
405+
"8080/tcp": {
406+
{HostIP: "0.0.0.0", HostPort: "9090"},
407+
},
408+
"8081/tcp": {
409+
{HostIP: "0.0.0.0", HostPort: "invalid"},
410+
},
411+
},
412+
expectedPorts: []codersdk.WorkspaceAgentListeningPort{
413+
{Network: "tcp", Port: 9090},
414+
},
415+
expectedWarns: 1,
416+
},
417+
}
418+
419+
for _, tc := range testCases {
420+
tc := tc
359421
t.Run(tc.name, func(t *testing.T) {
360422
t.Parallel()
361-
actualPort, actualNetwork, actualErr := convertDockerPort(tc.in)
362-
if tc.expectError != "" {
363-
assert.Zero(t, actualPort, "expected no port")
364-
assert.Empty(t, actualNetwork, "expected no network")
365-
assert.ErrorContains(t, actualErr, tc.expectError)
366-
} else {
367-
assert.NoError(t, actualErr, "expected no error")
368-
assert.Equal(t, tc.expectPort, actualPort, "expected port to match")
369-
assert.Equal(t, tc.expectNetwork, actualNetwork, "expected network to match")
423+
424+
// Create a sample docker inspection result
425+
dockerData := dockerInspect{
426+
ID: "test-container",
427+
Created: time.Now(),
428+
Config: dockerInspectConfig{
429+
Image: "test-image",
430+
Labels: map[string]string{"test": "value"},
431+
},
432+
Name: "test-container",
433+
State: dockerInspectState{Running: true},
434+
NetworkSettings: dockerNetworkSettings{
435+
Ports: tc.networkPorts,
436+
},
437+
}
438+
439+
// Process the docker data
440+
container, warns := convertDockerInspect(dockerData)
441+
442+
// Verify the ports
443+
assert.Len(t, container.Ports, len(tc.expectedPorts), "wrong number of ports")
444+
assert.Len(t, warns, tc.expectedWarns, "wrong number of warnings")
445+
446+
// Sort ports for consistent comparison (order may vary)
447+
sort.Slice(container.Ports, func(i, j int) bool {
448+
if container.Ports[i].Network == container.Ports[j].Network {
449+
return container.Ports[i].Port < container.Ports[j].Port
450+
}
451+
return container.Ports[i].Network < container.Ports[j].Network
452+
})
453+
sort.Slice(tc.expectedPorts, func(i, j int) bool {
454+
if tc.expectedPorts[i].Network == tc.expectedPorts[j].Network {
455+
return tc.expectedPorts[i].Port < tc.expectedPorts[j].Port
456+
}
457+
return tc.expectedPorts[i].Network < tc.expectedPorts[j].Network
458+
})
459+
460+
// Compare ports
461+
for i, expected := range tc.expectedPorts {
462+
if i < len(container.Ports) {
463+
assert.Equal(t, expected.Network, container.Ports[i].Network, "network mismatch")
464+
assert.Equal(t, expected.Port, container.Ports[i].Port, "port mismatch")
465+
}
370466
}
371467
})
372468
}

0 commit comments

Comments
 (0)