Repository: testcontainers/testcontainers-go (Go)

Issue:
[Bug]: Explicitely mapped ports break default readiness hook
### Testcontainers version

0.32.0

### Using the latest Testcontainers version?

Yes

### Host OS

Linux (WSL)

### Host arch

x86_64

### Go version

1.22.3

### Docker version

```shell
Client: Docker Engine - Community
 Version:           26.0.1
 API version:       1.45
 Go version:        go1.21.9
 Git commit:        d260a54
 Built:             Thu Apr 11 10:53:21 2024
 OS/Arch:           linux/amd64
 Context:           default

Server: Docker Engine - Community
 Engine:
  Version:          26.0.1
  API version:      1.45 (minimum version 1.24)
  Go version:       go1.21.9
  Git commit:       60b9add
  Built:            Thu Apr 11 10:53:21 2024
  OS/Arch:          linux/amd64
  Experimental:     false
 containerd:
  Version:          1.6.31
  GitCommit:        e377cd56a71523140ca6ae87e30244719194a521
 runc:
  Version:          1.1.12
  GitCommit:        v1.1.12-0-g51d5e94
 docker-init:
  Version:          0.19.0
  GitCommit:        de40ad0
```


### Docker info

```shell
Client: Docker Engine - Community
 Version:    26.0.1
 Context:    default
 Debug Mode: false
 Plugins:
  buildx: Docker Buildx (Docker Inc.)
    Version:  v0.13.1
    Path:     /usr/libexec/docker/cli-plugins/docker-buildx
  compose: Docker Compose (Docker Inc.)
    Version:  v2.26.1
    Path:     /usr/libexec/docker/cli-plugins/docker-compose
  scan: Docker Scan (Docker Inc.)
    Version:  v0.23.0
    Path:     /usr/libexec/docker/cli-plugins/docker-scan

Server:
 Containers: 7
  Running: 1
  Paused: 0
  Stopped: 6
 Images: 32
 Server Version: 26.0.1
 Storage Driver: overlay2
  Backing Filesystem: extfs
  Supports d_type: true
  Using metacopy: false
  Native Overlay Diff: true
  userxattr: false
 Logging Driver: json-file
 Cgroup Driver: cgroupfs
 Cgroup Version: 1
 Plugins:
  Volume: local
  Network: bridge host ipvlan macvlan null overlay
  Log: awslogs fluentd gcplogs gelf journald json-file local splunk syslog
 Swarm: inactive
 Runtimes: runc io.containerd.runc.v2
 Default Runtime: runc
 Init Binary: docker-init
 containerd version: e377cd56a71523140ca6ae87e30244719194a521
 runc version: v1.1.12-0-g51d5e94
 init version: de40ad0
 Security Options:
  seccomp
   Profile: builtin
 Kernel Version: 5.15.153.1-microsoft-standard-WSL2
 Operating System: Ubuntu 22.04.4 LTS
 OSType: linux
 Architecture: x86_64
 CPUs: 6
 Total Memory: 15.62GiB
 Name: VD011420
 ID: 5f7f091e-64fd-40bb-bfdf-ad87e9ae09ec
 Docker Root Dir: /var/lib/docker
 Debug Mode: false
 Username: robinvanderstraeten352
 Experimental: false
 Insecure Registries:
  127.0.0.0/8
 Live Restore Enabled: false

WARNING: No blkio throttle.read_bps_device support
WARNING: No blkio throttle.write_bps_device support
WARNING: No blkio throttle.read_iops_device support
WARNING: No blkio throttle.write_iops_device support
```


### What happened?

Since version 0.32.0 of testcontainers, creating a container with an explicit port mapping in `ExposedPorts` (for instance `"4443:4443/tcp"`) no longer works. The container starts correctly, but a newly introduced readiness check fails. It incorrectly parses the input given in `ExposedPorts` and concludes that the container did not start correctly. For context, I'm talking about the logic [here](https://github.com/testcontainers/testcontainers-go/blob/v0.32.0/lifecycle.go#L225-L244).
I'm aware that testcontainers creates a port mapping for you, but sometimes you need to know the port before the container starts. Here's an example snippet:
```go
      var port = "4443/tcp"
      var hostPort = "4443"
      req := testcontainers.GenericContainerRequest{
          ContainerRequest: testcontainers.ContainerRequest{
              Image:        "fsouza/fake-gcs-server",
              ExposedPorts: []string{fmt.Sprintf("%s:%s", hostPort, port)},
              Cmd:          []string{"-scheme", "http", "-public-host", fmt.Sprintf("localhost:%s", hostPort)},
              WaitingFor:   wait.ForHTTP("/storage/v1/b").WithPort(nat.Port(port)),
          },
          Started: true,
      }
```


### Relevant log output

```shell
failed to start container: all exposed ports, [4443:4443/tcp], were not mapped in 5s: port 4443:4443/tcp is not mapped yet
```


### Additional information

I have made a fork with a suggestion for a fix. https://github.com/robinvanderstraeten-klarrio/testcontainers-go/commit/193ab0683acd1372a0d9e071c847b21c3b4ce299

If this is the right approach I'll submit a PR.



Fix (patch):
diff --git a/lifecycle.go b/lifecycle.go
index a40ebc0fac..a0c6b99b88 100644
--- a/lifecycle.go
+++ b/lifecycle.go
@@ -198,6 +198,34 @@ var defaultLogConsumersHook = func(cfg *LogConsumerConfig) ContainerLifecycleHoo
 	}
 }
 
+func checkPortsMapped(exposedAndMappedPorts nat.PortMap, exposedPorts []string) error {
+	portMap, _, err := nat.ParsePortSpecs(exposedPorts)
+	if err != nil {
+		return fmt.Errorf("parse exposed ports: %w", err)
+	}
+
+	for exposedPort := range portMap {
+		// having entries in exposedAndMappedPorts, where the key is the exposed port,
+		// and the value is the mapped port, means that the port has been already mapped.
+		if _, ok := exposedAndMappedPorts[exposedPort]; ok {
+			continue
+		}
+
+		// check if the port is mapped with the protocol (default is TCP)
+		if strings.Contains(string(exposedPort), "/") {
+			return fmt.Errorf("port %s is not mapped yet", exposedPort)
+		}
+
+		// Port didn't have a type, default to tcp and retry.
+		exposedPort += "/tcp"
+		if _, ok := exposedAndMappedPorts[exposedPort]; !ok {
+			return fmt.Errorf("port %s is not mapped yet", exposedPort)
+		}
+	}
+
+	return nil
+}
+
 // defaultReadinessHook is a hook that will wait for the container to be ready
 var defaultReadinessHook = func() ContainerLifecycleHooks {
 	return ContainerLifecycleHooks{
@@ -222,26 +250,7 @@ var defaultReadinessHook = func() ContainerLifecycleHooks {
 							return err
 						}
 
-						exposedAndMappedPorts := jsonRaw.NetworkSettings.Ports
-
-						for _, exposedPort := range dockerContainer.exposedPorts {
-							portMap := nat.Port(exposedPort)
-							// having entries in exposedAndMappedPorts, where the key is the exposed port,
-							// and the value is the mapped port, means that the port has been already mapped.
-							if _, ok := exposedAndMappedPorts[portMap]; !ok {
-								// check if the port is mapped with the protocol (default is TCP)
-								if !strings.Contains(exposedPort, "/") {
-									portMap = nat.Port(fmt.Sprintf("%s/tcp", exposedPort))
-									if _, ok := exposedAndMappedPorts[portMap]; !ok {
-										return fmt.Errorf("port %s is not mapped yet", exposedPort)
-									}
-								} else {
-									return fmt.Errorf("port %s is not mapped yet", exposedPort)
-								}
-							}
-						}
-
-						return nil
+						return checkPortsMapped(jsonRaw.NetworkSettings.Ports, dockerContainer.exposedPorts)
 					},
 					b,
 					func(err error, duration time.Duration) {