Skip to content

Commit

Permalink
feat: tls strategy and strategy walk
Browse files Browse the repository at this point in the history
Extract TLS certificate wait strategy into a dedicated wait type so it
can be reused.

Implement walk method which can be used to identify wait strategies in
a request chain.

Use embed to simplify wait test loading of certs.
  • Loading branch information
stevenh committed Nov 8, 2024
1 parent 8cdb027 commit 48aa4b7
Show file tree
Hide file tree
Showing 17 changed files with 582 additions and 117 deletions.
19 changes: 19 additions & 0 deletions docs/features/wait/tls.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# TLS Strategy

TLS Strategy waits for one or more files to exist in the container and uses them
and other details to construct a `tls.Config` which can be used to create secure
connections.

It supports:

- x509 PEM Certificate loaded from a certificate / key file pair.
- Root Certificate Authorities aka RootCAs loaded from PEM encoded files.
- Server name.
- Startup timeout to be used in seconds, default is 60 seconds.
- Poll interval to be used in milliseconds, default is 100 milliseconds.

## Waiting for certificate pair to exist and construct a tls.Config

<!--codeinclude-->
[Waiting for certificate pair to exist and construct a tls.Config](../../../wait/tls_test.go) inside_block:waitForTLSCert
<!--/codeinclude-->
116 changes: 41 additions & 75 deletions modules/cockroachdb/cockroachdb.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,9 @@ import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
_ "embed"
"errors"
"fmt"
"io"
"net"
"net/url"

Expand Down Expand Up @@ -79,70 +77,10 @@ type CockroachDBContainer struct {

// options represents the options for the CockroachDBContainer type.
type options struct {
// Settings.
database string
user string
password string
insecure bool

// Client certificate.
clientCert []byte
clientKey []byte
certPool *x509.CertPool
tlsConfig *tls.Config
}

// WaitUntilReady implements the [wait.Strategy] interface.
// If TLS is enabled, it waits for the CA, client cert and key for the configured user to be
// available in the container and uses them to setup the TLS config, otherwise it does nothing.
//
// This is defined on the options as it needs to know the customised values to operate correctly.
func (o *options) WaitUntilReady(ctx context.Context, target wait.StrategyTarget) error {
if o.insecure {
return nil
}

return wait.ForAll(
wait.ForFile(fileCACert).WithMatcher(func(r io.Reader) error {
buf, err := io.ReadAll(r)
if err != nil {
return fmt.Errorf("read CA cert: %w", err)
}

if !o.certPool.AppendCertsFromPEM(buf) {
return errors.New("invalid CA cert")
}

return nil
}),
wait.ForFile(certsDir+"/client."+o.user+".crt").WithMatcher(func(r io.Reader) error {
var err error
if o.clientCert, err = io.ReadAll(r); err != nil {
return fmt.Errorf("read client cert: %w", err)
}

return nil
}),
wait.ForFile(certsDir+"/client."+o.user+".key").WithMatcher(func(r io.Reader) error {
var err error
if o.clientKey, err = io.ReadAll(r); err != nil {
return fmt.Errorf("read client key: %w", err)
}

cert, err := tls.X509KeyPair(o.clientCert, o.clientKey)
if err != nil {
return fmt.Errorf("x509 key pair: %w", err)
}

o.tlsConfig = &tls.Config{
RootCAs: o.certPool,
Certificates: []tls.Certificate{cert},
ServerName: "127.0.0.1",
}

return nil
}),
).WaitUntilReady(ctx, target)
database string
user string
password string
tlsStrategy *wait.TLSStrategy
}

// MustConnectionString returns a connection string to open a new connection to CockroachDB
Expand Down Expand Up @@ -191,11 +129,12 @@ func (c *CockroachDBContainer) ConnectionConfig(ctx context.Context) (*pgx.ConnC
// Deprecated: use [CockroachDBContainer.ConnectionConfig] or
// [CockroachDBContainer.ConnectionConfig] instead.
func (c *CockroachDBContainer) TLSConfig() (*tls.Config, error) {
if c.tlsConfig == nil {
return nil, ErrTLSNotEnabled
if cfg := c.tlsStrategy.TLSConfig(); cfg != nil {
return cfg, nil

}

return c.tlsConfig, nil
return nil, ErrTLSNotEnabled
}

// connString returns a connection string for the given host, port and options.
Expand All @@ -218,7 +157,8 @@ func (c *CockroachDBContainer) connConfig(host string, port nat.Port) (*pgx.Conn
}

sslMode := "disable"
if c.tlsConfig != nil {
tlsConfig := c.tlsStrategy.TLSConfig()
if tlsConfig != nil {
sslMode = "verify-full"
}
params := url.Values{
Expand All @@ -238,22 +178,46 @@ func (c *CockroachDBContainer) connConfig(host string, port nat.Port) (*pgx.Conn
return nil, fmt.Errorf("parse config: %w", err)
}

cfg.TLSConfig = c.tlsConfig
cfg.TLSConfig = tlsConfig

return cfg, nil
}

// setOptions sets the CockroachDBContainer options from a request.
func (c *CockroachDBContainer) setOptions(req *testcontainers.GenericContainerRequest) {
func (c *CockroachDBContainer) setOptions(req *testcontainers.GenericContainerRequest) error {
c.database = req.Env[envDatabase]
c.user = req.Env[envUser]
c.password = req.Env[envPassword]

var insecure bool
for _, arg := range req.Cmd {
if arg == insecureFlag {
c.insecure = true
insecure = true
break
}
}

if err := wait.Walk(&req.WaitingFor, func(strategy wait.Strategy) error {
if cert, ok := strategy.(*wait.TLSStrategy); ok {
if insecure {
// If insecure mode is enabled, the certificate strategy is removed.
return errors.Join(wait.VisitRemove, wait.VisitStop)
}

// Update the client certificate files to match the user which may have changed.
cert.WithCert(certsDir+"/client."+c.user+".crt", certsDir+"/client."+c.user+".key")

c.tlsStrategy = cert

// Stop the walk as the certificate strategy has been found.
return wait.VisitStop
}
return nil
}); err != nil {
return fmt.Errorf("walk strategies: %w", err)
}

return nil
}

// Deprecated: use Run instead.
Expand Down Expand Up @@ -281,7 +245,6 @@ func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustom
database: defaultDatabase,
user: defaultUser,
password: defaultPassword,
certPool: x509.NewCertPool(),
},
}
req := testcontainers.GenericContainerRequest{
Expand All @@ -308,7 +271,10 @@ func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustom
WaitingFor: wait.ForAll(
wait.ForFile(cockroachDir+"/init_success"),
wait.ForHTTP("/health").WithPort(defaultAdminPort),
ctr, // Wait for the TLS files to be available if needed.
wait.ForTLSCert(
certsDir+"/client."+defaultUser+".crt",
certsDir+"/client."+defaultUser+".key",
).WithRootCAs(fileCACert).WithServerName("127.0.0.1"),
wait.ForSQL(defaultSQLPort, "pgx/v5", func(host string, port nat.Port) string {
connStr, err := ctr.connString(host, port)
if err != nil {
Expand Down
31 changes: 19 additions & 12 deletions modules/cockroachdb/cockroachdb_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,19 +30,26 @@ func TestRun_WithAllOptions(t *testing.T) {
)
}

func TestRun_WithInsecureAndPassword(t *testing.T) {
_, err := cockroachdb.Run(context.Background(), testImage,
cockroachdb.WithPassword("testPassword"),
cockroachdb.WithInsecure(),
)
require.Error(t, err)
func TestRun_WithInsecure(t *testing.T) {
t.Run("valid", func(t *testing.T) {
testContainer(t, cockroachdb.WithInsecure())
})

// Check order does not matter.
_, err = cockroachdb.Run(context.Background(), testImage,
cockroachdb.WithInsecure(),
cockroachdb.WithPassword("testPassword"),
)
require.Error(t, err)
t.Run("invalid-password-insecure", func(t *testing.T) {
_, err := cockroachdb.Run(context.Background(), testImage,
cockroachdb.WithPassword("testPassword"),
cockroachdb.WithInsecure(),
)
require.Error(t, err)
})

t.Run("invalid-insecure-password", func(t *testing.T) {
_, err := cockroachdb.Run(context.Background(), testImage,
cockroachdb.WithInsecure(),
cockroachdb.WithPassword("testPassword"),
)
require.Error(t, err)
})
}

// testContainer runs a CockroachDB container and validates its functionality.
Expand Down
2 changes: 1 addition & 1 deletion modules/cockroachdb/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ func WithInitScripts(scripts ...string) testcontainers.CustomizeRequestOption {
}
}

// WithInsecure enables insecure mode and disables TLS.
// WithInsecure enables insecure mode which disables TLS.
func WithInsecure() testcontainers.CustomizeRequestOption {
return func(req *testcontainers.GenericContainerRequest) error {
if req.Env[envPassword] != "" {
Expand Down
2 changes: 1 addition & 1 deletion wait/file_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import (

const testFilename = "/tmp/file"

var anyContext = mock.AnythingOfType("*context.timerCtx")
var anyContext = mock.MatchedBy(func(_ context.Context) bool { return true })

// newRunningTarget creates a new mockStrategyTarget that is running.
func newRunningTarget() *mockStrategyTarget {
Expand Down
39 changes: 11 additions & 28 deletions wait/http_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"context"
"crypto/tls"
"crypto/x509"
_ "embed"
"fmt"
"io"
"log"
Expand All @@ -23,6 +24,9 @@ import (
"github.com/testcontainers/testcontainers-go/wait"
)

//go:embed testdata/root.pem
var caBytes []byte

// https://github.com/testcontainers/testcontainers-go/issues/183
func ExampleHTTPStrategy() {
// waitForHTTPWithDefaultPort {
Expand Down Expand Up @@ -80,7 +84,7 @@ func ExampleHTTPStrategy_WithHeaders() {
tlsconfig := &tls.Config{RootCAs: certpool, ServerName: "testcontainer.go.test"}
req := testcontainers.ContainerRequest{
FromDockerfile: testcontainers.FromDockerfile{
Context: "testdata",
Context: "testdata/http",
},
ExposedPorts: []string{"6443/tcp"},
WaitingFor: wait.ForHTTP("/headers").
Expand Down Expand Up @@ -227,20 +231,13 @@ func ExampleHTTPStrategy_WithBasicAuth() {
}

func TestHTTPStrategyWaitUntilReady(t *testing.T) {
workdir, err := os.Getwd()
require.NoError(t, err)

capath := filepath.Join(workdir, "testdata", "root.pem")
cafile, err := os.ReadFile(capath)
require.NoError(t, err)

certpool := x509.NewCertPool()
require.Truef(t, certpool.AppendCertsFromPEM(cafile), "the ca file isn't valid")
require.Truef(t, certpool.AppendCertsFromPEM(caBytes), "the ca file isn't valid")

tlsconfig := &tls.Config{RootCAs: certpool, ServerName: "testcontainer.go.test"}
dockerReq := testcontainers.ContainerRequest{
FromDockerfile: testcontainers.FromDockerfile{
Context: filepath.Join(workdir, "testdata"),
Context: "testdata/http",
},
ExposedPorts: []string{"6443/tcp"},
WaitingFor: wait.NewHTTPStrategy("/auth-ping").WithTLS(true, tlsconfig).
Expand Down Expand Up @@ -288,20 +285,13 @@ func TestHTTPStrategyWaitUntilReady(t *testing.T) {
}

func TestHTTPStrategyWaitUntilReadyWithQueryString(t *testing.T) {
workdir, err := os.Getwd()
require.NoError(t, err)

capath := filepath.Join(workdir, "testdata", "root.pem")
cafile, err := os.ReadFile(capath)
require.NoError(t, err)

certpool := x509.NewCertPool()
require.Truef(t, certpool.AppendCertsFromPEM(cafile), "the ca file isn't valid")
require.Truef(t, certpool.AppendCertsFromPEM(caBytes), "the ca file isn't valid")

tlsconfig := &tls.Config{RootCAs: certpool, ServerName: "testcontainer.go.test"}
dockerReq := testcontainers.ContainerRequest{
FromDockerfile: testcontainers.FromDockerfile{
Context: filepath.Join(workdir, "testdata"),
Context: "testdata/http",
},

ExposedPorts: []string{"6443/tcp"},
Expand Down Expand Up @@ -348,22 +338,15 @@ func TestHTTPStrategyWaitUntilReadyWithQueryString(t *testing.T) {
}

func TestHTTPStrategyWaitUntilReadyNoBasicAuth(t *testing.T) {
workdir, err := os.Getwd()
require.NoError(t, err)

capath := filepath.Join(workdir, "testdata", "root.pem")
cafile, err := os.ReadFile(capath)
require.NoError(t, err)

certpool := x509.NewCertPool()
require.Truef(t, certpool.AppendCertsFromPEM(cafile), "the ca file isn't valid")
require.Truef(t, certpool.AppendCertsFromPEM(caBytes), "the ca file isn't valid")

// waitForHTTPStatusCode {
tlsconfig := &tls.Config{RootCAs: certpool, ServerName: "testcontainer.go.test"}
var i int
dockerReq := testcontainers.ContainerRequest{
FromDockerfile: testcontainers.FromDockerfile{
Context: filepath.Join(workdir, "testdata"),
Context: "testdata/http",
},
ExposedPorts: []string{"6443/tcp"},
WaitingFor: wait.NewHTTPStrategy("/ping").WithTLS(true, tlsconfig).
Expand Down
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
5 changes: 5 additions & 0 deletions wait/testdata/http/tls-key.pem
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIM8HuDwcZyVqBBy2C6db6zNb/dAJ69bq5ejAEz7qGOIQoAoGCCqGSM49
AwEHoUQDQgAEBL2ioRmfTc70WT0vyx+amSQOGbMeoMRAfF2qaPzpzOqpKTk0aLOG
0735iy9Fz16PX4vqnLMiM/ZupugAhB//yA==
-----END EC PRIVATE KEY-----
12 changes: 12 additions & 0 deletions wait/testdata/http/tls.pem
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
-----BEGIN CERTIFICATE-----
MIIBxTCCAWugAwIBAgIUWBLNpiF1o4r+5ZXwawzPOfBM1F8wCgYIKoZIzj0EAwIw
ADAeFw0yMDA4MTkxMzM4MDBaFw0zMDA4MTcxMzM4MDBaMBkxFzAVBgNVBAMTDnRl
c3Rjb250YWluZXJzMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEBL2ioRmfTc70
WT0vyx+amSQOGbMeoMRAfF2qaPzpzOqpKTk0aLOG0735iy9Fz16PX4vqnLMiM/Zu
pugAhB//yKOBqTCBpjAOBgNVHQ8BAf8EBAMCBaAwEwYDVR0lBAwwCgYIKwYBBQUH
AwEwDAYDVR0TAQH/BAIwADAdBgNVHQ4EFgQUTMdz5PIZ+Gix4jYUzRIHfByrW+Yw
HwYDVR0jBBgwFoAUFdfV6PSYUlHs+lSQNouRwSfR2ZgwMQYDVR0RBCowKIIVdGVz
dGNvbnRhaW5lci5nby50ZXN0gglsb2NhbGhvc3SHBH8AAAEwCgYIKoZIzj0EAwID
SAAwRQIhAJznPNumi2Plf0GsP9DpC+8WukT/jUhnhcDWCfZ6Ini2AiBLhnhFebZX
XWfSsdSNxIo20OWvy6z3wqdybZtRUfdU+g==
-----END CERTIFICATE-----
Loading

0 comments on commit 48aa4b7

Please sign in to comment.