Skip to content

Commit 4dc3662

Browse files
authored
fix(elasticsearch): wait for (testcontainers#2724)
Wait for the HTTP port to be available to prevent random failures when the container isn't fully started and returns 503 errors.
1 parent 1bf8e2b commit 4dc3662

File tree

2 files changed

+89
-67
lines changed

2 files changed

+89
-67
lines changed

modules/elasticsearch/elasticsearch.go

Lines changed: 89 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package elasticsearch
22

33
import (
44
"context"
5+
"crypto/tls"
6+
"crypto/x509"
57
"fmt"
68
"io"
79
"os"
@@ -15,6 +17,7 @@ const (
1517
defaultTCPPort = "9300"
1618
defaultPassword = "changeme"
1719
defaultUsername = "elastic"
20+
defaultCaCertPath = "/usr/share/elasticsearch/config/certs/http_ca.crt"
1821
minimalImageVersion = "7.9.2"
1922
)
2023

@@ -32,7 +35,7 @@ type ElasticsearchContainer struct {
3235
}
3336

3437
// Deprecated: use Run instead
35-
// RunContainer creates an instance of the Couchbase container type
38+
// RunContainer creates an instance of the Elasticsearch container type
3639
func RunContainer(ctx context.Context, opts ...testcontainers.ContainerCustomizer) (*ElasticsearchContainer, error) {
3740
return Run(ctx, "docker.elastic.co/elasticsearch/elasticsearch:7.9.2", opts...)
3841
}
@@ -50,54 +53,41 @@ func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustom
5053
defaultHTTPPort + "/tcp",
5154
defaultTCPPort + "/tcp",
5255
},
53-
// regex that
54-
// matches 8.3 JSON logging with started message and some follow up content within the message field
55-
// matches 8.0 JSON logging with no whitespace between message field and content
56-
// matches 7.x JSON logging with whitespace between message field and content
57-
// matches 6.x text logging with node name in brackets and just a 'started' message till the end of the line
58-
WaitingFor: wait.ForLog(`.*("message":\s?"started(\s|")?.*|]\sstarted\n)`).AsRegexp(),
59-
LifecycleHooks: []testcontainers.ContainerLifecycleHooks{
60-
{
61-
// the container needs a post create hook to set the default JVM options in a file
62-
PostCreates: []testcontainers.ContainerHook{},
63-
PostReadies: []testcontainers.ContainerHook{},
64-
},
65-
},
6656
},
6757
Started: true,
6858
}
6959

7060
// Gather all config options (defaults and then apply provided options)
71-
settings := defaultOptions()
61+
options := defaultOptions()
7262
for _, opt := range opts {
7363
if apply, ok := opt.(Option); ok {
74-
apply(settings)
64+
apply(options)
7565
}
7666
if err := opt.Customize(&req); err != nil {
7767
return nil, err
7868
}
7969
}
8070

81-
// Transfer the certificate settings to the container request
82-
err := configureCertificate(settings, &req)
83-
if err != nil {
84-
return nil, err
85-
}
86-
8771
// Transfer the password settings to the container request
88-
err = configurePassword(settings, &req)
89-
if err != nil {
72+
if err := configurePassword(options, &req); err != nil {
9073
return nil, err
9174
}
9275

9376
if isAtLeastVersion(req.Image, 7) {
94-
req.LifecycleHooks[0].PostCreates = append(req.LifecycleHooks[0].PostCreates, configureJvmOpts)
77+
req.LifecycleHooks = append(req.LifecycleHooks,
78+
testcontainers.ContainerLifecycleHooks{
79+
PostCreates: []testcontainers.ContainerHook{configureJvmOpts},
80+
},
81+
)
9582
}
9683

84+
// Set the default waiting strategy if not already set.
85+
setWaitFor(options, &req.ContainerRequest)
86+
9787
container, err := testcontainers.GenericContainer(ctx, req)
9888
var esContainer *ElasticsearchContainer
9989
if container != nil {
100-
esContainer = &ElasticsearchContainer{Container: container, Settings: *settings}
90+
esContainer = &ElasticsearchContainer{Container: container, Settings: *options}
10191
}
10292
if err != nil {
10393
return esContainer, fmt.Errorf("generic container: %w", err)
@@ -110,6 +100,61 @@ func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustom
110100
return esContainer, nil
111101
}
112102

103+
// certWriter is a helper that writes the details of a CA cert to options.
104+
type certWriter struct {
105+
options *Options
106+
certPool *x509.CertPool
107+
}
108+
109+
// Read reads the CA cert from the reader and appends it to the options.
110+
func (w *certWriter) Read(r io.Reader) error {
111+
buf, err := io.ReadAll(r)
112+
if err != nil {
113+
return fmt.Errorf("read CA cert: %w", err)
114+
}
115+
116+
w.options.CACert = buf
117+
w.certPool.AppendCertsFromPEM(w.options.CACert)
118+
119+
return nil
120+
}
121+
122+
// setWaitFor sets the req.WaitingFor strategy based on settings.
123+
func setWaitFor(options *Options, req *testcontainers.ContainerRequest) {
124+
var strategies []wait.Strategy
125+
if req.WaitingFor != nil {
126+
// Custom waiting strategy, ensure we honour it.
127+
strategies = append(strategies, req.WaitingFor)
128+
}
129+
130+
waitHTTP := wait.ForHTTP("/").WithPort(defaultHTTPPort)
131+
if sslRequired(req) {
132+
waitHTTP = waitHTTP.WithTLS(true).WithAllowInsecure(true)
133+
cw := &certWriter{
134+
options: options,
135+
certPool: x509.NewCertPool(),
136+
}
137+
138+
waitHTTP = waitHTTP.
139+
WithTLS(true, &tls.Config{RootCAs: cw.certPool})
140+
141+
strategies = append(strategies, wait.ForFile(defaultCaCertPath).WithMatcher(cw.Read))
142+
}
143+
144+
if options.Password != "" || options.Username != "" {
145+
waitHTTP = waitHTTP.WithBasicAuth(options.Username, options.Password)
146+
}
147+
148+
strategies = append(strategies, waitHTTP)
149+
150+
if len(strategies) > 1 {
151+
req.WaitingFor = wait.ForAll(strategies...)
152+
return
153+
}
154+
155+
req.WaitingFor = strategies[0]
156+
}
157+
113158
// configureAddress sets the address of the Elasticsearch container.
114159
// If the certificate is set, it will use https as protocol, otherwise http.
115160
func (c *ElasticsearchContainer) configureAddress(ctx context.Context) error {
@@ -133,50 +178,28 @@ func (c *ElasticsearchContainer) configureAddress(ctx context.Context) error {
133178
return nil
134179
}
135180

136-
// configureCertificate transfers the certificate settings to the container request.
137-
// For that, it defines a post start hook that copies the certificate from the container to the host.
138-
// The certificate is only available since version 8, and will be located in a well-known location.
139-
func configureCertificate(settings *Options, req *testcontainers.GenericContainerRequest) error {
140-
if isAtLeastVersion(req.Image, 8) {
141-
// These configuration keys explicitly disable CA generation.
142-
// If any are set we skip the file retrieval.
143-
configKeys := []string{
144-
"xpack.security.enabled",
145-
"xpack.security.http.ssl.enabled",
146-
"xpack.security.transport.ssl.enabled",
147-
}
148-
for _, configKey := range configKeys {
149-
if value, ok := req.Env[configKey]; ok {
150-
if value == "false" {
151-
return nil
152-
}
181+
// sslRequired returns true if the SSL is required, otherwise false.
182+
func sslRequired(req *testcontainers.ContainerRequest) bool {
183+
if !isAtLeastVersion(req.Image, 8) {
184+
return false
185+
}
186+
187+
// These configuration keys explicitly disable CA generation.
188+
// If any are set we skip the file retrieval.
189+
configKeys := []string{
190+
"xpack.security.enabled",
191+
"xpack.security.http.ssl.enabled",
192+
"xpack.security.transport.ssl.enabled",
193+
}
194+
for _, configKey := range configKeys {
195+
if value, ok := req.Env[configKey]; ok {
196+
if value == "false" {
197+
return false
153198
}
154199
}
155-
156-
// The container needs a post ready hook to copy the certificate from the container to the host.
157-
// This certificate is only available since version 8
158-
req.LifecycleHooks[0].PostReadies = append(req.LifecycleHooks[0].PostReadies,
159-
func(ctx context.Context, container testcontainers.Container) error {
160-
const defaultCaCertPath = "/usr/share/elasticsearch/config/certs/http_ca.crt"
161-
162-
readCloser, err := container.CopyFileFromContainer(ctx, defaultCaCertPath)
163-
if err != nil {
164-
return err
165-
}
166-
167-
// receive the bytes from the default location
168-
certBytes, err := io.ReadAll(readCloser)
169-
if err != nil {
170-
return err
171-
}
172-
173-
settings.CACert = certBytes
174-
175-
return nil
176-
})
177200
}
178201

179-
return nil
202+
return true
180203
}
181204

182205
// configurePassword transfers the password settings to the container request.

modules/elasticsearch/options.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ type Options struct {
1616

1717
func defaultOptions() *Options {
1818
return &Options{
19-
CACert: nil,
2019
Username: defaultUsername,
2120
}
2221
}

0 commit comments

Comments
 (0)