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) +}