diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index 22ef0f554e..1df91f82a2 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -14,7 +14,6 @@ updates:
directories:
- /
- /examples/nginx
- - /examples/toxiproxy
- /modulegen
- /modules/aerospike
- /modules/arangodb
@@ -67,6 +66,7 @@ updates:
- /modules/scylladb
- /modules/socat
- /modules/surrealdb
+ - /modules/toxiproxy
- /modules/valkey
- /modules/vault
- /modules/vearch
diff --git a/.vscode/.testcontainers-go.code-workspace b/.vscode/.testcontainers-go.code-workspace
index d93f118a74..eaa4e1bcc0 100644
--- a/.vscode/.testcontainers-go.code-workspace
+++ b/.vscode/.testcontainers-go.code-workspace
@@ -9,10 +9,6 @@
"name": "example / nginx",
"path": "../examples/nginx"
},
- {
- "name": "example / toxiproxy",
- "path": "../examples/toxiproxy"
- },
{
"name": "module / arangodb",
"path": "../modules/arangodb"
@@ -213,6 +209,10 @@
"name": "module / surrealdb",
"path": "../modules/surrealdb"
},
+ {
+ "name": "module / toxiproxy",
+ "path": "../modules/toxiproxy"
+ },
{
"name": "module / valkey",
"path": "../modules/valkey"
diff --git a/docs/examples/toxiproxy.md b/docs/examples/toxiproxy.md
deleted file mode 100644
index 24b4d8a4d0..0000000000
--- a/docs/examples/toxiproxy.md
+++ /dev/null
@@ -1,9 +0,0 @@
-# Toxiproxy
-
-
-[Creating a Toxiproxy container](../../examples/toxiproxy/toxiproxy.go)
-
-
-
-[Test for a Toxiproxy container](../../examples/toxiproxy/toxiproxy_test.go)
-
diff --git a/docs/modules/toxiproxy.md b/docs/modules/toxiproxy.md
new file mode 100644
index 0000000000..5639cf8ac2
--- /dev/null
+++ b/docs/modules/toxiproxy.md
@@ -0,0 +1,141 @@
+# Toxiproxy
+
+Not available until the next release of testcontainers-go :material-tag: main
+
+## Introduction
+
+The Testcontainers module for Toxiproxy.
+
+## Adding this module to your project dependencies
+
+Please run the following command to add the Toxiproxy module to your Go dependencies:
+
+```
+go get github.com/testcontainers/testcontainers-go/modules/toxiproxy
+```
+
+## Usage example
+
+
+[Creating a Toxiproxy container](../../modules/toxiproxy/examples_test.go) inside_block:runToxiproxyContainer
+
+
+## Module Reference
+
+### Run function
+
+- Not available until the next release of testcontainers-go :material-tag: main
+
+The Toxiproxy module exposes one entrypoint function to create the Toxiproxy container, and this function receives three parameters:
+
+```golang
+func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*Container, error)
+```
+
+- `context.Context`, the Go context.
+- `string`, the Docker image to use.
+- `testcontainers.ContainerCustomizer`, a variadic argument for passing options.
+
+### Container Ports
+
+The Toxiproxy container exposes the following ports:
+
+- `8474/tcp`, the Toxiproxy control port, exported as `toxiproxy.ControlPort`.
+
+### Container Options
+
+When starting the Toxiproxy container, you can pass options in a variadic way to configure it.
+
+#### Image
+
+Use the second argument in the `Run` function to set a valid Docker image.
+In example: `Run(context.Background(), "shopify/toxiproxy:2.12.0")`.
+
+{% include "../features/common_functional_options.md" %}
+
+#### WithProxy
+
+- Not available until the next release of testcontainers-go :material-tag: main
+
+The `WithProxy` option allows you to specify a proxy to be created on the Toxiproxy container.
+This option allocates a random port on the host and exposes it to the Toxiproxy container, allowing
+you to create a unique proxy for a given service, starting from the `8666/tcp` port.
+
+```golang
+func WithProxy(name string, upstream string) Option
+```
+
+If this option is used in combination with the `WithConfigFile` option, the proxy defined in this option
+is added to the proxies defined in the config file.
+
+!!!info
+ If you add proxies in a programmatic manner using the Toxiproxy client, then you need to manually
+ add exposed ports in the Toxiproxy container.
+
+#### WithConfigFile
+
+- Not available until the next release of testcontainers-go :material-tag: main
+
+The `WithConfigFile` option allows you to specify a config file for the Toxiproxy container, in the form of an `io.Reader` representing
+the JSON file with the Toxiproxy configuration, in the valid format of the Toxiproxy configuration file.
+
+
+[Configuration file](../../modules/toxiproxy/testdata/toxiproxy.json)
+
+
+```golang
+func WithConfigFile(r io.Reader) testcontainers.CustomizeRequestOption
+```
+
+If this option is used in combination with the `WithProxy` option, the proxies defined in this option
+are added to the proxies defined with the `WithProxy` option.
+
+### Container Methods
+
+The Toxiproxy container exposes the following methods:
+
+#### ProxiedEndpoint
+
+- Not available until the next release of testcontainers-go :material-tag: main
+
+The `ProxiedEndpoint` method returns the host and port of the proxy for a given port. It's used to create new connections to the proxied service, and it returns an error in case the port has no proxy.
+
+```golang
+func (c *Container) ProxiedEndpoint(port int) (string, string, error)
+```
+
+
+[Get Proxied Endpoint](../../modules/toxiproxy/examples_test.go) inside_block:getProxiedEndpoint
+[Read Proxied Endpoint](../../modules/toxiproxy/examples_test.go) inside_block:readProxiedEndpoint
+
+
+The above examples show how to get the proxied endpoint and use it to create a new connection to the proxied service, in this case a Redis client.
+
+#### URI
+
+- Not available until the next release of testcontainers-go :material-tag: main
+
+The `URI` method returns the URI of the Toxiproxy container, used to create a new Toxiproxy client.
+
+```golang
+func (c *Container) URI() string
+```
+
+
+[Creating a Toxiproxy client](../../modules/toxiproxy/examples_test.go) inside_block:createToxiproxyClient
+
+
+- the `toxiproxy` package comes from the `github.com/Shopify/toxiproxy/v2/client` package.
+- the `toxiproxyContainer` variable has been created by the `Run` function.
+
+### Examples
+
+#### Programmatically create a proxy
+
+
+[Expose port manually](../../modules/toxiproxy/examples_test.go) inside_block:defineContainerExposingPort
+[Creating a proxy](../../modules/toxiproxy/examples_test.go) inside_block:createProxy
+[Creating a Redis client](../../modules/toxiproxy/examples_test.go) inside_block:createRedisClient
+[Adding a latency toxic](../../modules/toxiproxy/examples_test.go) inside_block:addLatencyToxic
+
+
\ No newline at end of file
diff --git a/examples/toxiproxy/redis.go b/examples/toxiproxy/redis.go
deleted file mode 100644
index c66e52550f..0000000000
--- a/examples/toxiproxy/redis.go
+++ /dev/null
@@ -1,35 +0,0 @@
-package toxiproxy
-
-import (
- "context"
-
- "github.com/testcontainers/testcontainers-go"
- "github.com/testcontainers/testcontainers-go/wait"
-)
-
-type redisContainer struct {
- testcontainers.Container
-}
-
-func setupRedis(ctx context.Context, network string, networkAlias []string) (*redisContainer, error) {
- req := testcontainers.ContainerRequest{
- Image: "redis:6",
- ExposedPorts: []string{"6379/tcp"},
- WaitingFor: wait.ForLog("* Ready to accept connections"),
- Networks: []string{
- network,
- },
- NetworkAliases: map[string][]string{
- network: networkAlias,
- },
- }
- container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
- ContainerRequest: req,
- Started: true,
- })
- var nginxC *redisContainer
- if container != nil {
- nginxC = &redisContainer{Container: container}
- }
- return nginxC, err
-}
diff --git a/examples/toxiproxy/toxiproxy.go b/examples/toxiproxy/toxiproxy.go
deleted file mode 100644
index 1a226e8c61..0000000000
--- a/examples/toxiproxy/toxiproxy.go
+++ /dev/null
@@ -1,55 +0,0 @@
-package toxiproxy
-
-import (
- "context"
- "fmt"
-
- "github.com/testcontainers/testcontainers-go"
- "github.com/testcontainers/testcontainers-go/wait"
-)
-
-// toxiproxyContainer represents the toxiproxy container type used in the module
-type toxiproxyContainer struct {
- testcontainers.Container
- URI string
-}
-
-// startContainer creates an instance of the toxiproxy container type
-func startContainer(ctx context.Context, network string, networkAlias []string) (*toxiproxyContainer, error) {
- req := testcontainers.ContainerRequest{
- Image: "ghcr.io/shopify/toxiproxy:2.5.0",
- ExposedPorts: []string{"8474/tcp", "8666/tcp"},
- WaitingFor: wait.ForHTTP("/version").WithPort("8474/tcp"),
- Networks: []string{
- network,
- },
- NetworkAliases: map[string][]string{
- network: networkAlias,
- },
- }
- container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
- ContainerRequest: req,
- Started: true,
- })
- var toxiC *toxiproxyContainer
- if container != nil {
- toxiC = &toxiproxyContainer{Container: container}
- }
- if err != nil {
- return toxiC, err
- }
-
- mappedPort, err := container.MappedPort(ctx, "8474")
- if err != nil {
- return toxiC, err
- }
-
- hostIP, err := container.Host(ctx)
- if err != nil {
- return toxiC, err
- }
-
- toxiC.URI = fmt.Sprintf("%s:%s", hostIP, mappedPort.Port())
-
- return toxiC, nil
-}
diff --git a/examples/toxiproxy/toxiproxy_test.go b/examples/toxiproxy/toxiproxy_test.go
deleted file mode 100644
index 47c80ab87a..0000000000
--- a/examples/toxiproxy/toxiproxy_test.go
+++ /dev/null
@@ -1,76 +0,0 @@
-package toxiproxy
-
-import (
- "context"
- "fmt"
- "testing"
- "time"
-
- toxiproxy "github.com/Shopify/toxiproxy/v2/client"
- "github.com/go-redis/redis/v8"
- "github.com/google/uuid"
- "github.com/stretchr/testify/require"
-
- "github.com/testcontainers/testcontainers-go"
- "github.com/testcontainers/testcontainers-go/network"
-)
-
-func TestToxiproxy(t *testing.T) {
- ctx := context.Background()
-
- newNetwork, err := network.New(ctx)
- require.NoError(t, err)
- testcontainers.CleanupNetwork(t, newNetwork)
-
- networkName := newNetwork.Name
-
- toxiproxyContainer, err := startContainer(ctx, networkName, []string{"toxiproxy"})
- testcontainers.CleanupContainer(t, toxiproxyContainer)
- require.NoError(t, err)
-
- redisContainer, err := setupRedis(ctx, networkName, []string{"redis"})
- testcontainers.CleanupContainer(t, redisContainer)
- require.NoError(t, err)
-
- toxiproxyClient := toxiproxy.NewClient(toxiproxyContainer.URI)
- proxy, err := toxiproxyClient.CreateProxy("redis", "0.0.0.0:8666", "redis:6379")
- require.NoError(t, err)
-
- toxiproxyProxyPort, err := toxiproxyContainer.MappedPort(ctx, "8666")
- require.NoError(t, err)
-
- toxiproxyProxyHostIP, err := toxiproxyContainer.Host(ctx)
- require.NoError(t, err)
-
- redisURI := fmt.Sprintf("redis://%s:%s?read_timeout=2s", toxiproxyProxyHostIP, toxiproxyProxyPort.Port())
-
- options, err := redis.ParseURL(redisURI)
- require.NoError(t, err)
- redisClient := redis.NewClient(options)
-
- defer func() {
- require.NoError(t, flushRedis(ctx, *redisClient))
- }()
-
- // Set data
- key := fmt.Sprintf("{user.%s}.favoritefood", uuid.NewString())
- value := "Cabbage Biscuits"
- ttl, _ := time.ParseDuration("2h")
- err = redisClient.Set(ctx, key, value, ttl).Err()
- require.NoError(t, err)
-
- _, err = proxy.AddToxic("latency_down", "latency", "downstream", 1.0, toxiproxy.Attributes{
- "latency": 1000,
- "jitter": 100,
- })
- require.NoError(t, err)
-
- // Get data
- savedValue, err := redisClient.Get(ctx, key).Result()
- require.NoError(t, err)
- require.Equal(t, value, savedValue)
-}
-
-func flushRedis(ctx context.Context, client redis.Client) error {
- return client.FlushAll(ctx).Err()
-}
diff --git a/mkdocs.yml b/mkdocs.yml
index 44bf2f2672..905fe3e884 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -118,6 +118,7 @@ nav:
- modules/scylladb.md
- modules/socat.md
- modules/surrealdb.md
+ - modules/toxiproxy.md
- modules/valkey.md
- modules/vault.md
- modules/vearch.md
@@ -126,7 +127,6 @@ nav:
- Examples:
- examples/index.md
- examples/nginx.md
- - examples/toxiproxy.md
- System Requirements:
- system_requirements/index.md
- system_requirements/docker.md
diff --git a/examples/toxiproxy/Makefile b/modules/toxiproxy/Makefile
similarity index 100%
rename from examples/toxiproxy/Makefile
rename to modules/toxiproxy/Makefile
diff --git a/modules/toxiproxy/config.go b/modules/toxiproxy/config.go
new file mode 100644
index 0000000000..b3dd298a46
--- /dev/null
+++ b/modules/toxiproxy/config.go
@@ -0,0 +1,70 @@
+package toxiproxy
+
+import (
+ "fmt"
+ "net"
+ "strconv"
+)
+
+// proxy represents a single proxy configuration in the toxiproxy config file
+type proxy struct {
+ Name string `json:"name"`
+ Listen string `json:"listen"`
+ Upstream string `json:"upstream"`
+ Enabled bool `json:"enabled"`
+
+ listenIP string
+ upstreamIP string
+ listenPort int
+ upstreamPort int
+}
+
+// sanitize is a helper function that returns another proxy
+// in which all the fields have been extracted from the
+// string representation of the upstream and listen fields.
+func (p *proxy) sanitize() error {
+ listenIP, listenPortStr, err := net.SplitHostPort(p.Listen)
+ if err != nil {
+ return fmt.Errorf("split hostPort: %w", err)
+ }
+ p.listenIP = listenIP
+
+ listenPort, err := strconv.Atoi(listenPortStr)
+ if err != nil {
+ return fmt.Errorf("atoi: %w", err)
+ }
+ p.listenPort = listenPort
+
+ upstreamIP, upstreamPortStr, err := net.SplitHostPort(p.Upstream)
+ if err != nil {
+ return fmt.Errorf("split hostPort: %w", err)
+ }
+ p.upstreamIP = upstreamIP
+
+ upstreamPort, err := strconv.Atoi(upstreamPortStr)
+ if err != nil {
+ return fmt.Errorf("atoi: %w", err)
+ }
+ p.upstreamPort = upstreamPort
+
+ return nil
+}
+
+// newProxy creates a new proxy configuration with default values
+func newProxy(name string, upstream string) (*proxy, error) {
+ _, upstreamPortStr, err := net.SplitHostPort(upstream)
+ if err != nil {
+ return nil, fmt.Errorf("split hostPort: %w", err)
+ }
+
+ _, err = strconv.Atoi(upstreamPortStr)
+ if err != nil {
+ return nil, fmt.Errorf("atoi: %w", err)
+ }
+
+ return &proxy{
+ Name: name,
+ Upstream: upstream,
+ Enabled: true,
+ }, nil
+}
diff --git a/modules/toxiproxy/examples_test.go b/modules/toxiproxy/examples_test.go
new file mode 100644
index 0000000000..0f294dd7b5
--- /dev/null
+++ b/modules/toxiproxy/examples_test.go
@@ -0,0 +1,354 @@
+package toxiproxy_test
+
+import (
+ "context"
+ "fmt"
+ "log"
+ "time"
+
+ toxiproxy "github.com/Shopify/toxiproxy/v2/client"
+ "github.com/go-redis/redis/v8"
+ "github.com/google/uuid"
+
+ "github.com/testcontainers/testcontainers-go"
+ tcredis "github.com/testcontainers/testcontainers-go/modules/redis"
+ tctoxiproxy "github.com/testcontainers/testcontainers-go/modules/toxiproxy"
+ "github.com/testcontainers/testcontainers-go/network"
+)
+
+func ExampleRun() {
+ // runToxiproxyContainer {
+ ctx := context.Background()
+
+ toxiproxyContainer, err := tctoxiproxy.Run(
+ ctx,
+ "ghcr.io/shopify/toxiproxy:2.12.0",
+ )
+ defer func() {
+ if err := testcontainers.TerminateContainer(toxiproxyContainer); err != nil {
+ log.Printf("failed to terminate container: %s", err)
+ }
+ }()
+ if err != nil {
+ log.Printf("failed to start container: %s", err)
+ return
+ }
+ // }
+
+ state, err := toxiproxyContainer.State(ctx)
+ if err != nil {
+ log.Printf("failed to get container state: %s", err)
+ return
+ }
+
+ fmt.Println(state.Running)
+
+ // Output:
+ // true
+}
+
+func ExampleRun_addLatency() {
+ ctx := context.Background()
+
+ nw, err := network.New(ctx)
+ if err != nil {
+ log.Printf("failed to create network: %v", err)
+ return
+ }
+ defer func() {
+ if err := nw.Remove(ctx); err != nil {
+ log.Printf("failed to remove network: %s", err)
+ }
+ }()
+
+ redisContainer, err := tcredis.Run(
+ ctx,
+ "redis:6-alpine",
+ network.WithNetwork([]string{"redis"}, nw),
+ )
+ defer func() {
+ if err := testcontainers.TerminateContainer(redisContainer); err != nil {
+ log.Printf("failed to terminate container: %s", err)
+ }
+ }()
+ if err != nil {
+ log.Printf("failed to start container: %s", err)
+ return
+ }
+
+ // defineContainerExposingPort {
+ const proxyPort = "8666"
+
+ // No need to create a proxy, as we are programmatically adding it below.
+ toxiproxyContainer, err := tctoxiproxy.Run(
+ ctx,
+ "ghcr.io/shopify/toxiproxy:2.12.0",
+ network.WithNetwork([]string{"toxiproxy"}, nw),
+ // explicitly expose the ports that will be proxied using the programmatic API
+ // of the toxiproxy client. Otherwise, the ports will not be exposed and the
+ // toxiproxy client will not be able to connect to the proxy.
+ testcontainers.WithExposedPorts(proxyPort+"/tcp"),
+ )
+ defer func() {
+ if err := testcontainers.TerminateContainer(toxiproxyContainer); err != nil {
+ log.Printf("failed to terminate container: %s", err)
+ }
+ }()
+ if err != nil {
+ log.Printf("failed to start container: %s", err)
+ return
+ }
+ // }
+
+ // createToxiproxyClient {
+ toxiURI, err := toxiproxyContainer.URI(ctx)
+ if err != nil {
+ log.Printf("failed to get toxiproxy container uri: %s", err)
+ return
+ }
+
+ toxiproxyClient := toxiproxy.NewClient(toxiURI)
+ // }
+
+ // createProxy {
+ // Create the proxy using the network alias of the redis container,
+ // as they run on the same network.
+ proxy, err := toxiproxyClient.CreateProxy("redis", "0.0.0.0:"+proxyPort, "redis:6379")
+ if err != nil {
+ log.Printf("failed to create proxy: %s", err)
+ return
+ }
+ // }
+
+ toxiproxyProxyPort, err := toxiproxyContainer.MappedPort(ctx, proxyPort+"/tcp")
+ if err != nil {
+ log.Printf("failed to get toxiproxy container port: %s", err)
+ return
+ }
+
+ toxiproxyProxyHostIP, err := toxiproxyContainer.Host(ctx)
+ if err != nil {
+ log.Printf("failed to get toxiproxy container host: %s", err)
+ return
+ }
+
+ // createRedisClient {
+ // Create a redis client that connects to the toxiproxy container.
+ // We are defining a read timeout of 2 seconds, because we are adding
+ // a latency toxic of 1 second to the request, +/- 100ms jitter.
+ redisURI := fmt.Sprintf("redis://%s:%s?read_timeout=2s", toxiproxyProxyHostIP, toxiproxyProxyPort.Port())
+
+ options, err := redis.ParseURL(redisURI)
+ if err != nil {
+ log.Printf("failed to parse url: %s", err)
+ return
+ }
+
+ redisCli := redis.NewClient(options)
+ defer func() {
+ if err := redisCli.FlushAll(ctx).Err(); err != nil {
+ log.Printf("failed to flush redis: %s", err)
+ }
+ }()
+ // }
+
+ key := fmt.Sprintf("{user.%s}.favoritefood", uuid.NewString())
+ value := "Cabbage Biscuits"
+ ttl, err := time.ParseDuration("2h")
+ if err != nil {
+ log.Printf("failed to parse duration: %s", err)
+ return
+ }
+
+ err = redisCli.Set(ctx, key, value, ttl).Err()
+ if err != nil {
+ log.Printf("failed to set data: %s", err)
+ return
+ }
+
+ // addLatencyToxic {
+ const (
+ latency = 1_000
+ jitter = 200
+ )
+ // Add a latency toxic to the proxy
+ _, err = proxy.AddToxic("latency_down", "latency", "downstream", 1.0, toxiproxy.Attributes{
+ "latency": latency,
+ "jitter": jitter,
+ })
+ if err != nil {
+ log.Printf("failed to add toxic: %s", err)
+ return
+ }
+ // }
+
+ start := time.Now()
+ // Get data
+ savedValue, err := redisCli.Get(ctx, key).Result()
+ if err != nil {
+ log.Printf("failed to get data: %s", err)
+ return
+ }
+ duration := time.Since(start)
+
+ log.Println("Duration:", duration)
+
+ // The value is retrieved successfully
+ fmt.Println(savedValue)
+
+ // Check that latency is within expected range (200ms-1200ms)
+ // The latency toxic adds 1000ms (1000ms +/- 200ms jitter)
+ minDuration := (latency - jitter) * time.Millisecond
+ maxDuration := (latency + jitter) * time.Millisecond
+ fmt.Printf("Duration is between %dms and %dms: %v\n",
+ minDuration.Milliseconds(),
+ maxDuration.Milliseconds(),
+ duration >= minDuration && duration <= maxDuration)
+
+ // Output:
+ // Cabbage Biscuits
+ // Duration is between 800ms and 1200ms: true
+}
+
+func ExampleRun_connectionCut() {
+ ctx := context.Background()
+
+ nw, err := network.New(ctx)
+ if err != nil {
+ log.Printf("failed to create network: %v", err)
+ return
+ }
+ defer func() {
+ if err := nw.Remove(ctx); err != nil {
+ log.Printf("failed to remove network: %s", err)
+ }
+ }()
+
+ redisContainer, err := tcredis.Run(
+ ctx,
+ "redis:6-alpine",
+ network.WithNetwork([]string{"redis"}, nw),
+ )
+ defer func() {
+ if err := testcontainers.TerminateContainer(redisContainer); err != nil {
+ log.Printf("failed to terminate container: %s", err)
+ }
+ }()
+ if err != nil {
+ log.Printf("failed to start container: %s", err)
+ return
+ }
+
+ toxiproxyContainer, err := tctoxiproxy.Run(
+ ctx,
+ "ghcr.io/shopify/toxiproxy:2.12.0",
+ // We create a proxy named "redis" that points to the redis container.
+ tctoxiproxy.WithProxy("redis", "redis:6379"),
+ network.WithNetwork([]string{"toxiproxy"}, nw),
+ )
+ defer func() {
+ if err := testcontainers.TerminateContainer(toxiproxyContainer); err != nil {
+ log.Printf("failed to terminate container: %s", err)
+ }
+ }()
+ if err != nil {
+ log.Printf("failed to start container: %s", err)
+ return
+ }
+
+ // getProxiedEndpoint {
+ proxiedRedisHost, proxiedRedisPort, err := toxiproxyContainer.ProxiedEndpoint(8666)
+ if err != nil {
+ log.Printf("failed to get toxiproxy container port: %s", err)
+ return
+ }
+ // }
+
+ toxiURI, err := toxiproxyContainer.URI(ctx)
+ if err != nil {
+ log.Printf("failed to get toxiproxy container uri: %s", err)
+ return
+ }
+
+ toxiproxyClient := toxiproxy.NewClient(toxiURI)
+
+ // Retrieve the existing proxy
+ proxies, err := toxiproxyClient.Proxies()
+ if err != nil {
+ log.Printf("failed to get proxies: %s", err)
+ return
+ }
+
+ proxy := proxies["redis"]
+
+ // readProxiedEndpoint {
+ // Create a redis client that connects to the toxiproxy container.
+ // We are defining a read timeout of 2 seconds, because we are adding
+ // a latency toxic of 1.1 seconds to the request.
+ redisURI := fmt.Sprintf("redis://%s:%s?read_timeout=2s", proxiedRedisHost, proxiedRedisPort)
+ // }
+
+ options, err := redis.ParseURL(redisURI)
+ if err != nil {
+ log.Printf("failed to parse url: %s", err)
+ return
+ }
+
+ redisCli := redis.NewClient(options)
+ defer func() {
+ if err := redisCli.FlushAll(ctx).Err(); err != nil {
+ log.Printf("failed to flush redis: %s", err)
+ }
+ }()
+
+ key := fmt.Sprintf("{user.%s}.favoritefood", uuid.NewString())
+ value := "Cabbage Biscuits"
+ ttl, err := time.ParseDuration("2h")
+ if err != nil {
+ log.Printf("failed to parse duration: %s", err)
+ return
+ }
+
+ err = redisCli.Set(ctx, key, value, ttl).Err()
+ if err != nil {
+ log.Printf("failed to set data: %s", err)
+ return
+ }
+
+ // Disable the proxy
+ err = proxy.Disable()
+ if err != nil {
+ log.Printf("failed to disable proxy: %s", err)
+ return
+ }
+
+ // Get data
+ savedValue, err := redisCli.Get(ctx, key).Result()
+ if err == nil {
+ log.Printf("proxy is disabled, but we got data")
+ return
+ }
+
+ // The value is not retrieved at all, so it's empty
+ fmt.Println(savedValue)
+
+ // Re-enable the proxy
+ err = proxy.Enable()
+ if err != nil {
+ log.Printf("failed to enable proxy: %s", err)
+ return
+ }
+
+ savedValue, err = redisCli.Get(ctx, key).Result()
+ if err != nil {
+ log.Printf("failed to get data: %s", err)
+ return
+ }
+
+ // The value is retrieved successfully
+ fmt.Println(savedValue)
+
+ // Output:
+ //
+ // Cabbage Biscuits
+}
diff --git a/examples/toxiproxy/go.mod b/modules/toxiproxy/go.mod
similarity index 73%
rename from examples/toxiproxy/go.mod
rename to modules/toxiproxy/go.mod
index 160e4ce5a6..3555dac0c8 100644
--- a/examples/toxiproxy/go.mod
+++ b/modules/toxiproxy/go.mod
@@ -1,15 +1,15 @@
-module github.com/testcontainers/testcontainers-go/examples/toxiproxy
+module github.com/testcontainers/testcontainers-go/modules/toxiproxy
go 1.23.0
-toolchain go1.23.6
-
require (
- github.com/Shopify/toxiproxy/v2 v2.8.0
+ github.com/Shopify/toxiproxy/v2 v2.12.0
+ github.com/docker/go-connections v0.5.0
github.com/go-redis/redis/v8 v8.11.5
github.com/google/uuid v1.6.0
github.com/stretchr/testify v1.10.0
github.com/testcontainers/testcontainers-go v0.36.0
+ github.com/testcontainers/testcontainers-go/modules/redis v0.36.0
)
require (
@@ -17,7 +17,7 @@ require (
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/cenkalti/backoff/v4 v4.2.1 // indirect
- github.com/cespare/xxhash/v2 v2.2.0 // indirect
+ github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/containerd/platforms v0.2.1 // indirect
github.com/cpuguy83/dockercfg v0.3.2 // indirect
@@ -25,16 +25,14 @@ require (
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/docker v28.0.1+incompatible // indirect
- github.com/docker/go-connections v0.5.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/ebitengine/purego v0.8.2 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
- github.com/fsnotify/fsnotify v1.6.0 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
- github.com/klauspost/compress v1.17.4 // indirect
+ github.com/klauspost/compress v1.17.11 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/magiconair/properties v1.8.9 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
@@ -57,16 +55,21 @@ require (
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect
go.opentelemetry.io/otel v1.35.0 // indirect
- go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0 // indirect
+ go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 // indirect
go.opentelemetry.io/otel/metric v1.35.0 // indirect
+ go.opentelemetry.io/otel/sdk v1.35.0 // indirect
go.opentelemetry.io/otel/trace v1.35.0 // indirect
- go.opentelemetry.io/proto/otlp v1.0.0 // indirect
+ go.opentelemetry.io/proto/otlp v1.5.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
+ google.golang.org/genproto/googleapis/api v0.0.0-20250407143221-ac9807e6c755 // indirect
+ google.golang.org/genproto/googleapis/rpc v0.0.0-20250407143221-ac9807e6c755 // indirect
+ google.golang.org/grpc v1.71.0 // indirect
+ google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
-replace github.com/testcontainers/testcontainers-go => ../..
+replace (
+ github.com/testcontainers/testcontainers-go => ../..
+ github.com/testcontainers/testcontainers-go/modules/redis => ../redis
+)
diff --git a/examples/toxiproxy/go.sum b/modules/toxiproxy/go.sum
similarity index 88%
rename from examples/toxiproxy/go.sum
rename to modules/toxiproxy/go.sum
index b48f4003c7..a8474c8fa9 100644
--- a/examples/toxiproxy/go.sum
+++ b/modules/toxiproxy/go.sum
@@ -6,12 +6,12 @@ github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOEl
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
-github.com/Shopify/toxiproxy/v2 v2.8.0 h1:d7OzvAc0Rco3QO8jVsDSfadQ1up0Ca42hK+EGEpnQWs=
-github.com/Shopify/toxiproxy/v2 v2.8.0/go.mod h1:k0V84e/dLQmVNuI6S0G7TpXCl611OSRYdptoxm0XTzA=
+github.com/Shopify/toxiproxy/v2 v2.12.0 h1:d1x++lYZg/zijXPPcv7PH0MvHMzEI5aX/YuUi/Sw+yg=
+github.com/Shopify/toxiproxy/v2 v2.12.0/go.mod h1:R9Z38Pw6k2cGZWXHe7tbxjGW9azmY1KbDQJ1kd+h7Tk=
github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM=
github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
-github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
-github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
+github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
@@ -55,12 +55,12 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 h1:YBftPWNWd4WwGqtY2yeZL2ef8rHAxPBD8KFhJpmcqms=
-github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0/go.mod h1:YN5jB8ie0yfIUg6VvR9Kz84aCaG7AsGZnLjhHbUqwPg=
+github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1 h1:VNqngBF40hVlDloBruUehVYC3ArSgIyScOAyMRqBxRg=
+github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1/go.mod h1:RBRO7fro65R6tjKzYgLAFo0t1QEXY1Dp+i/bvpRiqiQ=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
-github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=
-github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
+github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
+github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
@@ -125,18 +125,18 @@ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw=
go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ=
go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y=
-go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0 h1:Mne5On7VWdx7omSrSSZvM4Kw7cS7NQkOOmLcgscI51U=
-go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0/go.mod h1:IPtUMKL4O3tH5y+iXVyAXqpAwMuzC1IrxVS81rummfE=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 h1:1fTNlAIJZGWLP5FVu0fikVry1IsiUnXjf7QFvoNN3Xw=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0/go.mod h1:zjPK58DtkqQFn+YUMbx0M2XV3QgKU0gS9LeGohREyK4=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU=
go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M=
go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE=
-go.opentelemetry.io/otel/sdk v1.19.0 h1:6USY6zH+L8uMH8L3t1enZPR3WFEmSTADlqldyHtJi3o=
-go.opentelemetry.io/otel/sdk v1.19.0/go.mod h1:NedEbbS4w3C6zElbLdPJKOpJQOrGUJ+GfzpjUvI0v1A=
+go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY=
+go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg=
go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs=
go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
-go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lIVU/I=
-go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM=
+go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4=
+go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
@@ -160,7 +160,6 @@ golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
@@ -181,14 +180,14 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-google.golang.org/genproto/googleapis/api v0.0.0-20240318140521-94a12d6c2237 h1:RFiFrvy37/mpSpdySBDrUdipW/dHwsRwh3J3+A9VgT4=
-google.golang.org/genproto/googleapis/api v0.0.0-20240318140521-94a12d6c2237/go.mod h1:Z5Iiy3jtmioajWHDGFk7CeugTyHtPvMHA4UTmUkyalE=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20240318140521-94a12d6c2237 h1:NnYq6UN9ReLM9/Y01KWNOWyI5xQ9kbIms5GGJVwS/Yc=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20240318140521-94a12d6c2237/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY=
-google.golang.org/grpc v1.64.1 h1:LKtvyfbX3UGVPFcGqJ9ItpVWW6oN/2XqTxfAnwRRXiA=
-google.golang.org/grpc v1.64.1/go.mod h1:hiQF4LFZelK2WKaP6W0L92zGHtiQdZxk8CrSdvyjeP0=
-google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
-google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
+google.golang.org/genproto/googleapis/api v0.0.0-20250407143221-ac9807e6c755 h1:AMLTAunltONNuzWgVPZXrjLWtXpsG6A3yLLPEoJ/IjU=
+google.golang.org/genproto/googleapis/api v0.0.0-20250407143221-ac9807e6c755/go.mod h1:2R6XrVC8Oc08GlNh8ujEpc7HkLiEZ16QeY7FxIs20ac=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20250407143221-ac9807e6c755 h1:TwXJCGVREgQ/cl18iY0Z4wJCTL/GmW+Um2oSwZiZPnc=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20250407143221-ac9807e6c755/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
+google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg=
+google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec=
+google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
+google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
diff --git a/modules/toxiproxy/options.go b/modules/toxiproxy/options.go
new file mode 100644
index 0000000000..99f80e6cf1
--- /dev/null
+++ b/modules/toxiproxy/options.go
@@ -0,0 +1,76 @@
+package toxiproxy
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+
+ "github.com/testcontainers/testcontainers-go"
+)
+
+type options struct {
+ proxies []*proxy
+}
+
+func defaultOptions() options {
+ return options{
+ proxies: []*proxy{},
+ }
+}
+
+// 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
+}
+
+// WithProxy creates a new proxy configuration for the given name and upstream.
+// If the upstream is not a valid IP address and port, it returns an error.
+// If this option is used in combination with the [WithConfigFile] option, the proxy defined in this option
+// is added to the existing proxies.
+func WithProxy(name string, upstream string) Option {
+ return func(o *options) error {
+ proxy, err := newProxy(name, upstream)
+ if err != nil {
+ return fmt.Errorf("newProxy: %w", err)
+ }
+
+ o.proxies = append(o.proxies, proxy)
+ return nil
+ }
+}
+
+// WithConfigFile sets the config file for the Toxiproxy container, copying
+// the file to the "/tmp/tc-toxiproxy.json" path. It also appends the "-host=0.0.0.0"
+// and "-config=/tmp/tc-toxiproxy.json" flags to the command line.
+// The config file is a JSON file that contains the configuration for the Toxiproxy container,
+// and it is not validated by the Toxiproxy container.
+// If this option is used in combination with the [WithProxy] option, the proxies defined in this option
+// are added to the existing proxies.
+func WithConfigFile(r io.Reader) Option {
+ return func(o *options) error {
+ // unmarshal the config file
+ var config []proxy
+ err := json.NewDecoder(r).Decode(&config)
+ if err != nil {
+ return fmt.Errorf("unmarshal: %w", err)
+ }
+
+ for _, proxy := range config {
+ proxy, err := newProxy(proxy.Name, proxy.Upstream)
+ if err != nil {
+ return fmt.Errorf("newProxy: %w", err)
+ }
+
+ o.proxies = append(o.proxies, proxy)
+ }
+
+ return nil
+ }
+}
diff --git a/modules/toxiproxy/options_test.go b/modules/toxiproxy/options_test.go
new file mode 100644
index 0000000000..6447168730
--- /dev/null
+++ b/modules/toxiproxy/options_test.go
@@ -0,0 +1,182 @@
+package toxiproxy
+
+import (
+ "context"
+ "encoding/json"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/testcontainers/testcontainers-go"
+ tcredis "github.com/testcontainers/testcontainers-go/modules/redis"
+ "github.com/testcontainers/testcontainers-go/network"
+)
+
+func TestWithProxy(t *testing.T) {
+ t.Run("upstream-is-valid", func(t *testing.T) {
+ opt := WithProxy("redis", "redis:6379")
+
+ var opts options
+ err := opt(&opts)
+ require.NoError(t, err)
+
+ require.Equal(t, "redis", opts.proxies[0].Name)
+ })
+
+ t.Run("upstream-is-invalid", func(t *testing.T) {
+ opt := WithProxy("redis", "redis:6379:80")
+
+ var opts options
+ err := opt(&opts)
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "split hostPort")
+ })
+
+ t.Run("upstream-is-invalid-port", func(t *testing.T) {
+ opt := WithProxy("redis", "redis:abcde")
+
+ var opts options
+ err := opt(&opts)
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "atoi")
+ })
+}
+
+func TestWithConfigFile(t *testing.T) {
+ t.Run("config-file-is-valid", func(t *testing.T) {
+ opt := WithConfigFile(strings.NewReader(`[]`))
+
+ var opts options
+ err := opt(&opts)
+ require.NoError(t, err)
+ require.Empty(t, opts.proxies)
+ })
+
+ t.Run("config-file-is-invalid", func(t *testing.T) {
+ opt := WithConfigFile(strings.NewReader(`invalid`))
+
+ var opts options
+ err := opt(&opts)
+ require.Error(t, err)
+ })
+
+ t.Run("config-file-is-valid-with-proxy", func(t *testing.T) {
+ opt := WithConfigFile(strings.NewReader(`[{"name": "redis", "listen": "0.0.0.0:8666", "upstream": "redis:6379", "enabled": true}]`))
+
+ var opts options
+ err := opt(&opts)
+ require.NoError(t, err)
+ require.Equal(t, "redis", opts.proxies[0].Name)
+ require.Equal(t, "redis:6379", opts.proxies[0].Upstream)
+ require.Empty(t, opts.proxies[0].Listen) // listen is set by the container, as it knows the port
+ require.True(t, opts.proxies[0].Enabled)
+ })
+
+ t.Run("config-file-is-valid-with-multiple-proxies", func(t *testing.T) {
+ opt := WithConfigFile(strings.NewReader(`[{"name": "redis", "listen": "0.0.0.0:8666", "upstream": "redis:6379", "enabled": true}, {"name": "redis2", "listen": "0.0.0.0:8667", "upstream": "redis2:6379", "enabled": true}]`))
+
+ var opts options
+ err := opt(&opts)
+ require.NoError(t, err)
+ require.Len(t, opts.proxies, 2)
+ require.Equal(t, "redis", opts.proxies[0].Name)
+ require.Equal(t, "redis:6379", opts.proxies[0].Upstream)
+ require.Empty(t, opts.proxies[0].Listen) // listen is set by the container, as it knows the port
+ require.True(t, opts.proxies[0].Enabled)
+ require.Equal(t, "redis2", opts.proxies[1].Name)
+ require.Equal(t, "redis2:6379", opts.proxies[1].Upstream)
+ require.Empty(t, opts.proxies[1].Listen) // listen is set by the container, as it knows the port
+ })
+
+ t.Run("config-file-is-valid-with-invalid-proxy", func(t *testing.T) {
+ opt := WithConfigFile(strings.NewReader(`[{"name": "redis", "listen": "0.0.0.0:8666", "upstream": "redis2:6379:80", "enabled": true}]`))
+
+ var opts options
+ err := opt(&opts)
+ require.Error(t, err)
+ })
+}
+
+func TestRun_withConfigFile_and_proxy(t *testing.T) {
+ configContent := `[
+ {
+ "name": "redis",
+ "listen": "0.0.0.0:8666",
+ "upstream": "redis:6379",
+ "enabled": true
+ }
+]`
+
+ ctx := context.Background()
+
+ nw, err := network.New(ctx)
+ require.NoError(t, err)
+ t.Cleanup(func() {
+ require.NoError(t, nw.Remove(ctx))
+ })
+
+ redisContainer, err := tcredis.Run(
+ ctx,
+ "redis:6-alpine",
+ network.WithNetwork([]string{"redis"}, nw),
+ )
+ testcontainers.CleanupContainer(t, redisContainer)
+ require.NoError(t, err)
+
+ toxiproxyContainer, err := Run(
+ ctx,
+ "ghcr.io/shopify/toxiproxy:2.12.0",
+ // the config file defines a proxy named "redis"
+ WithConfigFile(strings.NewReader(configContent)),
+ // this proxy will be added to the existing proxies
+ WithProxy("redis2", "redis2:6379"),
+ network.WithNetwork([]string{"toxiproxy"}, nw),
+ )
+ testcontainers.CleanupContainer(t, toxiproxyContainer)
+ require.NoError(t, err)
+
+ t.Run("config-file/exists", func(t *testing.T) {
+ rc, err := toxiproxyContainer.CopyFileFromContainer(ctx, "/tmp/tc-toxiproxy.json")
+ require.NoError(t, err)
+
+ // check that the config file contains two proxies
+ var config []proxy
+ err = json.NewDecoder(rc).Decode(&config)
+ require.NoError(t, err)
+ require.Len(t, config, 2)
+
+ require.Contains(t, config, proxy{
+ Name: "redis",
+ Listen: "0.0.0.0:8666",
+ Upstream: "redis:6379",
+ Enabled: true,
+ })
+
+ require.Contains(t, config, proxy{
+ Name: "redis2",
+ Listen: "0.0.0.0:8667",
+ Upstream: "redis2:6379",
+ Enabled: true,
+ })
+ })
+
+ t.Run("proxied-endpoint/exists", func(t *testing.T) {
+ host, port, err := toxiproxyContainer.ProxiedEndpoint(8666)
+ require.NoError(t, err)
+ require.NotEmpty(t, host)
+ require.NotEmpty(t, port)
+
+ host, port, err = toxiproxyContainer.ProxiedEndpoint(8667)
+ require.NoError(t, err)
+ require.NotEmpty(t, host)
+ require.NotEmpty(t, port)
+ })
+
+ t.Run("proxied-endpoint/does-not-exist", func(t *testing.T) {
+ host, port, err := toxiproxyContainer.ProxiedEndpoint(9999)
+ require.Error(t, err)
+ require.Empty(t, host)
+ require.Empty(t, port)
+ })
+}
diff --git a/modules/toxiproxy/testdata/toxiproxy.json b/modules/toxiproxy/testdata/toxiproxy.json
new file mode 100644
index 0000000000..2cda02e05f
--- /dev/null
+++ b/modules/toxiproxy/testdata/toxiproxy.json
@@ -0,0 +1,8 @@
+[
+ {
+ "name": "redis",
+ "listen": "0.0.0.0:8666",
+ "upstream": "redis:6379",
+ "enabled": true
+ }
+]
\ No newline at end of file
diff --git a/modules/toxiproxy/toxiproxy.go b/modules/toxiproxy/toxiproxy.go
new file mode 100644
index 0000000000..eb018b58de
--- /dev/null
+++ b/modules/toxiproxy/toxiproxy.go
@@ -0,0 +1,139 @@
+package toxiproxy
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net"
+ "net/http"
+
+ "github.com/docker/go-connections/nat"
+
+ "github.com/testcontainers/testcontainers-go"
+ "github.com/testcontainers/testcontainers-go/wait"
+)
+
+const (
+ // ControlPort is the port of the Toxiproxy control API
+ ControlPort = "8474/tcp"
+
+ // firstProxiedPort is the first port of the range of ports that will be proxied
+ firstProxiedPort = 8666
+)
+
+// Container represents the Toxiproxy container type used in the module
+type Container struct {
+ testcontainers.Container
+
+ // proxiedEndpoints is a map of the proxied endpoints of the Toxiproxy container
+ proxiedEndpoints map[int]string
+}
+
+// ProxiedEndpoint returns the endpoint for the proxied port in the Toxiproxy container,
+// an error in case the port has no proxied endpoint.
+func (c *Container) ProxiedEndpoint(p int) (string, string, error) {
+ endpoint, ok := c.proxiedEndpoints[p]
+ if !ok {
+ return "", "", errors.New("port not found")
+ }
+
+ return net.SplitHostPort(endpoint)
+}
+
+// URI returns the URI of the Toxiproxy container
+func (c *Container) URI(ctx context.Context) (string, error) {
+ portEndpoint, err := c.PortEndpoint(ctx, ControlPort, "http")
+ if err != nil {
+ return "", fmt.Errorf("port endpoint: %w", err)
+ }
+
+ return portEndpoint, nil
+}
+
+// Run creates an instance of the Toxiproxy container type
+func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*Container, error) {
+ req := testcontainers.ContainerRequest{
+ Image: img,
+ ExposedPorts: []string{ControlPort},
+ WaitingFor: wait.ForHTTP("/version").WithPort(ControlPort).WithStatusCodeMatcher(func(status int) bool {
+ return status == http.StatusOK
+ }),
+ }
+
+ genericContainerReq := testcontainers.GenericContainerRequest{
+ ContainerRequest: req,
+ Started: true,
+ }
+
+ settings := defaultOptions()
+ for _, opt := range opts {
+ if apply, ok := opt.(Option); ok {
+ if err := apply(&settings); err != nil {
+ return nil, fmt.Errorf("apply: %w", err)
+ }
+ }
+ if err := opt.Customize(&genericContainerReq); err != nil {
+ return nil, fmt.Errorf("customize: %w", err)
+ }
+ }
+
+ // Expose the ports for the proxies, starting from the first proxied port
+ portsInRange := make([]string, 0, len(settings.proxies))
+ for i, proxy := range settings.proxies {
+ proxiedPort := firstProxiedPort + i
+ // Update the listen port of the proxy
+ proxy.Listen = fmt.Sprintf("0.0.0.0:%d", proxiedPort)
+ portsInRange = append(portsInRange, fmt.Sprintf("%d/tcp", proxiedPort))
+ }
+ genericContainerReq.ExposedPorts = append(genericContainerReq.ExposedPorts, portsInRange...)
+
+ // Render the config file
+ jsonData, err := json.MarshalIndent(settings.proxies, "", " ")
+ if err != nil {
+ return nil, fmt.Errorf("marshal: %w", err)
+ }
+
+ // Apply the config file to the container with the proxies.
+ if len(settings.proxies) > 0 {
+ genericContainerReq.Files = append(genericContainerReq.Files, testcontainers.ContainerFile{
+ Reader: bytes.NewReader(jsonData),
+ ContainerFilePath: "/tmp/tc-toxiproxy.json",
+ FileMode: 0o644,
+ })
+ genericContainerReq.Cmd = append(genericContainerReq.Cmd, "-host=0.0.0.0", "-config=/tmp/tc-toxiproxy.json")
+ }
+
+ container, err := testcontainers.GenericContainer(ctx, genericContainerReq)
+ var c *Container
+ if container != nil {
+ c = &Container{Container: container, proxiedEndpoints: make(map[int]string)}
+ }
+
+ if err != nil {
+ return c, fmt.Errorf("generic container: %w", err)
+ }
+
+ // Map the ports of the proxies to the container, so that we can use them in the tests
+ for _, proxy := range settings.proxies {
+ err := proxy.sanitize()
+ if err != nil {
+ return c, fmt.Errorf("sanitize: %w", err)
+ }
+
+ host, err := c.Host(ctx)
+ if err != nil {
+ return c, fmt.Errorf("host: %w", err)
+ }
+
+ mappedPort, err := c.MappedPort(ctx, nat.Port(fmt.Sprintf("%d/tcp", proxy.listenPort)))
+ if err != nil {
+ return c, fmt.Errorf("mapped port: %w", err)
+ }
+
+ c.proxiedEndpoints[proxy.listenPort] = net.JoinHostPort(host, mappedPort.Port())
+ }
+
+ return c, nil
+}
diff --git a/modules/toxiproxy/toxiproxy_test.go b/modules/toxiproxy/toxiproxy_test.go
new file mode 100644
index 0000000000..119f271e70
--- /dev/null
+++ b/modules/toxiproxy/toxiproxy_test.go
@@ -0,0 +1,130 @@
+package toxiproxy_test
+
+import (
+ "bytes"
+ "context"
+ _ "embed"
+ "fmt"
+ "testing"
+ "time"
+
+ toxiproxy "github.com/Shopify/toxiproxy/v2/client"
+ "github.com/go-redis/redis/v8"
+ "github.com/google/uuid"
+ "github.com/stretchr/testify/require"
+
+ "github.com/testcontainers/testcontainers-go"
+ tcredis "github.com/testcontainers/testcontainers-go/modules/redis"
+ tctoxiproxy "github.com/testcontainers/testcontainers-go/modules/toxiproxy"
+ "github.com/testcontainers/testcontainers-go/network"
+)
+
+func TestRun(t *testing.T) {
+ ctx := context.Background()
+
+ ctr, err := tctoxiproxy.Run(ctx, "ghcr.io/shopify/toxiproxy:2.12.0")
+ testcontainers.CleanupContainer(t, ctr)
+ require.NoError(t, err)
+
+ // perform assertions
+}
+
+//go:embed testdata/toxiproxy.json
+var configFile []byte
+
+func TestRun_withConfigFile(t *testing.T) {
+ ctx := context.Background()
+
+ nw, err := network.New(ctx)
+ require.NoError(t, err)
+ t.Cleanup(func() {
+ require.NoError(t, nw.Remove(ctx))
+ })
+
+ redisContainer, err := tcredis.Run(
+ ctx,
+ "redis:6-alpine",
+ network.WithNetwork([]string{"redis"}, nw),
+ )
+ testcontainers.CleanupContainer(t, redisContainer)
+ require.NoError(t, err)
+
+ toxiproxyContainer, err := tctoxiproxy.Run(
+ ctx,
+ "ghcr.io/shopify/toxiproxy:2.12.0",
+ tctoxiproxy.WithConfigFile(bytes.NewReader(configFile)),
+ network.WithNetwork([]string{"toxiproxy"}, nw),
+ )
+ testcontainers.CleanupContainer(t, toxiproxyContainer)
+ require.NoError(t, err)
+
+ toxiURI, err := toxiproxyContainer.URI(ctx)
+ require.NoError(t, err)
+
+ toxiproxyClient := toxiproxy.NewClient(toxiURI)
+
+ toxiproxyProxyPort, err := toxiproxyContainer.MappedPort(ctx, "8666/tcp")
+ require.NoError(t, err)
+
+ toxiproxyProxyHostIP, err := toxiproxyContainer.Host(ctx)
+ require.NoError(t, err)
+
+ // Create a redis client that connects to the toxiproxy container.
+ // We are defining a read timeout of 2 seconds, because we are adding
+ // a latency toxic of 1 second to the request, +/- 100ms jitter.
+ redisURI := fmt.Sprintf("redis://%s:%s?read_timeout=2s", toxiproxyProxyHostIP, toxiproxyProxyPort.Port())
+
+ options, err := redis.ParseURL(redisURI)
+ require.NoError(t, err)
+
+ redisCli := redis.NewClient(options)
+ t.Cleanup(func() {
+ require.NoError(t, redisCli.FlushAll(ctx).Err())
+ })
+
+ key := fmt.Sprintf("{user.%s}.favoritefood", uuid.NewString())
+ value := "Cabbage Biscuits"
+ ttl, err := time.ParseDuration("2h")
+ require.NoError(t, err)
+
+ err = redisCli.Set(ctx, key, value, ttl).Err()
+ require.NoError(t, err)
+
+ const (
+ latency = 1_000
+ jitter = 200
+ )
+
+ // Add a latency toxic to the proxy
+ toxicOptions := &toxiproxy.ToxicOptions{
+ ProxyName: "redis", // name of the proxy in the config file
+ ToxicName: "latency_down",
+ ToxicType: "latency",
+ Toxicity: 1.0,
+ Stream: "downstream",
+ Attributes: map[string]any{
+ "latency": latency,
+ "jitter": jitter,
+ },
+ }
+ _, err = toxiproxyClient.AddToxic(toxicOptions)
+ require.NoError(t, err)
+
+ start := time.Now()
+ // Get data
+ savedValue, err := redisCli.Get(ctx, key).Result()
+ require.NoError(t, err)
+
+ duration := time.Since(start)
+
+ t.Logf("Duration: %s\n", duration)
+
+ // The value is retrieved successfully
+ require.Equal(t, value, savedValue)
+
+ // Check that latency is within expected range (900ms-1100ms)
+ // The latency toxic adds 1000ms (1000ms +/- 100ms jitter)
+ minDuration := (latency - jitter) * time.Millisecond
+ maxDuration := (latency + jitter) * time.Millisecond
+ require.True(t, duration >= minDuration && duration <= maxDuration)
+}