diff --git a/e2e/tests/up-docker-compose/up_docker_compose.go b/e2e/tests/up-docker-compose/up_docker_compose.go index 7a98f8f59..171e0c407 100644 --- a/e2e/tests/up-docker-compose/up_docker_compose.go +++ b/e2e/tests/up-docker-compose/up_docker_compose.go @@ -7,6 +7,7 @@ import ( "context" "fmt" "io" + "net" "net/http" "os" "os/exec" @@ -160,6 +161,77 @@ var _ = ginkgo.Describe( )) }, ginkgo.SpecTimeout(framework.TimeoutLong())) + ginkgo.It("ssh forward ports support remote service names", func(ctx context.Context) { + _, workspace, err := tc.setupAndStartWorkspace( + ctx, + "tests/up-docker-compose/testdata/docker-compose-forward-ports", + "--debug", + ) + framework.ExpectNoError(err) + + ids, err := findComposeContainer( + ctx, + tc.dockerHelper, + tc.composeHelper, + workspace.UID, + "app", + ) + framework.ExpectNoError(err) + gomega.Expect(ids).To(gomega.HaveLen(1), "1 compose container to be created") + + listener, err := net.Listen("tcp", "127.0.0.1:0") + framework.ExpectNoError(err) + localPort := listener.Addr().(*net.TCPAddr).Port + framework.ExpectNoError(listener.Close()) + + done := make(chan error) + sshContext, sshCancel := context.WithCancel(context.Background()) + go func() { + // #nosec G204 -- test command with controlled arguments + cmd := exec.CommandContext( + sshContext, + filepath.Join(tc.f.DevsyBinDir, tc.f.DevsyBinName), + "ssh", + "--forward-ports", + fmt.Sprintf("%d:nginx:8080", localPort), + workspace.ID, + ) + + if err := cmd.Start(); err != nil { + done <- err + return + } + + if err := cmd.Wait(); err != nil { + done <- err + return + } + + done <- nil + }() + + gomega.Eventually(func(g gomega.Gomega) { + response, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d", localPort)) + g.Expect(err).NotTo(gomega.HaveOccurred()) + defer func() { _ = response.Body.Close() }() + + body, err := io.ReadAll(response.Body) + g.Expect(err).NotTo(gomega.HaveOccurred()) + g.Expect(body).To(gomega.ContainSubstring("Thank you for using nginx.")) + }). + WithPolling(1 * time.Second). + WithTimeout(20 * time.Second). + Should(gomega.Succeed()) + + sshCancel() + err = <-done + + gomega.Expect(err).To(gomega.Or( + gomega.MatchError("signal: killed"), + gomega.MatchError(context.Canceled), + )) + }, ginkgo.SpecTimeout(framework.TimeoutLong())) + ginkgo.It("features", func(ctx context.Context) { tempDir, workspace, err := tc.setupAndStartWorkspace( ctx, diff --git a/pkg/port/parse.go b/pkg/port/parse.go index 3e44f84ae..bccd840de 100644 --- a/pkg/port/parse.go +++ b/pkg/port/parse.go @@ -2,11 +2,12 @@ package port import ( "fmt" - "net" "strconv" "strings" ) +const defaultHost = "localhost" + type Address struct { Protocol string Address string @@ -39,26 +40,21 @@ func ParsePortSpec(port string) (Mapping, error) { }, nil } -func toAddress(ip, port string) (Address, error) { - // check if port is integer +func toAddress(host, port string) (Address, error) { _, err := strconv.Atoi(port) if err == nil { - if ip == "" { - ip = "localhost" - } - - if ip != "localhost" && net.ParseIP(ip) == nil { - return Address{}, fmt.Errorf("not an ip address %s", ip) + if host == "" { + host = defaultHost } return Address{ Protocol: "tcp", - Address: ip + ":" + port, + Address: host + ":" + port, }, nil } - if ip != "" { - return Address{}, fmt.Errorf("unexpected ip address for unix socket: %s", ip) + if host != "" { + return Address{}, fmt.Errorf("unexpected host for unix socket: %s", host) } return Address{ @@ -78,7 +74,10 @@ func splitParts(rawport string) (string, string, string, string, error) { case 2: return "", parts[0], "", containerport, nil case 3: - if parts[1] == "localhost" || net.ParseIP(parts[1]) != nil { + // a:b:c — if middle token is non-numeric, it's a host/IP for the + // container side: hostPort:containerHost:containerPort. + // Otherwise it's hostHost:hostPort:containerPort. + if _, err := strconv.Atoi(parts[1]); err != nil { return "", parts[0], parts[1], containerport, nil } diff --git a/pkg/port/parse_test.go b/pkg/port/parse_test.go new file mode 100644 index 000000000..44900e04f --- /dev/null +++ b/pkg/port/parse_test.go @@ -0,0 +1,236 @@ +package port + +import ( + "testing" +) + +const ( + protoTCP = "tcp" + protoUnix = "unix" + + addrLocalhost8080 = "localhost:8080" + addrLocalhost3000 = "localhost:3000" + testUnixSocket = "/var/run/app.sock" +) + +func TestParsePortSpec_BasicPorts(t *testing.T) { + tests := []struct { + name string + input string + want Mapping + }{ + { + name: "bare port", + input: "8080", + want: Mapping{ + Host: Address{Protocol: protoTCP, Address: addrLocalhost8080}, + Container: Address{Protocol: protoTCP, Address: addrLocalhost8080}, + }, + }, + { + name: "host port to container port", + input: "8080:3000", + want: Mapping{ + Host: Address{Protocol: protoTCP, Address: addrLocalhost8080}, + Container: Address{Protocol: protoTCP, Address: addrLocalhost3000}, + }, + }, + { + name: "unix socket", + input: testUnixSocket, + want: Mapping{ + Host: Address{Protocol: protoUnix, Address: testUnixSocket}, + Container: Address{Protocol: protoUnix, Address: testUnixSocket}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParsePortSpec(tt.input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tt.want { + t.Errorf("got %+v, want %+v", got, tt.want) + } + }) + } +} + +func TestParsePortSpec_Hostnames(t *testing.T) { + tests := []struct { + name string + input string + want Mapping + }{ + { + name: "IP host three-part", + input: "127.0.0.1:8080:3000", + want: Mapping{ + Host: Address{Protocol: protoTCP, Address: "127.0.0.1:8080"}, + Container: Address{Protocol: protoTCP, Address: addrLocalhost3000}, + }, + }, + { + name: "localhost explicit three-part", + input: "localhost:8080:3000", + want: Mapping{ + Host: Address{Protocol: protoTCP, Address: addrLocalhost8080}, + Container: Address{Protocol: protoTCP, Address: addrLocalhost3000}, + }, + }, + { + name: "hostname host", + input: "database.internal:5432:5432", + want: Mapping{ + Host: Address{Protocol: protoTCP, Address: "database.internal:5432"}, + Container: Address{Protocol: protoTCP, Address: "localhost:5432"}, + }, + }, + { + name: "container hostname in three-part spec", + input: "8080:redis:6379", + want: Mapping{ + Host: Address{Protocol: protoTCP, Address: addrLocalhost8080}, + Container: Address{Protocol: protoTCP, Address: "redis:6379"}, + }, + }, + { + name: "four-part full spec with hostnames", + input: "myhost:8080:mycontainer:3000", + want: Mapping{ + Host: Address{Protocol: protoTCP, Address: "myhost:8080"}, + Container: Address{Protocol: protoTCP, Address: "mycontainer:3000"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParsePortSpec(tt.input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tt.want { + t.Errorf("got %+v, want %+v", got, tt.want) + } + }) + } +} + +func TestParsePortSpec_Errors(t *testing.T) { + _, err := ParsePortSpec("a:b:c:d:e") + if err == nil { + t.Fatal("expected error for too many parts") + } +} + +func TestToAddress_TCP(t *testing.T) { + tests := []struct { + name string + host string + port string + want Address + }{ + { + "empty host defaults to localhost", + "", + "8080", + Address{Protocol: protoTCP, Address: addrLocalhost8080}, + }, + {defaultHost, defaultHost, "3000", Address{Protocol: protoTCP, Address: addrLocalhost3000}}, + { + "IP address", "192.168.1.1", "443", + Address{Protocol: protoTCP, Address: "192.168.1.1:443"}, + }, + { + "hostname", + "database.internal", + "5432", + Address{Protocol: protoTCP, Address: "database.internal:5432"}, + }, + {"short hostname", "db", "5432", Address{Protocol: protoTCP, Address: "db:5432"}}, + {"IPv6 address", "::1", "8080", Address{Protocol: protoTCP, Address: "::1:8080"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := toAddress(tt.host, tt.port) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tt.want { + t.Errorf("got %+v, want %+v", got, tt.want) + } + }) + } +} + +func TestToAddress_Unix(t *testing.T) { + got, err := toAddress("", testUnixSocket) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := Address{Protocol: protoUnix, Address: testUnixSocket} + if got != want { + t.Errorf("got %+v, want %+v", got, want) + } +} + +func TestToAddress_HostWithUnixErrors(t *testing.T) { + _, err := toAddress("myhost", testUnixSocket) + if err == nil { + t.Fatal("expected error for host with unix socket") + } +} + +func TestSplitParts(t *testing.T) { + type result struct{ hostIP, hostPort, contIP, contPort string } + + tests := []struct { + name string + input string + want result + }{ + {"single port", "8080", result{"", "8080", "", "8080"}}, + {"two parts", "8080:3000", result{"", "8080", "", "3000"}}, + {"three parts numeric middle", "myhost:8080:3000", result{"myhost", "8080", "", "3000"}}, + {"three parts hostname middle", "8080:redis:6379", result{"", "8080", "redis", "6379"}}, + { + "three parts IP middle", + "8080:192.168.1.1:3000", + result{"", "8080", "192.168.1.1", "3000"}, + }, + { + "three parts localhost middle", + "8080:localhost:3000", + result{"", "8080", defaultHost, "3000"}, + }, + { + "four parts", + "myhost:8080:mycontainer:3000", + result{"myhost", "8080", "mycontainer", "3000"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + hostIP, hostPort, contIP, contPort, err := splitParts(tt.input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + got := result{hostIP, hostPort, contIP, contPort} + if got != tt.want { + t.Errorf("got %+v, want %+v", got, tt.want) + } + }) + } +} + +func TestSplitParts_Errors(t *testing.T) { + _, _, _, _, err := splitParts("a:b:c:d:e") + if err == nil { + t.Fatal("expected error for five parts") + } +}