Skip to content

fix(agent/agentcontainers): return host port instead of container port #16866

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 62 additions & 42 deletions agent/agentcontainers/containers_dockercli.go
Original file line number Diff line number Diff line change
Expand Up @@ -320,13 +320,14 @@ func runDockerInspect(ctx context.Context, execer agentexec.Execer, ids ...strin
// To avoid a direct dependency on the Docker API, we use the docker CLI
// to fetch information about containers.
type dockerInspect struct {
ID string `json:"Id"`
Created time.Time `json:"Created"`
Config dockerInspectConfig `json:"Config"`
HostConfig dockerInspectHostConfig `json:"HostConfig"`
Name string `json:"Name"`
Mounts []dockerInspectMount `json:"Mounts"`
State dockerInspectState `json:"State"`
ID string `json:"Id"`
Created time.Time `json:"Created"`
Config dockerInspectConfig `json:"Config"`
HostConfig dockerInspectHostConfig `json:"HostConfig"`
Name string `json:"Name"`
Mounts []dockerInspectMount `json:"Mounts"`
State dockerInspectState `json:"State"`
NetworkSettings dockerNetworkSettings `json:"NetworkSettings"`
}

type dockerInspectConfig struct {
Expand All @@ -335,7 +336,16 @@ type dockerInspectConfig struct {
}

type dockerInspectHostConfig struct {
PortBindings map[string]any `json:"PortBindings"`
PortBindings map[string][]dockerPortBinding `json:"PortBindings"`
}

type dockerPortBinding struct {
HostIP string `json:"HostIp"`
HostPort string `json:"HostPort"`
}

type dockerNetworkSettings struct {
Ports map[string][]dockerPortBinding `json:"Ports"`
}

type dockerInspectMount struct {
Expand Down Expand Up @@ -382,20 +392,55 @@ func convertDockerInspect(in dockerInspect) (codersdk.WorkspaceAgentDevcontainer
Volumes: make(map[string]string, len(in.Mounts)),
}

if in.HostConfig.PortBindings == nil {
in.HostConfig.PortBindings = make(map[string]any)
// Use NetworkSettings.Ports for externally accessible ports
if in.NetworkSettings.Ports == nil {
in.NetworkSettings.Ports = make(map[string][]dockerPortBinding)
}
portKeys := maps.Keys(in.HostConfig.PortBindings)
// Sort the ports for deterministic output.

// Track host ports to avoid duplicates (same port on multiple interfaces)
seenHostPorts := make(map[string]bool)

// Get port mappings in sorted order for deterministic output
portKeys := maps.Keys(in.NetworkSettings.Ports)
sort.Strings(portKeys)
for _, p := range portKeys {
if port, network, err := convertDockerPort(p); err != nil {
warns = append(warns, err.Error())
} else {

for _, containerPortSpec := range portKeys {
bindings := in.NetworkSettings.Ports[containerPortSpec]

// Skip if no bindings exist (don't expose the container port)
if len(bindings) == 0 {
continue
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: This check seems like it's not needed, we're iterating on the map keys so technically this can't happen.


// Parse the containerPortSpec (e.g., "8080/tcp")
parts := strings.Split(containerPortSpec, "/")

// Get the network protocol
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// Get the network protocol

Nit: Comment why not what. What is inferable from the code.

network := "tcp" // Default to tcp
if len(parts) == 2 {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this is output from docker inspect, we should probably assume len == 2 and anything else is an unexpected state.

Alt: why did we get rid of the convertDockerPort function when it was more complete?

network = parts[1]
}

// Process all bindings for this port (usually different interfaces)
for _, binding := range bindings {
// Skip if we've already seen this host port
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// Skip if we've already seen this host port

if seenHostPorts[binding.HostPort] {
continue
}

hostPort, err := strconv.Atoi(binding.HostPort)
if err != nil {
warns = append(warns, fmt.Sprintf("invalid host port format: %s", binding.HostPort))
continue
}

out.Ports = append(out.Ports, codersdk.WorkspaceAgentListeningPort{
Network: network,
Port: port,
Port: uint16(hostPort),
})

// Mark this host port as seen
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// Mark this host port as seen

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// Mark this host port as seen

seenHostPorts[binding.HostPort] = true
}
}

Expand All @@ -412,28 +457,3 @@ func convertDockerInspect(in dockerInspect) (codersdk.WorkspaceAgentDevcontainer

return out, warns
}

// convertDockerPort converts a Docker port string to a port number and network
// example: "8080/tcp" -> 8080, "tcp"
//
// "8080" -> 8080, "tcp"
func convertDockerPort(in string) (uint16, string, error) {
parts := strings.Split(in, "/")
switch len(parts) {
case 1:
// assume it's a TCP port
p, err := strconv.Atoi(parts[0])
if err != nil {
return 0, "", xerrors.Errorf("invalid port format: %s", in)
}
return uint16(p), "tcp", nil
case 2:
p, err := strconv.Atoi(parts[0])
if err != nil {
return 0, "", xerrors.Errorf("invalid port format: %s", in)
}
return uint16(p), parts[1], nil
default:
return 0, "", xerrors.Errorf("invalid port format: %s", in)
}
}
163 changes: 117 additions & 46 deletions agent/agentcontainers/containers_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (

"go.uber.org/mock/gomock"

"github.com/google/go-cmp/cmp"
"github.com/google/uuid"
"github.com/ory/dockertest/v3"
"github.com/ory/dockertest/v3/docker"
Expand Down Expand Up @@ -46,7 +47,7 @@ func TestIntegrationDocker(t *testing.T) {
// Create a temporary directory to validate that we surface mounts correctly.
testTempDir := t.TempDir()
// Pick a random port to expose for testing port bindings.
testRandPort := testutil.RandomPortNoListen(t)
testRandHostPort, testRandContainerPort := testutil.RandomPortNoListen(t), testutil.RandomPortNoListen(t)
ct, err := pool.RunWithOptions(&dockertest.RunOptions{
Repository: "busybox",
Tag: "latest",
Expand All @@ -56,12 +57,12 @@ func TestIntegrationDocker(t *testing.T) {
"devcontainer.metadata": `[{"remoteEnv": {"FOO": "bar", "MULTILINE": "foo\nbar\nbaz"}}]`,
},
Mounts: []string{testTempDir + ":" + testTempDir},
ExposedPorts: []string{fmt.Sprintf("%d/tcp", testRandPort)},
ExposedPorts: []string{fmt.Sprintf("%d/tcp", testRandContainerPort)},
PortBindings: map[docker.Port][]docker.PortBinding{
docker.Port(fmt.Sprintf("%d/tcp", testRandPort)): {
docker.Port(fmt.Sprintf("%d/tcp", testRandContainerPort)): {
{
HostIP: "0.0.0.0",
HostPort: strconv.FormatInt(int64(testRandPort), 10),
HostPort: strconv.FormatInt(int64(testRandHostPort), 10),
},
},
},
Expand Down Expand Up @@ -99,7 +100,7 @@ func TestIntegrationDocker(t *testing.T) {
assert.True(t, foundContainer.Running)
assert.Equal(t, "running", foundContainer.Status)
if assert.Len(t, foundContainer.Ports, 1) {
assert.Equal(t, testRandPort, foundContainer.Ports[0].Port)
assert.Equal(t, testRandHostPort, foundContainer.Ports[0].Port)
assert.Equal(t, "tcp", foundContainer.Ports[0].Network)
}
if assert.Len(t, foundContainer.Volumes, 1) {
Expand Down Expand Up @@ -306,68 +307,138 @@ func TestContainersHandler(t *testing.T) {
})
}

func TestConvertDockerPort(t *testing.T) {
func TestDockerPortBinding(t *testing.T) {
t.Parallel()

for _, tc := range []struct {
testCases := []struct {
name string
in string
expectPort uint16
expectNetwork string
expectError string
networkPorts map[string][]dockerPortBinding
expectedPorts []codersdk.WorkspaceAgentListeningPort
expectedWarns int
}{
{
name: "empty port",
in: "",
expectError: "invalid port",
name: "nil",
networkPorts: nil,
expectedPorts: []codersdk.WorkspaceAgentListeningPort{},
},
{
name: "valid tcp port",
in: "8080/tcp",
expectPort: 8080,
expectNetwork: "tcp",
name: "simple port binding",
networkPorts: map[string][]dockerPortBinding{
"8080/tcp": {
{HostIP: "0.0.0.0", HostPort: "9090"},
},
},
expectedPorts: []codersdk.WorkspaceAgentListeningPort{
{Network: "tcp", Port: 9090},
},
},
{
name: "valid udp port",
in: "8080/udp",
expectPort: 8080,
expectNetwork: "udp",
name: "multiple port bindings",
networkPorts: map[string][]dockerPortBinding{
"8080/tcp": {
{HostIP: "0.0.0.0", HostPort: "9090"},
},
"8081/tcp": {
{HostIP: "0.0.0.0", HostPort: "9091"},
},
},
expectedPorts: []codersdk.WorkspaceAgentListeningPort{
{Network: "tcp", Port: 9090},
{Network: "tcp", Port: 9091},
},
},
{
name: "duplicate host ports on different interfaces",
networkPorts: map[string][]dockerPortBinding{
"8080/tcp": {
{HostIP: "0.0.0.0", HostPort: "9090"},
{HostIP: "127.0.0.1", HostPort: "9090"},
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
{HostIP: "127.0.0.1", HostPort: "9090"},
{HostIP: "127.0.0.1", HostPort: "9090"},
{HostIP: "::", HostPort: "9090"},

For completeness, let's include IPv6

},
},
expectedPorts: []codersdk.WorkspaceAgentListeningPort{
{Network: "tcp", Port: 9090},
},
},
{
name: "udp protocol",
networkPorts: map[string][]dockerPortBinding{
"8080/udp": {
{HostIP: "0.0.0.0", HostPort: "9090"},
},
},
expectedPorts: []codersdk.WorkspaceAgentListeningPort{
{Network: "udp", Port: 9090},
},
},
{
name: "valid port no network",
in: "8080",
expectPort: 8080,
expectNetwork: "tcp",
name: "no protocol defaults to tcp",
networkPorts: map[string][]dockerPortBinding{
"8080": {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Mentioned earlier, but is this valid output of docker inspect?

{HostIP: "0.0.0.0", HostPort: "9090"},
},
},
expectedPorts: []codersdk.WorkspaceAgentListeningPort{
{Network: "tcp", Port: 9090},
},
},
{
name: "invalid port",
in: "invalid/tcp",
expectError: "invalid port",
name: "no bindings should not create ports",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Can this actually happen? If not, perhaps we could mention it's only here for completeness?

networkPorts: map[string][]dockerPortBinding{
"8080/tcp": {},
},
expectedPorts: []codersdk.WorkspaceAgentListeningPort{},
},
{
name: "invalid port no network",
in: "invalid",
expectError: "invalid port",
name: "invalid host port",
networkPorts: map[string][]dockerPortBinding{
"8080/tcp": {
{HostIP: "0.0.0.0", HostPort: "invalid"},
},
},
expectedPorts: []codersdk.WorkspaceAgentListeningPort{},
expectedWarns: 1,
},
{
name: "multiple network",
in: "8080/tcp/udp",
expectError: "invalid port",
name: "mix of valid and invalid ports",
networkPorts: map[string][]dockerPortBinding{
"8080/tcp": {
{HostIP: "0.0.0.0", HostPort: "9090"},
},
"8081/tcp": {
{HostIP: "0.0.0.0", HostPort: "invalid"},
},
},
expectedPorts: []codersdk.WorkspaceAgentListeningPort{
{Network: "tcp", Port: 9090},
},
expectedWarns: 1,
},
} {
tc := tc // not needed anymore but makes the linter happy
}

for _, tc := range testCases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
actualPort, actualNetwork, actualErr := convertDockerPort(tc.in)
if tc.expectError != "" {
assert.Zero(t, actualPort, "expected no port")
assert.Empty(t, actualNetwork, "expected no network")
assert.ErrorContains(t, actualErr, tc.expectError)
} else {
assert.NoError(t, actualErr, "expected no error")
assert.Equal(t, tc.expectPort, actualPort, "expected port to match")
assert.Equal(t, tc.expectNetwork, actualNetwork, "expected network to match")

dockerData := dockerInspect{
ID: "test-container",
Created: time.Now(),
Config: dockerInspectConfig{
Image: "test-image",
Labels: map[string]string{"test": "value"},
},
Name: "test-container",
State: dockerInspectState{Running: true},
NetworkSettings: dockerNetworkSettings{
Ports: tc.networkPorts,
},
}

container, warns := convertDockerInspect(dockerData)
if diff := cmp.Diff(tc.expectedPorts, container.Ports); diff != "" {
assert.Failf(t, "port mismatch", "(-want +got):\n%s", diff)
}
assert.Len(t, warns, tc.expectedWarns, "wrong number of warnings")
})
}
}
Expand Down
Loading