Repository: testcontainers/testcontainers-go (Go)

Issue:
[Bug]: HealthStrategy tries to dereference a null Health pointer
### Testcontainers version

0.17.0

### Using the latest Testcontainers version?

Yes

### Host OS

Ubuntu 22.04

### Host arch

amd64

### Go version

1.19

### Docker version

```shell
Client: Docker Engine - Community
 Version:           23.0.0
 API version:       1.42
 Go version:        go1.19.5
 Git commit:        e92dd87
 Built:             Wed Feb  1 17:47:51 2023
 OS/Arch:           linux/amd64
 Context:           default

Server: Docker Engine - Community
 Engine:
  Version:          23.0.0
  API version:      1.42 (minimum version 1.12)
  Go version:       go1.19.5
  Git commit:       d7573ab
  Built:            Wed Feb  1 17:47:51 2023
  OS/Arch:          linux/amd64
  Experimental:     false
 containerd:
  Version:          1.6.16
  GitCommit:        31aa4358a36870b21a992d3ad2bef29e1d693bec
 runc:
  Version:          1.1.4
  GitCommit:        v1.1.4-0-g5fd4c4d
 docker-init:
  Version:          0.19.0
  GitCommit:        de40ad0
```


### Docker info

```shell
Client:
 Context:    default
 Debug Mode: false
 Plugins:
  buildx: Docker Buildx (Docker Inc.)
    Version:  v0.10.2
    Path:     /usr/libexec/docker/cli-plugins/docker-buildx
  compose: Docker Compose (Docker Inc.)
    Version:  v2.15.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: 5
  Running: 2
  Paused: 0
  Stopped: 3
 Images: 578
 Server Version: 23.0.0
 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: systemd
 Cgroup Version: 2
 Plugins:
  Volume: local
  Network: bridge host ipvlan macvlan null overlay
  Log: awslogs fluentd gcplogs gelf journald json-file local logentries splunk syslog
 Swarm: inactive
 Runtimes: io.containerd.runc.v2 runc
 Default Runtime: runc
 Init Binary: docker-init
 containerd version: 31aa4358a36870b21a992d3ad2bef29e1d693bec
 runc version: v1.1.4-0-g5fd4c4d
 init version: de40ad0
 Security Options:
  apparmor
  seccomp
   Profile: builtin
  cgroupns
 Kernel Version: 5.15.0-58-generic
 Operating System: Ubuntu 22.04.1 LTS
 OSType: linux
 Architecture: x86_64
 CPUs: 12
 Total Memory: 31.29GiB
 Name: gondor
 ID: DVVB:WRMF:MXNZ:2HZ3:JYZC:BQSD:6E7X:RY7E:IHRI:SV3A:ERUG:KTPF
 Docker Root Dir: /var/lib/docker
 Debug Mode: false
 Username: massenz
 Registry: https://index.docker.io/v1/
 Experimental: false
 Insecure Registries:
  192.168.99.101:30500
  127.0.0.0/8
 Live Restore Enabled: false
```


### What happened?

I used the code exactly as in the docs (and other variations too) and the tests failed with a panic (see log):

```
	req := testcontainers.ContainerRequest{
		Image:        OpaImage,
		ExposedPorts: []string{OpaPort},
		WaitingFor: wait.ForHealthCheck().
			WithPollInterval(1 * time.Second),
	}
```

### Relevant log output

```shell
└─( ko ./gentests/internals

Running Suite: Internals Suite
==============================
Random Seed: 1675555046
Will run 1 of 1 specs

2023/02/04 15:57:27 github.com/testcontainers/testcontainers-go - Connected to docker: 
  Server Version: 23.0.0
  API Version: 1.42
  Operating System: Ubuntu 22.04.1 LTS
  Total Memory: 32045 MB
2023/02/04 15:57:27 Starting container id: 286d87d82a44 image: docker.io/testcontainers/ryuk:0.3.4
2023/02/04 15:57:27 Waiting for container id 286d87d82a44 image: docker.io/testcontainers/ryuk:0.3.4
2023/02/04 15:57:28 Container is ready id: 286d87d82a44 image: docker.io/testcontainers/ryuk:0.3.4
2023/02/04 15:57:28 Starting container id: f6de79a5282d image: openpolicyagent/opa:0.48.0
2023/02/04 15:57:28 Waiting for container id f6de79a5282d image: openpolicyagent/opa:0.48.0
•! Panic [1.011 seconds]
Gentests/Internals/Server
/home/marco/Development/CopilotIQ/opa-tests/gentests/internals/server_test.go:10
  when the tests start
  /home/marco/Development/CopilotIQ/opa-tests/gentests/internals/server_test.go:12
    should have a running OPA server [It]
    /home/marco/Development/CopilotIQ/opa-tests/gentests/internals/server_test.go:13

    Test Panicked
    runtime error: invalid memory address or nil pointer dereference
    /opt/go/src/runtime/panic.go:260

    Full Stack Trace
    github.com/testcontainers/testcontainers-go/wait.(*HealthStrategy).WaitUntilReady(0xc000120000, {0xb72898?, 0xc00002e110?}, {0xb73d60, 0xc000000320})
    	/home/marco/.local/go/pkg/mod/github.com/testcontainers/testcontainers-go@v0.17.0/wait/health.go:79 +0xd9
    github.com/testcontainers/testcontainers-go.(*DockerContainer).Start(0xc000000320, {0xb72898, 0xc00002e110})
    	/home/marco/.local/go/pkg/mod/github.com/testcontainers/testcontainers-go@v0.17.0/docker.go:198 +0x24f
    github.com/testcontainers/testcontainers-go.GenericContainer({_, _}, {{{{0x0, 0x0}, {0x0, 0x0}, {0x0, 0x0}, 0x0, 0x0, ...}, ...}, ...})
    	/home/marco/.local/go/pkg/mod/github.com/testcontainers/testcontainers-go@v0.17.0/generic.go:75 +0x3d4
    github.com/CopilotIQ/opa-tests/gentests/internals.NewOpaContainer({0xb72898, 0xc00002e110}, {0xa87143, 0xc})
    	/home/marco/Development/CopilotIQ/opa-tests/gentests/internals/server.go:36 +0x1ae
    github.com/CopilotIQ/opa-tests/gentests/internals_test.glob..func1.1.1()
    	/home/marco/Development/CopilotIQ/opa-tests/gentests/internals/server_test.go:14 +0x45
    github.com/onsi/ginkgo/internal/leafnodes.(*runner).runSync(0xc00003c4e0?)
    	/home/marco/.local/go/pkg/mod/github.com/onsi/ginkgo@v1.12.1/internal/leafnodes/runner.go:113 +0xb1
    github.com/onsi/ginkgo/internal/leafnodes.(*runner).run(0x417610?)
    	/home/marco/.local/go/pkg/mod/github.com/onsi/ginkgo@v1.12.1/internal/leafnodes/runner.go:64 +0x125
    github.com/onsi/ginkgo/internal/leafnodes.(*ItNode).Run(0x400?)
    	/home/marco/.local/go/pkg/mod/github.com/onsi/ginkgo@v1.12.1/internal/leafnodes/it_node.go:26 +0x7b
    github.com/onsi/ginkgo/internal/spec.(*Spec).runSample(0xc0003ac000, 0xc000071af0?, {0xb6d600, 0xc000300ec0})
    	/home/marco/.local/go/pkg/mod/github.com/onsi/ginkgo@v1.12.1/internal/spec/spec.go:215 +0x2a9
    github.com/onsi/ginkgo/internal/spec.(*Spec).Run(0xc0003ac000, {0xb6d600, 0xc000300ec0})
    	/home/marco/.local/go/pkg/mod/github.com/onsi/ginkgo@v1.12.1/internal/spec/spec.go:138 +0xf2
    github.com/onsi/ginkgo/internal/specrunner.(*SpecRunner).runSpec(0xc000360780, 0xc0003ac000)
    	/home/marco/.local/go/pkg/mod/github.com/onsi/ginkgo@v1.12.1/internal/specrunner/spec_runner.go:200 +0xf1
    github.com/onsi/ginkgo/internal/specrunner.(*SpecRunner).runSpecs(0xc000360780)
    	/home/marco/.local/go/pkg/mod/github.com/onsi/ginkgo@v1.12.1/internal/specrunner/spec_runner.go:170 +0x1b6
    github.com/onsi/ginkgo/internal/specrunner.(*SpecRunner).Run(0xc000360780)
    	/home/marco/.local/go/pkg/mod/github.com/onsi/ginkgo@v1.12.1/internal/specrunner/spec_runner.go:66 +0xc5
    github.com/onsi/ginkgo/internal/suite.(*Suite).Run(0xc0002f6690, {0x7f263aa33ca0, 0xc0000d6d00}, {0xa896b1, 0xf}, {0xc00035e8f0, 0x1, 0x1}, {0xb73360, 0xc000300ec0}, ...)
    	/home/marco/.local/go/pkg/mod/github.com/onsi/ginkgo@v1.12.1/internal/suite/suite.go:62 +0x4c5
    github.com/onsi/ginkgo.RunSpecsWithCustomReporters({0xb6e2a0?, 0xc0000d6d00}, {0xa896b1, 0xf}, {0xc000060718, 0x1, 0xc000060728?})
    	/home/marco/.local/go/pkg/mod/github.com/onsi/ginkgo@v1.12.1/ginkgo_dsl.go:226 +0x189
    github.com/onsi/ginkgo.RunSpecs({0xb6e2a0, 0xc0000d6d00}, {0xa896b1, 0xf})
    	/home/marco/.local/go/pkg/mod/github.com/onsi/ginkgo@v1.12.1/ginkgo_dsl.go:207 +0x10b
    github.com/CopilotIQ/opa-tests/gentests/internals_test.TestInternals(0x0?)
    	/home/marco/Development/CopilotIQ/opa-tests/gentests/internals/internals_suite_test.go:12 +0x85
    testing.tRunner(0xc0000d6d00, 0xac79e0)
    	/opt/go/src/testing/testing.go:1446 +0x10b
    created by testing.(*T).Run
    	/opt/go/src/testing/testing.go:1493 +0x35f
------------------------------


Summarizing 1 Failure:

[Panic!] Gentests/Internals/Server when the tests start [It] should have a running OPA server 
/opt/go/src/runtime/panic.go:260

Ran 1 of 1 Specs in 1.011 seconds
FAIL! -- 0 Passed | 1 Failed | 0 Pending | 0 Skipped
--- FAIL: TestInternals (1.01s)
FAIL

Ginkgo ran 1 suite in 2.027766693s
Test Suite Failed
```


### Additional information

This is caused by [this line](https://github.com/testcontainers/testcontainers-go/blob/a201e1d72d529a4d252e38702fc1718ba761cd16/wait/health.go#L79):

```
if state.Health.Status != "healthy" {
```
due to `Health` being `nil` (at least, initially) - however, the following should work (I've rewritten the strategy and tested to work, but it's been a very limited testing on just that one container):

```
if state.Health == nil || state.Health.Status != "healthy" {
```

Whether this is "intended" I am not sure (but it achieves what I want, to wait for the container to be ready).

In any event, as a matter of best practice, before dereferencing "two levels deep" it would be better to defend against `nil` values.


Discussion:
Hi @massenz thanks for opening this issue. I think you are right, that double dereference should be checked.

Would you mind sending a patch for it, hopefully adding a test for a nil health check? I can do it my self, but later this week 🙏 

Thanks!
Of course, I'd love to!
❤️ TestContainers, would be really nice to be a (tiny, tiny) contributor 😄 

Probably later in the day, or tomorrow at the latest.
Thank you so much, @mdelapenya for jumping so quickly on this one and helping out.
As I mentioned, I :heart:  Testcontainers, and it makes me so happy to have made a tiny, tiny contribution! :tada: 

If there is any issue that you think you'd like me take a look at, I'd be happy to continue contributing
:pray: 
My pleasure @massenz!

Feel free to visit the issues, but I'm specially proud of the new example modules system. Would you like to contribute an example module for the technology you use the most? You can find docs here: https://golang.testcontainers.org/examples/ and a list of target modules here: https://github.com/testcontainers/testcontainers-go/issues/636

In any other case, contributions are always welcomed, be it in code, in docs, in tests... 🙏 
Ah, last one tiny thing: it's `Testcontainers`, no camel-case 🤫 



Patch:
diff --git a/wait/health.go b/wait/health.go
index 00130d8485..4712444fdc 100644
--- a/wait/health.go
+++ b/wait/health.go
@@ -2,6 +2,7 @@ package wait
 
 import (
 	"context"
+	"github.com/docker/docker/api/types"
 	"time"
 )
 
@@ -76,7 +77,7 @@ func (ws *HealthStrategy) WaitUntilReady(ctx context.Context, target StrategyTar
 			if err != nil {
 				return err
 			}
-			if state.Health.Status != "healthy" {
+			if state.Health == nil || state.Health.Status != types.Healthy {
 				time.Sleep(ws.PollInterval)
 				continue
 			}


Test patch:
diff --git a/wait/health_test.go b/wait/health_test.go
new file mode 100644
index 0000000000..689db82de6
--- /dev/null
+++ b/wait/health_test.go
@@ -0,0 +1,93 @@
+package wait
+
+import (
+	"context"
+	"errors"
+	"github.com/stretchr/testify/assert"
+	"io"
+	"testing"
+	"time"
+
+	"github.com/docker/docker/api/types"
+	"github.com/docker/go-connections/nat"
+	tcexec "github.com/testcontainers/testcontainers-go/exec"
+)
+
+type healthStrategyTarget struct {
+	Health *types.Health
+}
+
+func (st healthStrategyTarget) Host(ctx context.Context) (string, error) {
+	return "", nil
+}
+
+func (st healthStrategyTarget) Ports(ctx context.Context) (nat.PortMap, error) {
+	return nil, nil
+}
+
+func (st healthStrategyTarget) MappedPort(ctx context.Context, n nat.Port) (nat.Port, error) {
+	return n, nil
+}
+
+func (st healthStrategyTarget) Logs(ctx context.Context) (io.ReadCloser, error) {
+	return nil, nil
+}
+
+func (st healthStrategyTarget) Exec(ctx context.Context, cmd []string, options ...tcexec.ProcessOption) (int, io.Reader, error) {
+	return 0, nil, nil
+}
+
+func (st healthStrategyTarget) State(ctx context.Context) (*types.ContainerState, error) {
+	return &types.ContainerState{Health: st.Health}, nil
+}
+
+// TestWaitForHealthTimesOutForUnhealthy confirms that an unhealthy container will eventually
+// time out.
+func TestWaitForHealthTimesOutForUnhealthy(t *testing.T) {
+	target := healthStrategyTarget{Health: &types.Health{Status: types.Unhealthy}}
+	wg := NewHealthStrategy().WithStartupTimeout(100 * time.Millisecond)
+	err := wg.WaitUntilReady(context.Background(), target)
+
+	assert.NotNil(t, err)
+	assert.True(t, errors.Is(err, context.DeadlineExceeded))
+}
+
+// TestWaitForHealthSucceeds ensures that a healthy container always succeeds.
+func TestWaitForHealthSucceeds(t *testing.T) {
+	target := healthStrategyTarget{Health: &types.Health{Status: types.Healthy}}
+	wg := NewHealthStrategy().WithStartupTimeout(100 * time.Millisecond)
+	err := wg.WaitUntilReady(context.Background(), target)
+
+	assert.Nil(t, err)
+}
+
+// TestWaitForHealthWithNil checks that an initial `nil` Health will not casue a panic,
+// and if the container eventually becomes healthy, the HealthStrategy will succeed.
+func TestWaitForHealthWithNil(t *testing.T) {
+	target := &healthStrategyTarget{Health: nil}
+	wg := NewHealthStrategy().
+		WithStartupTimeout(500 * time.Millisecond).
+		WithPollInterval(100 * time.Millisecond)
+
+	go func(target *healthStrategyTarget) {
+		// wait a bit to simulate startup time and give check time to at least
+		// try a few times with a nil Health
+		time.Sleep(200 * time.Millisecond)
+		target.Health = &types.Health{Status: types.Healthy}
+	}(target)
+
+	err := wg.WaitUntilReady(context.Background(), target)
+	assert.Nil(t, err)
+}
+
+// TestWaitFailsForNilHealth checks that Health always nil fails (but will NOT cause a panic)
+func TestWaitFailsForNilHealth(t *testing.T) {
+	target := &healthStrategyTarget{Health: nil}
+	wg := NewHealthStrategy().
+		WithStartupTimeout(500 * time.Millisecond).
+		WithPollInterval(100 * time.Millisecond)
+
+	err := wg.WaitUntilReady(context.Background(), target)
+	assert.NotNil(t, err)
+	assert.True(t, errors.Is(err, context.DeadlineExceeded))
+}