diff --git a/docs/modules/redis.md b/docs/modules/redis.md index 58993fc181..e0c18bbaf8 100644 --- a/docs/modules/redis.md +++ b/docs/modules/redis.md @@ -73,10 +73,12 @@ In the case you have a custom config file for Redis, it's possible to copy that #### WithTLS +- Since testcontainers-go :material-tag: v0.37.0 + In the case you want to enable TLS for the Redis container, you can use the `WithTLS()` option. This options enables TLS on the `6379/tcp` port and uses a secure URL (e.g. `rediss://host:port`). !!!info - In case you want to use Non-mutual TLS (i.e. client authentication is not required), you can customize the CMD arguments by using the `WithCmdArgs` option. E.g. `WithCmdArgs("--tls-auth-clients no")`. + In case you want to use Non-mutual TLS (i.e. client authentication is not required), you can customize the CMD arguments by using the `WithCmdArgs` option. E.g. `WithCmdArgs("--tls-auth-clients", "no")`. The module automatically generates three certificates, a CA certificate, a client certificate and a Redis certificate. Please use the `TLSConfig()` container method to get the TLS configuration and use it to configure the Redis client. See more details in the [TLSConfig](#tlsconfig) section. @@ -94,6 +96,8 @@ If the container is started with TLS enabled, the connection string is `rediss:/ #### TLSConfig +- Since testcontainers-go :material-tag: v0.37.0 + This method returns the TLS configuration for the Redis container, nil if TLS is not enabled. diff --git a/docs/modules/valkey.md b/docs/modules/valkey.md index 81c84b4509..c31d6ac545 100644 --- a/docs/modules/valkey.md +++ b/docs/modules/valkey.md @@ -66,6 +66,17 @@ You can easily set the valkey logging level. E.g. `WithLogLevel(LogLevelDebug)`. In the case you have a custom config file for Valkey, it's possible to copy that file into the container before it's started. E.g. `WithConfigFile(filepath.Join("testdata", "valkey.conf"))`. +#### WithTLS + +- Not available until the next release of testcontainers-go :material-tag: main + +In the case you want to enable TLS for the Valkey container, you can use the `WithTLS()` option. This options enables TLS on the `6379/tcp` port and uses a secure URL (e.g. `rediss://host:port`). + +!!!info + In case you want to use Non-mutual TLS (i.e. client authentication is not required), you can customize the CMD arguments by using the `WithCmdArgs` option. E.g. `WithCmdArgs("--tls-auth-clients", "no")`. + +The module automatically generates three certificates, a CA certificate, a client certificate and a Valkey certificate. Please use the `TLSConfig()` container method to get the TLS configuration and use it to configure the Valkey client. See more details in the [TLSConfig](#tlsconfig) section. + ### Container Methods The Valkey container exposes the following methods: @@ -78,4 +89,16 @@ This method returns the connection string to connect to the Valkey container, us [Get connection string](../../modules/valkey/valkey_test.go) inside_block:connectionString - \ No newline at end of file + + +#### TLSConfig + +- Not available until the next release of testcontainers-go :material-tag: main + +This method returns the TLS configuration for the Valkey container, nil if TLS is not enabled. + + +[Get TLS config](../../modules/valkey/valkey_test.go) inside_block:tlsConfig + + +In the above example, the options are used to configure a Valkey client with TLS enabled. diff --git a/modules/valkey/examples_test.go b/modules/valkey/examples_test.go index c700e8d3f3..192e488ba5 100644 --- a/modules/valkey/examples_test.go +++ b/modules/valkey/examples_test.go @@ -6,19 +6,21 @@ import ( "log" "path/filepath" + "github.com/valkey-io/valkey-go" + "github.com/testcontainers/testcontainers-go" - "github.com/testcontainers/testcontainers-go/modules/valkey" + tcvalkey "github.com/testcontainers/testcontainers-go/modules/valkey" ) func ExampleRun() { // runValkeyContainer { ctx := context.Background() - valkeyContainer, err := valkey.Run(ctx, + valkeyContainer, err := tcvalkey.Run(ctx, "valkey/valkey:7.2.5", - valkey.WithSnapshotting(10, 1), - valkey.WithLogLevel(valkey.LogLevelVerbose), - valkey.WithConfigFile(filepath.Join("testdata", "valkey7.conf")), + tcvalkey.WithSnapshotting(10, 1), + tcvalkey.WithLogLevel(tcvalkey.LogLevelVerbose), + tcvalkey.WithConfigFile(filepath.Join("testdata", "valkey7.conf")), ) defer func() { if err := testcontainers.TerminateContainer(valkeyContainer); err != nil { @@ -42,3 +44,63 @@ func ExampleRun() { // Output: // true } + +func ExampleRun_withTLS() { + ctx := context.Background() + + valkeyContainer, err := tcvalkey.Run(ctx, + "valkey/valkey:7.2.5", + tcvalkey.WithSnapshotting(10, 1), + tcvalkey.WithLogLevel(tcvalkey.LogLevelVerbose), + tcvalkey.WithTLS(), + ) + defer func() { + if err := testcontainers.TerminateContainer(valkeyContainer); err != nil { + log.Printf("failed to terminate container: %s", err) + } + }() + if err != nil { + log.Printf("failed to start container: %s", err) + return + } + + if valkeyContainer.TLSConfig() == nil { + log.Println("TLS is not enabled") + return + } + + uri, err := valkeyContainer.ConnectionString(ctx) + if err != nil { + log.Printf("failed to get connection string: %s", err) + return + } + + // You will likely want to wrap your Valkey package of choice in an + // interface to aid in unit testing and limit lock-in throughout your + // codebase but that's out of scope for this example + options, err := valkey.ParseURL(uri) + if err != nil { + log.Printf("failed to parse connection string: %s", err) + return + } + + options.TLSConfig = valkeyContainer.TLSConfig() + + client, err := valkey.NewClient(options) + if err != nil { + log.Printf("failed to create valkey client: %s", err) + return + } + defer func() { + err := flushValkey(ctx, client) + if err != nil { + log.Printf("failed to flush valkey: %s", err) + } + }() + + resp := client.Do(ctx, client.B().Ping().Build().Pin()) + fmt.Println(resp.String()) + + // Output: + // {"Message":{"Value":"PONG","Type":"simple string"}} +} diff --git a/modules/valkey/go.mod b/modules/valkey/go.mod index 46316ec2c7..ac739c0b2f 100644 --- a/modules/valkey/go.mod +++ b/modules/valkey/go.mod @@ -6,9 +6,10 @@ toolchain go1.23.6 require ( github.com/google/uuid v1.6.0 + github.com/mdelapenya/tlscert v0.2.0 github.com/stretchr/testify v1.10.0 github.com/testcontainers/testcontainers-go v0.37.0 - github.com/valkey-io/valkey-go v1.0.41 + github.com/valkey-io/valkey-go v1.0.59 ) replace github.com/testcontainers/testcontainers-go => ../.. @@ -60,7 +61,6 @@ require ( go.opentelemetry.io/otel/trace v1.35.0 // indirect go.opentelemetry.io/proto/otlp v1.0.0 // indirect golang.org/x/crypto v0.37.0 // indirect - golang.org/x/net v0.38.0 // indirect golang.org/x/sys v0.32.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20240318140521-94a12d6c2237 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20240318140521-94a12d6c2237 // indirect diff --git a/modules/valkey/go.sum b/modules/valkey/go.sum index e01fdc3ed5..dcd01783ad 100644 --- a/modules/valkey/go.sum +++ b/modules/valkey/go.sum @@ -60,6 +60,8 @@ github.com/lufia/plan9stats v0.0.0-20240513124658-fba389f38bae h1:dIZY4ULFcto4tA github.com/lufia/plan9stats v0.0.0-20240513124658-fba389f38bae/go.mod h1:ilwx/Dta8jXAgpFYFvSWEMwxmbWXyiUHkd5FwyKhb5k= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= +github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= @@ -74,8 +76,8 @@ github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= -github.com/onsi/gomega v1.31.1 h1:KYppCUK+bUgAZwHOu7EXVBKyQA6ILvOESHkn/tgoqvo= -github.com/onsi/gomega v1.31.1/go.mod h1:y40C95dwAD1Nz36SsEnxvfFe8FFfNxzI5eJ0EYGyAy0= +github.com/onsi/gomega v1.36.2 h1:koNYke6TVk6ZmnyHrCXba/T/MoLBXFjeC1PtvYgw0A8= +github.com/onsi/gomega v1.36.2/go.mod h1:DdwyADRjrc825LhMEkD76cHR5+pUnjhUN8GlHlRPHzY= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= @@ -102,8 +104,8 @@ github.com/tklauser/go-sysconf v0.3.14 h1:g5vzr9iPFFz24v2KZXs/pvpvh8/V9Fw6vQK5ZZ github.com/tklauser/go-sysconf v0.3.14/go.mod h1:1ym4lWMLUOhuBOPGtRcJm7tEGX4SCYNEEEtghGG/8uY= github.com/tklauser/numcpus v0.8.0 h1:Mx4Wwe/FjZLeQsK/6kt2EOepwwSl7SmJrK5bV/dXYgY= github.com/tklauser/numcpus v0.8.0/go.mod h1:ZJZlAY+dmR4eut8epnzf0u/VwodKmryxR8txiloSqBE= -github.com/valkey-io/valkey-go v1.0.41 h1:pWgh9MP24Vl0ANZ0KxEMwB/LHvTUKwlm2SPuWIrSlFw= -github.com/valkey-io/valkey-go v1.0.41/go.mod h1:LXqAbjygRuA1YRocojTslAGx2dQB4p8feaseGviWka4= +github.com/valkey-io/valkey-go v1.0.59 h1:W67Z0UY+Qqk3k8NKkFCFlM3X4yQUniixl7dSJAch2Qo= +github.com/valkey-io/valkey-go v1.0.59/go.mod h1:bHmwjIEOrGq/ubOJfh5uMRs7Xj6mV3mQ/ZXUbmqpjqY= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= diff --git a/modules/valkey/options.go b/modules/valkey/options.go new file mode 100644 index 0000000000..78f0f7170d --- /dev/null +++ b/modules/valkey/options.go @@ -0,0 +1,82 @@ +package valkey + +import ( + "crypto/tls" + "fmt" + "net" + + "github.com/mdelapenya/tlscert" + + "github.com/testcontainers/testcontainers-go" +) + +type options struct { + tlsEnabled bool + tlsConfig *tls.Config +} + +// Compiler check to ensure that Option implements the testcontainers.ContainerCustomizer interface. +var _ testcontainers.ContainerCustomizer = (Option)(nil) + +// Option is an option for the Redpanda container. +type Option func(*options) error + +// Customize is a NOOP. It's defined to satisfy the testcontainers.ContainerCustomizer interface. +func (o Option) Customize(*testcontainers.GenericContainerRequest) error { + // NOOP to satisfy interface. + return nil +} + +// WithTLS sets the TLS configuration for the redis container, setting +// the 6380/tcp port to listen on for TLS connections and using a secure URL (rediss://). +func WithTLS() Option { + return func(o *options) error { + o.tlsEnabled = true + return nil + } +} + +// createTLSCerts creates a CA certificate, a client certificate and a Valkey certificate. +func createTLSCerts() (caCert *tlscert.Certificate, clientCert *tlscert.Certificate, valkeyCert *tlscert.Certificate, err error) { + // ips is the extra list of IPs to include in the certificates. + // It's used to allow the client and Valkey certificates to be used in the same host + // when the tests are run using a remote docker daemon. + ips := []net.IP{net.ParseIP("127.0.0.1")} + + // Generate CA certificate + caCert, err = tlscert.SelfSignedFromRequestE(tlscert.Request{ + Host: "localhost", + IPAddresses: ips, + Name: "ca", + SubjectCommonName: "ca", + IsCA: true, + }) + if err != nil { + return nil, nil, nil, fmt.Errorf("generate CA certificate: %w", err) + } + + // Generate client certificate + clientCert, err = tlscert.SelfSignedFromRequestE(tlscert.Request{ + Host: "localhost", + Name: "Redis Client", + SubjectCommonName: "localhost", + IPAddresses: ips, + Parent: caCert, + }) + if err != nil { + return nil, nil, nil, fmt.Errorf("generate client certificate: %w", err) + } + + // Generate Valkey certificate + valkeyCert, err = tlscert.SelfSignedFromRequestE(tlscert.Request{ + Host: "localhost", + IPAddresses: ips, + Name: "Valkey Server", + Parent: caCert, + }) + if err != nil { + return nil, nil, nil, fmt.Errorf("generate Valkey certificate: %w", err) + } + + return caCert, clientCert, valkeyCert, nil +} diff --git a/modules/valkey/valkey.go b/modules/valkey/valkey.go index 26de4458a8..48257c04ff 100644 --- a/modules/valkey/valkey.go +++ b/modules/valkey/valkey.go @@ -1,9 +1,13 @@ package valkey import ( + "bytes" "context" + "crypto/tls" "fmt" "strconv" + "strings" + "time" "github.com/testcontainers/testcontainers-go" "github.com/testcontainers/testcontainers-go/wait" @@ -12,6 +16,7 @@ import ( // ValkeyContainer represents the Valkey container type used in the module type ValkeyContainer struct { testcontainers.Container + settings options } // valkeyServerProcess is the name of the valkey server process @@ -20,6 +25,9 @@ const valkeyServerProcess = "valkey-server" type LogLevel string const ( + // valkeyPort is the port for the Valkey connection + valkeyPort = "6379/tcp" + // LogLevelDebug is the debug log level LogLevelDebug LogLevel = "debug" // LogLevelVerbose is the verbose log level @@ -32,7 +40,7 @@ const ( // ConnectionString returns the connection string for the Valkey container func (c *ValkeyContainer) ConnectionString(ctx context.Context) (string, error) { - mappedPort, err := c.MappedPort(ctx, "6379/tcp") + mappedPort, err := c.MappedPort(ctx, valkeyPort) if err != nil { return "", err } @@ -42,10 +50,20 @@ func (c *ValkeyContainer) ConnectionString(ctx context.Context) (string, error) return "", err } - uri := fmt.Sprintf("redis://%s:%s", hostIP, mappedPort.Port()) + schema := "redis" + if c.settings.tlsEnabled { + schema = "rediss" + } + + uri := fmt.Sprintf("%s://%s:%s", schema, hostIP, mappedPort.Port()) return uri, nil } +// TLSConfig returns the TLS configuration for the Valkey container, nil if TLS is not enabled. +func (c *ValkeyContainer) TLSConfig() *tls.Config { + return c.settings.tlsConfig +} + // Deprecated: use Run instead // RunContainer creates an instance of the Valkey container type func RunContainer(ctx context.Context, opts ...testcontainers.ContainerCustomizer) (*ValkeyContainer, error) { @@ -56,8 +74,7 @@ func RunContainer(ctx context.Context, opts ...testcontainers.ContainerCustomize func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*ValkeyContainer, error) { req := testcontainers.ContainerRequest{ Image: img, - ExposedPorts: []string{"6379/tcp"}, - WaitingFor: wait.ForListeningPort("6379/tcp"), + ExposedPorts: []string{valkeyPort}, } genericContainerReq := testcontainers.GenericContainerRequest{ @@ -65,7 +82,77 @@ func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustom Started: true, } + var settings options for _, opt := range opts { + if opt, ok := opt.(Option); ok { + if err := opt(&settings); err != nil { + return nil, err + } + } + } + + tcOpts := []testcontainers.ContainerCustomizer{} + + waitStrategies := []wait.Strategy{ + wait.ForListeningPort(valkeyPort).WithStartupTimeout(time.Second * 10), + wait.ForLog("* Ready to accept connections"), + } + + if settings.tlsEnabled { + // wait for the TLS port to be available + waitStrategies = append(waitStrategies, wait.ForListeningPort(valkeyPort).WithStartupTimeout(time.Second*10)) + + // Generate TLS certificates in the fly and add them to the container before it starts. + // Update the CMD to use the TLS certificates. + caCert, clientCert, serverCert, err := createTLSCerts() + if err != nil { + return nil, fmt.Errorf("create tls certs: %w", err) + } + + // Update the CMD to use the TLS certificates. + cmds := []string{ + "--tls-port", strings.Replace(valkeyPort, "/tcp", "", 1), + // Disable the default port, as described in https://redis.io/docs/latest/operate/oss_and_stack/management/security/encryption/#running-manually + "--port", "0", + "--tls-cert-file", "/tls/server.crt", + "--tls-key-file", "/tls/server.key", + "--tls-ca-cert-file", "/tls/ca.crt", + "--tls-auth-clients", "yes", + } + + tcOpts = append(tcOpts, testcontainers.WithCmdArgs(cmds...)) // Append the default CMD with the TLS certificates. + tcOpts = append(tcOpts, testcontainers.WithFiles( + testcontainers.ContainerFile{ + Reader: bytes.NewReader(caCert.Bytes), + ContainerFilePath: "/tls/ca.crt", + FileMode: 0o644, + }, + testcontainers.ContainerFile{ + Reader: bytes.NewReader(serverCert.Bytes), + ContainerFilePath: "/tls/server.crt", + FileMode: 0o644, + }, + testcontainers.ContainerFile{ + Reader: bytes.NewReader(serverCert.KeyBytes), + ContainerFilePath: "/tls/server.key", + FileMode: 0o644, + })) + + settings.tlsConfig = &tls.Config{ + MinVersion: tls.VersionTLS12, + RootCAs: caCert.TLSConfig().RootCAs, + Certificates: clientCert.TLSConfig().Certificates, + ServerName: "localhost", // Match the server cert's common name + } + } + + tcOpts = append(tcOpts, testcontainers.WithWaitStrategy(waitStrategies...)) + + // Append the customizers passed to the Run function. + tcOpts = append(tcOpts, opts...) + + // Apply the testcontainers customizers. + for _, opt := range tcOpts { if err := opt.Customize(&genericContainerReq); err != nil { return nil, err } @@ -74,7 +161,7 @@ func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustom container, err := testcontainers.GenericContainer(ctx, genericContainerReq) var c *ValkeyContainer if container != nil { - c = &ValkeyContainer{Container: container} + c = &ValkeyContainer{Container: container, settings: settings} } if err != nil { diff --git a/modules/valkey/valkey_test.go b/modules/valkey/valkey_test.go index 44412afa4a..ef8eead8cc 100644 --- a/modules/valkey/valkey_test.go +++ b/modules/valkey/valkey_test.go @@ -80,6 +80,26 @@ func TestValkeyWithSnapshotting(t *testing.T) { assertSetsGets(t, ctx, valkeyContainer, 10) } +func TestRedisWithTLS(t *testing.T) { + ctx := context.Background() + + t.Run("mtls-disabled", func(t *testing.T) { + valkeyContainer, err := tcvalkey.Run(ctx, "valkey/valkey:7.2.5", tcvalkey.WithTLS()) + testcontainers.CleanupContainer(t, valkeyContainer) + require.NoError(t, err) + + assertSetsGets(t, ctx, valkeyContainer, 1) + }) + + t.Run("mtls-enabled", func(t *testing.T) { + valkeyContainer, err := tcvalkey.Run(ctx, "valkey/valkey:7.2.5", tcvalkey.WithTLS(), testcontainers.WithCmdArgs("--tls-auth-clients", "no")) + testcontainers.CleanupContainer(t, valkeyContainer) + require.NoError(t, err) + + assertSetsGets(t, ctx, valkeyContainer, 1) + }) +} + func assertSetsGets(t *testing.T, ctx context.Context, valkeyContainer *tcvalkey.ValkeyContainer, keyCount int) { t.Helper() // connectionString { @@ -93,6 +113,10 @@ func assertSetsGets(t *testing.T, ctx context.Context, valkeyContainer *tcvalkey options, err := valkey.ParseURL(uri) require.NoError(t, err) + // tlsConfig { + options.TLSConfig = valkeyContainer.TLSConfig() + // } + client, err := valkey.NewClient(options) require.NoError(t, err) defer func(t *testing.T, ctx context.Context, client *valkey.Client) {