-
Notifications
You must be signed in to change notification settings - Fork 887
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
|
@@ -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 { | ||||||
|
@@ -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 { | ||||||
|
@@ -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 | ||||||
} | ||||||
|
||||||
// Parse the containerPortSpec (e.g., "8080/tcp") | ||||||
parts := strings.Split(containerPortSpec, "/") | ||||||
|
||||||
// Get the network protocol | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
Nit: Comment why not what. What is inferable from the code. |
||||||
network := "tcp" // Default to tcp | ||||||
if len(parts) == 2 { | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Since this is output from Alt: why did we get rid of the |
||||||
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 | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
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 | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
seenHostPorts[binding.HostPort] = true | ||||||
} | ||||||
} | ||||||
|
||||||
|
@@ -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) | ||||||
} | ||||||
} |
Original file line number | Diff line number | Diff line change | ||||||
---|---|---|---|---|---|---|---|---|
|
@@ -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" | ||||||||
|
@@ -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", | ||||||||
|
@@ -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), | ||||||||
}, | ||||||||
}, | ||||||||
}, | ||||||||
|
@@ -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) { | ||||||||
|
@@ -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"}, | ||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
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": { | ||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: Mentioned earlier, but is this valid output of |
||||||||
{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", | ||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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") | ||||||||
}) | ||||||||
} | ||||||||
} | ||||||||
|
There was a problem hiding this comment.
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.