From 48fc22882cb0e99a93807343c779726fffae6800 Mon Sep 17 00:00:00 2001 From: Guillaume St-Pierre Date: Wed, 14 Feb 2024 13:05:39 -0500 Subject: [PATCH] feat (postgres): support for creating and restoring Snapshots (#2199) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add tips and examples to the documentation I recently went through building a complex testing setup for an entire webservice using testcontainers-go, which taught me some important tips and tricks about the library. I have added these in the documentation wherever I thought they could be useful. I also added a new example for using the postgres module and making each test use a clean database. It's a pretty useful tool, and it's available as an example in the Java version, so I've added it here as well. * Added the example to the sidebar * Move everything to the module * Remove testing stuff I added to debug the integration * Apply review feedback and rename reset to restore since it makes more sense in context * Update modules/postgres/postgres.go Co-authored-by: Manuel de la Peña * Add test examples for file copy and update the docs * Add the wait for hello file to help understanding the example * chore: use pinned version of the image * chore: simplify waiting for an expected log * chore: check for errors in tests * docs: use dot * docs: simplify tabs * chore: simplify variables and paths * docs: remove misleading comment * docs: move customise requests to each module * docs: swap places * chore: less verbose * chore: use testify assertions * Revert "chore: use testify assertions" This reverts commit b7d6e902fcf201cb84a2b125a3c416908ce6bb27. --------- Co-authored-by: Manuel de la Peña Co-authored-by: Manuel de la Peña --- docker_files_test.go | 213 ++++++++++++++++++++ docs/features/common_functional_options.md | 20 ++ docs/features/files_and_mounts.md | 76 ++----- docs/features/override_container_command.md | 7 + docs/features/wait/http.md | 3 + docs/modules/postgres.md | 11 + modules/postgres/go.mod | 5 + modules/postgres/go.sum | 13 +- modules/postgres/postgres.go | 92 ++++++++- modules/postgres/postgres_test.go | 161 ++++++++++++--- network/network_test.go | 2 + testdata/waitForHello.sh | 10 + 12 files changed, 528 insertions(+), 85 deletions(-) create mode 100644 docker_files_test.go create mode 100644 testdata/waitForHello.sh diff --git a/docker_files_test.go b/docker_files_test.go new file mode 100644 index 0000000000..065f87443f --- /dev/null +++ b/docker_files_test.go @@ -0,0 +1,213 @@ +package testcontainers + +import ( + "context" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/testcontainers/testcontainers-go/wait" +) + +func TestCopyFileToContainer(t *testing.T) { + ctx, cnl := context.WithTimeout(context.Background(), 30*time.Second) + defer cnl() + + // copyFileOnCreate { + absPath, err := filepath.Abs(filepath.Join(".", "testdata", "hello.sh")) + if err != nil { + t.Fatal(err) + } + + container, err := GenericContainer(ctx, GenericContainerRequest{ + ContainerRequest: ContainerRequest{ + Image: "docker.io/bash", + Files: []ContainerFile{ + { + HostFilePath: absPath, + ContainerFilePath: "/hello.sh", + FileMode: 0o700, + }, + }, + Cmd: []string{"bash", "/hello.sh"}, + WaitingFor: wait.ForLog("done"), + }, + Started: true, + }) + // } + + require.NoError(t, err) + require.NoError(t, container.Terminate(ctx)) +} + +func TestCopyFileToRunningContainer(t *testing.T) { + ctx, cnl := context.WithTimeout(context.Background(), 30*time.Second) + defer cnl() + + // Not using the assertations here to avoid leaking the library into the example + // copyFileAfterCreate { + waitForPath, err := filepath.Abs(filepath.Join(".", "testdata", "waitForHello.sh")) + if err != nil { + t.Fatal(err) + } + helloPath, err := filepath.Abs(filepath.Join(".", "testdata", "hello.sh")) + if err != nil { + t.Fatal(err) + } + + container, err := GenericContainer(ctx, GenericContainerRequest{ + ContainerRequest: ContainerRequest{ + Image: "docker.io/bash:5.2.26", + Files: []ContainerFile{ + { + HostFilePath: waitForPath, + ContainerFilePath: "/waitForHello.sh", + FileMode: 0o700, + }, + }, + Cmd: []string{"bash", "/waitForHello.sh"}, + }, + Started: true, + }) + if err != nil { + t.Fatal(err) + } + + err = container.CopyFileToContainer(ctx, helloPath, "/scripts/hello.sh", 0o700) + // } + + require.NoError(t, err) + + // Give some time to the wait script to catch the hello script being created + err = wait.ForLog("done").WithStartupTimeout(200*time.Millisecond).WaitUntilReady(ctx, container) + require.NoError(t, err) + + require.NoError(t, container.Terminate(ctx)) +} + +func TestCopyDirectoryToContainer(t *testing.T) { + ctx, cnl := context.WithTimeout(context.Background(), 30*time.Second) + defer cnl() + + // Not using the assertations here to avoid leaking the library into the example + // copyDirectoryToContainer { + dataDirectory, err := filepath.Abs(filepath.Join(".", "testdata")) + if err != nil { + t.Fatal(err) + } + + container, err := GenericContainer(ctx, GenericContainerRequest{ + ContainerRequest: ContainerRequest{ + Image: "docker.io/bash", + Files: []ContainerFile{ + { + HostFilePath: dataDirectory, + // ContainerFile cannot create the parent directory, so we copy the scripts + // to the root of the container instead. Make sure to create the container directory + // before you copy a host directory on create. + ContainerFilePath: "/", + FileMode: 0o700, + }, + }, + Cmd: []string{"bash", "/testdata/hello.sh"}, + WaitingFor: wait.ForLog("done"), + }, + Started: true, + }) + // } + + require.NoError(t, err) + require.NoError(t, container.Terminate(ctx)) +} + +func TestCopyDirectoryToRunningContainerAsFile(t *testing.T) { + ctx, cnl := context.WithTimeout(context.Background(), 30*time.Second) + defer cnl() + + // copyDirectoryToRunningContainerAsFile { + dataDirectory, err := filepath.Abs(filepath.Join(".", "testdata")) + if err != nil { + t.Fatal(err) + } + waitForPath, err := filepath.Abs(filepath.Join(dataDirectory, "waitForHello.sh")) + if err != nil { + t.Fatal(err) + } + + container, err := GenericContainer(ctx, GenericContainerRequest{ + ContainerRequest: ContainerRequest{ + Image: "docker.io/bash", + Files: []ContainerFile{ + { + HostFilePath: waitForPath, + ContainerFilePath: "/waitForHello.sh", + FileMode: 0o700, + }, + }, + Cmd: []string{"bash", "/waitForHello.sh"}, + }, + Started: true, + }) + require.NoError(t, err) + + // as the container is started, we can create the directory first + _, _, err = container.Exec(ctx, []string{"mkdir", "-p", "/scripts"}) + require.NoError(t, err) + + // because the container path is a directory, it will use the copy dir method as fallback + err = container.CopyFileToContainer(ctx, dataDirectory, "/scripts", 0o700) + if err != nil { + t.Fatal(err) + } + // } + + require.NoError(t, err) + require.NoError(t, container.Terminate(ctx)) +} + +func TestCopyDirectoryToRunningContainerAsDir(t *testing.T) { + ctx, cnl := context.WithTimeout(context.Background(), 30*time.Second) + defer cnl() + + // Not using the assertations here to avoid leaking the library into the example + // copyDirectoryToRunningContainerAsDir { + waitForPath, err := filepath.Abs(filepath.Join(".", "testdata", "waitForHello.sh")) + if err != nil { + t.Fatal(err) + } + dataDirectory, err := filepath.Abs(filepath.Join(".", "testdata")) + if err != nil { + t.Fatal(err) + } + + container, err := GenericContainer(ctx, GenericContainerRequest{ + ContainerRequest: ContainerRequest{ + Image: "docker.io/bash", + Files: []ContainerFile{ + { + HostFilePath: waitForPath, + ContainerFilePath: "/waitForHello.sh", + FileMode: 0o700, + }, + }, + Cmd: []string{"bash", "/waitForHello.sh"}, + }, + Started: true, + }) + require.NoError(t, err) + + // as the container is started, we can create the directory first + _, _, err = container.Exec(ctx, []string{"mkdir", "-p", "/scripts"}) + require.NoError(t, err) + + err = container.CopyDirToContainer(ctx, dataDirectory, "/scripts", 0o700) + if err != nil { + t.Fatal(err) + } + // } + + require.NoError(t, err) + require.NoError(t, container.Terminate(ctx)) +} diff --git a/docs/features/common_functional_options.md b/docs/features/common_functional_options.md index 3cc5d902aa..b60dbd288d 100644 --- a/docs/features/common_functional_options.md +++ b/docs/features/common_functional_options.md @@ -86,3 +86,23 @@ If you need an advanced configuration for the container, you can leverage the fo - `testcontainers.WithEndpointSettingsModifier` Please read the [Create containers: Advanced Settings](/features/creating_container.md#advanced-settings) documentation for more information. + +#### Customising the ContainerRequest + +This option will merge the customized request into the module's own `ContainerRequest`. + +```go +container, err := RunContainer(ctx, + /* Other module options */ + testcontainers.CustomizeRequest(testcontainers.GenericContainerRequest{ + ContainerRequest: testcontainers.ContainerRequest{ + Cmd: []string{"-c", "log_statement=all"}, + }, + }), +) +``` + +The above example is updating the predefined command of the image, **appending** them to the module's command. + +!!!info + This can't be used to replace the command, only to append options. diff --git a/docs/features/files_and_mounts.md b/docs/features/files_and_mounts.md index 9c0b3711d2..76e54012f3 100644 --- a/docs/features/files_and_mounts.md +++ b/docs/features/files_and_mounts.md @@ -16,48 +16,26 @@ It is possible to map a Docker volume into the container using the `Mounts` attr !!!warning Bind mounts are not supported, as it could not work with remote Docker hosts. +!!!tip + It is recommended to copy data from your local host machine to a test container using the file copy API + described below, as it is much more portable. + ## Copying files to a container If you would like to copy a file to a container, you can do it in two different manners: 1. Adding a list of files in the `ContainerRequest`, which will be copied before the container starts: -```go -ctx := context.Background() - -nginxC, err := GenericContainer(ctx, GenericContainerRequest{ - ContainerRequest: ContainerRequest{ - Image: "nginx:1.17.6", - ExposedPorts: []string{"80/tcp"}, - WaitingFor: wait.ForListeningPort("80/tcp"), - Files: []ContainerFile{ - { - HostFilePath: "./testdata/hello.sh", - ContainerFilePath: "/copies-hello.sh", - FileMode: 0o700, - }, - }, - }, - Started: false, - }) -``` + +[Copying a list of files](../../docker_files_test.go) inside_block:copyFileOnCreate + 2. Using the `CopyFileToContainer` method on a `running` container: -```go -ctx := context.Background() - -nginxC, err := GenericContainer(ctx, GenericContainerRequest{ - ContainerRequest: ContainerRequest{ - Image: "nginx:1.17.6", - ExposedPorts: []string{"80/tcp"}, - WaitingFor: wait.ForListeningPort("80/tcp"), - }, - Started: true, - }) - -nginxC.CopyFileToContainer(ctx, "./testdata/hello.sh", "/hello_copy.sh", 0o700) -``` + +[Copying files to a running container](../../docker_files_test.go) inside_block:copyFileAfterCreate +[Wait for hello](../../testdata/waitForHello.sh) + ## Copying directories to a container @@ -67,30 +45,20 @@ It's important to notice that, when copying the directory to the container, the You can leverage the very same mechanism used for copying files to a container, but for directories.: -1. The first way is using the `Files` field in the `ContainerRequest` struct, as shown in the previous section, but using the path of a directory as `HostFilePath`. +1. The first way is using the `Files` field in the `ContainerRequest` struct, as shown in the previous section, but using the path of a directory as `HostFilePath`. Like so: + + +[Copying a directory using files](../../docker_files_test.go) inside_block:copyDirectoryToContainer + 2. The second way uses the existing `CopyFileToContainer` method, which will internally check if the host path is a directory, calling the `CopyDirToContainer` method if needed: -```go -ctx := context.Background() -// as the container is started, we can create the directory first -_, _, err = myContainer.Exec(ctx, []string{"mkdir", "-p", "/usr/lib/my-software/config"}) -// because the container path is a directory, it will use the copy dir method as fallback -err = myContainer.CopyFileToContainer(ctx, "./files", "/usr/lib/my-software/config/files", 0o700) -if err != nil { - // handle error -} -``` + +[Copying a directory to a running container](../../docker_files_test.go) inside_block:copyDirectoryToRunningContainerAsFile + 3. The last third way uses the `CopyDirToContainer` method, directly, which, as you probably know, needs the existence of the parent directory in order to copy the directory: -```go -ctx := context.Background() - -// as the container is started, we can create the directory first -_, _, err = nginxC.Exec(ctx, []string{"mkdir", "-p", "/usr/lib/my-software/config"}) -err = nginxC.CopyDirToContainer(ctx, "./plugins", "/usr/lib/my-software/config/plugins", 0o700) -if err != nil { - // handle error -} -``` + +[Copying a directory to a running container](../../docker_files_test.go) inside_block:copyDirectoryToRunningContainerAsDir + diff --git a/docs/features/override_container_command.md b/docs/features/override_container_command.md index c7a369ed6f..0ea1d6c921 100644 --- a/docs/features/override_container_command.md +++ b/docs/features/override_container_command.md @@ -15,6 +15,13 @@ req := ContainerRequest{ } ``` +!!!info + If you are using a module, you can use the `testcontainers.CustomizeRequest` option to add arguments to the command. Check the individual module's pages for more information on their commands. + +This option will merge the customized request into the module's request, appending any additional `Cmd` arguments to the +module's command. This can't be used to replace the command, only to append options. +Check the individual module's pages for more information on their commands. + ## Executing a command You can execute a command inside a running container, similar to a `docker exec` call: diff --git a/docs/features/wait/http.md b/docs/features/wait/http.md index 81a42df292..8b2b731762 100644 --- a/docs/features/wait/http.md +++ b/docs/features/wait/http.md @@ -13,6 +13,9 @@ The HTTP wait strategy will check the result of an HTTP(S) request against the c - the poll interval to be used in milliseconds, default is 100 milliseconds. - the basic auth credentials to be used. +!!!info + It's important to notice that the HTTP wait strategy will default to the first port exported/published by the image. + Variations on the HTTP wait strategy are supported, including: ## Match an HTTP method diff --git a/docs/modules/postgres.md b/docs/modules/postgres.md index 0614b689b4..56fd9617e8 100644 --- a/docs/modules/postgres.md +++ b/docs/modules/postgres.md @@ -91,3 +91,14 @@ It's possible to use the Postgres container with Timescale or Postgis, to name a [Image for Postgis](../../modules/postgres/postgres_test.go) inside_block:postgis + +## Examples + +### Using Snapshots +This example shows the usage of the postgres module's Snapshot feature to give each test a clean database without having +to recreate the database container on every test or run heavy scripts to clean your database. This makes the individual +tests very modular, since they always run on a brand-new database. + + +[Test with a reusable Postgres container](../../modules/postgres/postgres_test.go) inside_block:snapshotAndReset + diff --git a/modules/postgres/go.mod b/modules/postgres/go.mod index 09ff7674ba..f2e28fd416 100644 --- a/modules/postgres/go.mod +++ b/modules/postgres/go.mod @@ -4,6 +4,7 @@ go 1.20 require ( github.com/docker/go-connections v0.5.0 + github.com/jackc/pgx/v5 v5.5.3 github.com/lib/pq v1.10.9 github.com/stretchr/testify v1.8.4 github.com/testcontainers/testcontainers-go v0.27.0 @@ -30,6 +31,8 @@ require ( github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/protobuf v1.5.3 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect github.com/klauspost/compress v1.16.0 // indirect github.com/kr/text v0.2.0 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect @@ -54,9 +57,11 @@ require ( go.opentelemetry.io/otel v1.19.0 // indirect go.opentelemetry.io/otel/metric v1.19.0 // indirect go.opentelemetry.io/otel/trace v1.19.0 // indirect + golang.org/x/crypto v0.17.0 // indirect golang.org/x/exp v0.0.0-20230510235704-dd950f8aeaea // indirect golang.org/x/mod v0.11.0 // indirect golang.org/x/sys v0.16.0 // indirect + golang.org/x/text v0.14.0 // indirect golang.org/x/tools v0.10.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20230711160842-782d3b101e98 // indirect google.golang.org/grpc v1.58.3 // indirect diff --git a/modules/postgres/go.sum b/modules/postgres/go.sum index ebe3f14b0e..ab4b81e8e7 100644 --- a/modules/postgres/go.sum +++ b/modules/postgres/go.sum @@ -50,6 +50,13 @@ github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN 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/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.5.3 h1:Ces6/M3wbDXYpM8JyyPD57ivTtJACFZJd885pdIaV2s= +github.com/jackc/pgx/v5 v5.5.3/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= +github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= 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.16.0 h1:iULayQNOReoYUe+1qtKOqw9CwJv3aNQu8ivo7lw1HU4= @@ -95,6 +102,7 @@ github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVs github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= @@ -123,6 +131,8 @@ go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lI 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= +golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= +golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= golang.org/x/exp v0.0.0-20230510235704-dd950f8aeaea h1:vLCWI/yYrdEHyN2JzIzPO3aaQJHQdp89IZBA/+azVC4= golang.org/x/exp v0.0.0-20230510235704-dd950f8aeaea/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= @@ -152,7 +162,8 @@ golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= diff --git a/modules/postgres/postgres.go b/modules/postgres/postgres.go index b50f58737a..111a2a0953 100644 --- a/modules/postgres/postgres.go +++ b/modules/postgres/postgres.go @@ -14,14 +14,16 @@ const ( defaultUser = "postgres" defaultPassword = "postgres" defaultPostgresImage = "docker.io/postgres:11-alpine" + defaultSnapshotName = "migrated_template" ) // PostgresContainer represents the postgres container type used in the module type PostgresContainer struct { testcontainers.Container - dbName string - user string - password string + dbName string + user string + password string + snapshotName string } // ConnectionString returns the connection string for the postgres container, using the default 5432 port, and @@ -141,3 +143,87 @@ func RunContainer(ctx context.Context, opts ...testcontainers.ContainerCustomize return &PostgresContainer{Container: container, dbName: dbName, password: password, user: user}, nil } + +type snapshotConfig struct { + snapshotName string +} + +// SnapshotOption is the type for passing options to the snapshot function of the database +type SnapshotOption func(container *snapshotConfig) *snapshotConfig + +// WithSnapshotName adds a specific name to the snapshot database created from the main database defined on the +// container. The snapshot must not have the same name as your main database, otherwise it will be overwritten +func WithSnapshotName(name string) SnapshotOption { + return func(cfg *snapshotConfig) *snapshotConfig { + cfg.snapshotName = name + return cfg + } +} + +// Snapshot takes a snapshot of the current state of the database as a template, which can then be restored using +// the Restore method. By default, the snapshot will be created under a database called migrated_template, you can +// customize the snapshot name with the options. +// If a snapshot already exists under the given/default name, it will be overwritten with the new snapshot. +func (c *PostgresContainer) Snapshot(ctx context.Context, opts ...SnapshotOption) error { + config := &snapshotConfig{} + for _, opt := range opts { + config = opt(config) + } + + snapshotName := defaultSnapshotName + if config.snapshotName != "" { + snapshotName = config.snapshotName + } + + // execute the commands to create the snapshot, in order + cmds := []string{ + // Drop the snapshot database if it already exists + fmt.Sprintf(`DROP DATABASE IF EXISTS "%s"`, snapshotName), + // Create a copy of the database to another database to use as a template now that it was fully migrated + fmt.Sprintf(`CREATE DATABASE "%s" WITH TEMPLATE "%s" OWNER "%s"`, snapshotName, c.dbName, c.user), + // Snapshot the template database so we can restore it onto our original database going forward + fmt.Sprintf(`ALTER DATABASE "%s" WITH is_template = TRUE`, snapshotName), + } + + for _, cmd := range cmds { + _, _, err := c.Exec(ctx, []string{"psql", "-U", c.user, "-c", cmd}) + if err != nil { + return err + } + } + + c.snapshotName = snapshotName + + return nil +} + +// Restore will restore the database to a specific snapshot. By default, it will restore the last snapshot taken on the +// database by the Snapshot method. If a snapshot name is provided, it will instead try to restore the snapshot by name. +func (c *PostgresContainer) Restore(ctx context.Context, opts ...SnapshotOption) error { + config := &snapshotConfig{} + for _, opt := range opts { + config = opt(config) + } + + snapshotName := c.snapshotName + if config.snapshotName != "" { + snapshotName = config.snapshotName + } + + // execute the commands to restore the snapshot, in order + cmds := []string{ + // Drop the entire database by connecting to the postgres global database + fmt.Sprintf(`DROP DATABASE "%s" with (FORCE)`, c.dbName), + // Then restore the previous snapshot + fmt.Sprintf(`CREATE DATABASE "%s" WITH TEMPLATE "%s" OWNER "%s"`, c.dbName, snapshotName, c.user), + } + + for _, cmd := range cmds { + _, _, err := c.Exec(ctx, []string{"psql", "-U", c.user, "-d", "postgres", "-c", cmd}) + if err != nil { + return err + } + } + + return nil +} diff --git a/modules/postgres/postgres_test.go b/modules/postgres/postgres_test.go index 040b4775c4..6cb1346977 100644 --- a/modules/postgres/postgres_test.go +++ b/modules/postgres/postgres_test.go @@ -1,19 +1,22 @@ -package postgres +package postgres_test import ( "context" "database/sql" + "errors" "fmt" "path/filepath" "testing" "time" "github.com/docker/go-connections/nat" + "github.com/jackc/pgx/v5" _ "github.com/lib/pq" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/modules/postgres" "github.com/testcontainers/testcontainers-go/wait" ) @@ -54,11 +57,11 @@ func TestPostgres(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - container, err := RunContainer(ctx, + container, err := postgres.RunContainer(ctx, testcontainers.WithImage(tt.image), - WithDatabase(dbname), - WithUsername(user), - WithPassword(password), + postgres.WithDatabase(dbname), + postgres.WithUsername(user), + postgres.WithPassword(password), testcontainers.WithWaitStrategy(tt.wait), ) if err != nil { @@ -109,33 +112,33 @@ func TestContainerWithWaitForSQL(t *testing.T) { } t.Run("default query", func(t *testing.T) { - container, err := RunContainer( + container, err := postgres.RunContainer( ctx, - WithDatabase(dbname), - WithUsername(user), - WithPassword(password), + postgres.WithDatabase(dbname), + postgres.WithUsername(user), + postgres.WithPassword(password), testcontainers.WithWaitStrategy(wait.ForSQL(nat.Port(port), "postgres", dbURL)), ) require.NoError(t, err) require.NotNil(t, container) }) t.Run("custom query", func(t *testing.T) { - container, err := RunContainer( + container, err := postgres.RunContainer( ctx, - WithDatabase(dbname), - WithUsername(user), - WithPassword(password), + postgres.WithDatabase(dbname), + postgres.WithUsername(user), + postgres.WithPassword(password), testcontainers.WithWaitStrategy(wait.ForSQL(nat.Port(port), "postgres", dbURL).WithStartupTimeout(time.Second*5).WithQuery("SELECT 10")), ) require.NoError(t, err) require.NotNil(t, container) }) t.Run("custom bad query", func(t *testing.T) { - container, err := RunContainer( + container, err := postgres.RunContainer( ctx, - WithDatabase(dbname), - WithUsername(user), - WithPassword(password), + postgres.WithDatabase(dbname), + postgres.WithUsername(user), + postgres.WithPassword(password), testcontainers.WithWaitStrategy(wait.ForSQL(nat.Port(port), "postgres", dbURL).WithStartupTimeout(time.Second*5).WithQuery("SELECT 'a' from b")), ) require.Error(t, err) @@ -146,11 +149,11 @@ func TestContainerWithWaitForSQL(t *testing.T) { func TestWithConfigFile(t *testing.T) { ctx := context.Background() - container, err := RunContainer(ctx, - WithConfigFile(filepath.Join("testdata", "my-postgres.conf")), - WithDatabase(dbname), - WithUsername(user), - WithPassword(password), + container, err := postgres.RunContainer(ctx, + postgres.WithConfigFile(filepath.Join("testdata", "my-postgres.conf")), + postgres.WithDatabase(dbname), + postgres.WithUsername(user), + postgres.WithPassword(password), testcontainers.WithWaitStrategy(wait.ForLog("database system is ready to accept connections").WithOccurrence(2).WithStartupTimeout(5*time.Second)), ) if err != nil { @@ -176,12 +179,12 @@ func TestWithConfigFile(t *testing.T) { func TestWithInitScript(t *testing.T) { ctx := context.Background() - container, err := RunContainer(ctx, + container, err := postgres.RunContainer(ctx, testcontainers.WithImage("docker.io/postgres:15.2-alpine"), - WithInitScripts(filepath.Join("testdata", "init-user-db.sh")), - WithDatabase(dbname), - WithUsername(user), - WithPassword(password), + postgres.WithInitScripts(filepath.Join("testdata", "init-user-db.sh")), + postgres.WithDatabase(dbname), + postgres.WithUsername(user), + postgres.WithPassword(password), testcontainers.WithWaitStrategy(wait.ForLog("database system is ready to accept connections").WithOccurrence(2).WithStartupTimeout(5*time.Second)), ) if err != nil { @@ -208,3 +211,107 @@ func TestWithInitScript(t *testing.T) { require.NoError(t, err) assert.NotNil(t, result) } + +func TestSnapshot(t *testing.T) { + // snapshotAndReset { + ctx := context.Background() + + // 1. Start the postgres container and run any migrations on it + container, err := postgres.RunContainer( + ctx, + testcontainers.WithImage("docker.io/postgres:16-alpine"), + postgres.WithDatabase(dbname), + postgres.WithUsername(user), + postgres.WithPassword(password), + testcontainers.WithWaitStrategy( + wait.ForLog("database system is ready to accept connections"). + WithOccurrence(2). + WithStartupTimeout(5*time.Second)), + ) + if err != nil { + t.Fatal(err) + } + + // Run any migrations on the database + _, _, err = container.Exec(ctx, []string{"psql", "-U", user, "-d", dbname, "-c", "CREATE TABLE users (id SERIAL, name TEXT NOT NULL, age INT NOT NULL)"}) + if err != nil { + t.Fatal(err) + } + + // 2. Create a snapshot of the database to restore later + err = container.Snapshot(ctx, postgres.WithSnapshotName("test-snapshot")) + if err != nil { + t.Fatal(err) + } + + // Clean up the container after the test is complete + t.Cleanup(func() { + if err := container.Terminate(ctx); err != nil { + t.Fatalf("failed to terminate container: %s", err) + } + }) + + dbURL, err := container.ConnectionString(ctx) + if err != nil { + t.Fatal(err) + } + + t.Run("Test inserting a user", func(t *testing.T) { + t.Cleanup(func() { + // 3. In each test, reset the DB to its snapshot state. + err = container.Restore(ctx) + if err != nil { + t.Fatal(err) + } + }) + + conn, err := pgx.Connect(context.Background(), dbURL) + if err != nil { + t.Fatal(err) + } + defer conn.Close(context.Background()) + + _, err = conn.Exec(ctx, "INSERT INTO users(name, age) VALUES ($1, $2)", "test", 42) + if err != nil { + t.Fatal(err) + } + + var name string + var age int64 + err = conn.QueryRow(context.Background(), "SELECT name, age FROM users LIMIT 1").Scan(&name, &age) + if err != nil { + t.Fatal(err) + } + + if name != "test" { + t.Fatalf("Expected %s to equal `test`", name) + } + if age != 42 { + t.Fatalf("Expected %d to equal `42`", age) + } + }) + + // 4. Run as many tests as you need, they will each get a clean database + t.Run("Test querying empty DB", func(t *testing.T) { + t.Cleanup(func() { + err = container.Restore(ctx) + if err != nil { + t.Fatal(err) + } + }) + + conn, err := pgx.Connect(context.Background(), dbURL) + if err != nil { + t.Fatal(err) + } + defer conn.Close(context.Background()) + + var name string + var age int64 + err = conn.QueryRow(context.Background(), "SELECT name, age FROM users LIMIT 1").Scan(&name, &age) + if !errors.Is(err, pgx.ErrNoRows) { + t.Fatalf("Expected error to be a NoRows error, since the DB should be empty on every test. Got %s instead", err) + } + }) + // } +} diff --git a/network/network_test.go b/network/network_test.go index 05e22756cf..1756c7d5e6 100644 --- a/network/network_test.go +++ b/network/network_test.go @@ -32,6 +32,8 @@ func ExampleNew() { net, err := network.New(ctx, network.WithCheckDuplicate(), network.WithAttachable(), + // Makes the network internal only, meaning the host machine cannot access it. + // Remove or use `network.WithDriver("bridge")` to change the network's mode. network.WithInternal(), network.WithLabels(map[string]string{"this-is-a-test": "value"}), ) diff --git a/testdata/waitForHello.sh b/testdata/waitForHello.sh new file mode 100644 index 0000000000..9fefeeeace --- /dev/null +++ b/testdata/waitForHello.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +file=/scripts/hello.sh + +until [ -s "$file" ] +do + sleep 0.1 +done + +sh $file \ No newline at end of file