diff --git a/docs/modules/mongodb-atlaslocal.md b/docs/modules/mongodb-atlaslocal.md new file mode 100644 index 0000000000..3b52e3d6b4 --- /dev/null +++ b/docs/modules/mongodb-atlaslocal.md @@ -0,0 +1,188 @@ +# MongoDB Atlas Local + +Not available until the next release :material-tag: main + +## Introduction + +The MongoDB Atlas Local module for Testcontainers lets you spin up a local MongoDB Atlas instance in Docker using +[mongodb/mongodb-atlas-local](https://hub.docker.com/r/mongodb/mongodb-atlas-local) for integration tests and +development. This module supports SCRAM authentication, init scripts, and custom log file mounting. + +This module differs from the standard modules/mongodb Testcontainers module, allowing users to spin up a full local +Atlas-like environment complete with Atlas Search and Atlas Vector Search. + +## Adding this module to your project dependencies + +Please run the following command to add the MongoDB Atlas Local module to your Go dependencies: + +``` +go get github.com/testcontainers/testcontainers-go/modules/mongodb/atlaslocal +``` + +## Usage example + + +[Creating a MongoDB Atlas Local container](../../modules/mongodb/atlaslocal/examples_test.go) inside_block:runMongoDBAtlasLocalContainer + + +## Module Reference + +### Run function + +- Not available until the next release :material-tag: main + +The `atlaslocal` module exposes one entrypoint function to create the MongoDB Atlas Local 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. + +#### Image + +Use the second argument in the `Run` function to set a valid Docker image. +In example: `Run(context.Background(), "mongodb/mongodb-atlas-local:latest")`. + +### Container Options + +When starting the MongoDB Atlas Local container, you can pass options in a variadic way to configure it. + +#### WithUsername + +- Not available until the next release :material-tag: main + +This functional option sets the initial username to be created when the container starts, populating the +`MONGODB_INITDB_ROOT_USERNAME` environment variable. You cannot mix this option with `WithUsernameFile`, as it will +result in an error. + +#### WithPassword + +- Not available until the next release :material-tag: main + +This functional option sets the initial password to be created when the container starts, populating the +`MONGODB_INITDB_ROOT_PASSWORD` environment variable. You cannot mix this option with `WithPasswordFile`, as it will +result in an error. + +#### WithUsernameFile + +- Not available until the next release :material-tag: main + +This functional option mounts a local file as the MongoDB root username secret at `/run/secrets/mongo-root-username` +and sets the `MONGODB_INITDB_ROOT_USERNAME_FILE` environment variable. The path must be absolute and exist; no-op if +empty. + +#### WithPasswordFile + +- Not available until the next release :material-tag: main + +This functional option mounts a local file as the MongoDB root password secret at `/run/secrets/mongo-root-password` and +sets the `MONGODB_INITDB_ROOT_PASSWORD_FILE` environment variable. The path must be absolute and exist; no-op if empty. + +#### WithNoTelemetry + +- Not available until the next release :material-tag: main + +This functional option disables the telemetry feature of MongoDB Atlas Local, setting the `DO_NOT_TRACK` environment +variable to `1`. + +#### WithInitDatabase + +- Not available until the next release :material-tag: main + +This functional option allows you to specify a database name to be initialized when the container starts, populating +the `MONGODB_INITDB_DATABASE` environment variable. + +#### WithInitScripts + +- Not available until the next release :material-tag: main + +Mounts a directory into `/docker-entrypoint-initdb.d`, running `.sh`/`.js` scripts on startup. Calling this function +multiple times mounts only the latest directory. + +#### WithMongotLogFile + +- Not available until the next release :material-tag: main + +This functional option writes the mongot logs to `/tmp/mongot.log` inside the container. See +`(*Container).ReadMongotLogs` to read the logs locally. + +#### WithMongotLogToStdout + +- Not available until the next release :material-tag: main + +This functional option writes the mongot logs to `/dev/stdout` inside the container. See +`(*Container).ReadMongotLogs` to read the logs locally. + +#### WithMongotLogToStderr + +- Not available until the next release :material-tag: main + +This functional option writes the mongot logs to `/dev/stderr` inside the container. See +`(*Container).ReadMongotLogs` to read the logs locally. + +#### WithRunnerLogFile + +- Not available until the next release :material-tag: main + +This functional option writes the runner logs to `/tmp/runner.log` inside the container. See +`(*Container).ReadRunnerLogs` to read the logs locally. + +#### WithRunnerLogToStdout + +- Not available until the next release :material-tag: main + +This functional option writes the runner logs to `/dev/stdout` inside the container. See +`(*Container).ReadRunnerLogs` to read the logs locally. + +#### WithRunnerLogToStderr + +- Not available until the next release :material-tag: main + +This functional option writes the runner logs to `/dev/stderr` inside the container. See +`(*Container).ReadRunnerLogs` to read the logs locally. + +{% include "../features/common_functional_options_list.md" %} + +### Container Methods + +The MongoDB Atlas Local container exposes the following methods: + + +#### ConnectionString + +- Not available until the next release :material-tag: main + +The `ConnectionString` method returns the connection string to connect to the MongoDB Atlas Local container. +It returns a string with the format `mongodb://:[/]/?directConnection=true[&authSource=admin]`. + +It can be used to configure a MongoDB client (`go.mongodb.org/mongo-driver/v2/mongo`), e.g.: + + +[Using ConnectionString with the MongoDB client](../../modules/mongodb/atlaslocal/examples_test.go) inside_block:connectToMongo + + +#### ReadMongotLogs + +- Not available until the next release :material-tag: main + +The `ReadMongotLogs` returns a reader for the log solution specified when constructing the container. + + + +[Using ReadMongotLogs with the MongoDB client](../../modules/mongodb/atlaslocal/examples_test.go) inside_block:mongotLogsRead + + +#### ReadRunnerLogs + +- Not available until the next release :material-tag: main + +The `ReadRunnerLogs` returns a reader for the log solution specified when constructing the container. + + + +[Using ReadRunnerLogs with the MongoDB client](../../modules/mongodb/atlaslocal/examples_test.go) inside_block:runnerLogsRead + diff --git a/mkdocs.yml b/mkdocs.yml index 1d6f55baea..0603401fc6 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -100,6 +100,7 @@ nav: - modules/milvus.md - modules/minio.md - modules/mockserver.md + - modules/mongodb-atlaslocal.md - modules/mongodb.md - modules/mssql.md - modules/mysql.md diff --git a/modules/mongodb/atlaslocal/atlaslocal.go b/modules/mongodb/atlaslocal/atlaslocal.go new file mode 100644 index 0000000000..91b9d811a2 --- /dev/null +++ b/modules/mongodb/atlaslocal/atlaslocal.go @@ -0,0 +1,144 @@ +package atlaslocal + +import ( + "context" + "fmt" + "io" + "net/url" + "os" + + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" +) + +const defaultPort = "27017/tcp" + +// Container represents the MongoDBAtlasLocal container type used in the module. +type Container struct { + testcontainers.Container + userOpts options +} + +// Run creates an instance of the MongoDBAtlasLocal container type. +func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*Container, error) { + userOpts := options{} + for _, opt := range opts { + if apply, ok := opt.(Option); ok { + if err := apply(&userOpts); err != nil { + return nil, fmt.Errorf("apply option: %w", err) + } + } + } + + if err := userOpts.validate(); err != nil { + return nil, fmt.Errorf("validate options: %w", err) + } + + moduleOpts := []testcontainers.ContainerCustomizer{ // Set the defaults + testcontainers.WithExposedPorts(defaultPort), + testcontainers.WithWaitStrategy(wait.ForAll(wait.ForListeningPort(defaultPort), wait.ForHealthCheck())), + testcontainers.WithEnv(userOpts.env()), + testcontainers.WithFiles(userOpts.files...), + } + + moduleOpts = append(moduleOpts, opts...) + + container, err := testcontainers.Run(ctx, img, moduleOpts...) + var c *Container + if container != nil { + c = &Container{Container: container, userOpts: userOpts} + } + + if err != nil { + return c, fmt.Errorf("run container: %w", err) + } + + return c, nil +} + +// ConnectionString returns the connection string for the MongoDB Atlas Local +// container. If you provide a username and a password, the connection string +// will also include them. +func (ctr *Container) ConnectionString(ctx context.Context) (string, error) { + endpoint, err := ctr.PortEndpoint(ctx, defaultPort, "") + if err != nil { + return "", fmt.Errorf("port endpoint: %w", err) + } + + uri := &url.URL{ + Scheme: "mongodb", + Host: endpoint, + Path: "/", + } + + // If MONGODB_INITDB_DATABASE is set, use it as the default database in the + // connection string. + if db := ctr.userOpts.database; db != "" { + uri.Path, err = url.JoinPath("/", db) + if err != nil { + return "", fmt.Errorf("join path: %w", err) + } + } + + user, err := ctr.userOpts.parseUsername() + if err != nil { + return "", fmt.Errorf("parse username: %w", err) + } + + password, err := ctr.userOpts.parsePassword() + if err != nil { + return "", fmt.Errorf("parse password: %w", err) + } + + if user != "" && password != "" { + uri.User = url.UserPassword(user, password) + } + + q := uri.Query() + q.Set("directConnection", "true") + if user != "" && password != "" { + q.Set("authSource", "admin") + } + + uri.RawQuery = q.Encode() + + return uri.String(), nil +} + +// ReadMongotLogs returns a reader for mongot logs in the container. Reads from +// stdout/stderr or /tmp/mongot.log if configured. +// +// This method return the os.ErrNotExist sentinel error if it is called with +// no log file configured. +func (ctr *Container) ReadMongotLogs(ctx context.Context) (io.ReadCloser, error) { + path := ctr.userOpts.mongotLogPath + if path == "" { + return nil, os.ErrNotExist + } + + switch path { + case "/dev/stdout", "/dev/stderr": + return ctr.Logs(ctx) + default: + return ctr.CopyFileFromContainer(ctx, path) + } +} + +// ReadRunnerLogs() returns a reader for runner logs in the container. Reads +// from stdout/stderr or /tmp/runner.log if configured. +// +// This method return the os.ErrNotExist sentinel error if it is called with +// no log file configured. +func (ctr *Container) ReadRunnerLogs(ctx context.Context) (io.ReadCloser, error) { + path := ctr.userOpts.runnerLogPath + if path == "" { + return nil, os.ErrNotExist + } + + switch path { + case "/dev/stdout", "/dev/stderr": + return ctr.Logs(ctx) + default: + return ctr.CopyFileFromContainer(ctx, path) + } +} diff --git a/modules/mongodb/atlaslocal/atlaslocal_test.go b/modules/mongodb/atlaslocal/atlaslocal_test.go new file mode 100644 index 0000000000..b46221857b --- /dev/null +++ b/modules/mongodb/atlaslocal/atlaslocal_test.go @@ -0,0 +1,811 @@ +package atlaslocal_test + +import ( + "context" + "io" + "math/rand/v2" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" + "go.mongodb.org/mongo-driver/v2/x/mongo/driver/connstring" + + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/modules/mongodb/atlaslocal" +) + +const latestImage = "mongodb/mongodb-atlas-local:latest" + +func TestMongoDBAtlasLocal(t *testing.T) { + ctx := context.Background() + + ctr, err := atlaslocal.Run(ctx, latestImage) + testcontainers.CleanupContainer(t, ctr) + require.NoError(t, err) + + client, td := newMongoClient(t, ctx, ctr) + defer td() + + err = client.Ping(ctx, nil) + require.NoError(t, err) +} + +func TestSCRAMAuth(t *testing.T) { + tmpDir, usernameFilepath, passwordFilepath := newAuthFiles(t) + + cases := []struct { + name string + username string + password string + usernameFile string + passwordFile string + wantRunErr string + }{ + { + name: "without auth", + username: "", + password: "", + usernameFile: "", + passwordFile: "", + wantRunErr: "", + }, + { + name: "with auth", + username: "testuser", + password: "testpass", + usernameFile: "", + passwordFile: "", + wantRunErr: "", + }, + { + name: "with auth files", + username: "", + password: "", + usernameFile: usernameFilepath, + passwordFile: passwordFilepath, + wantRunErr: "", + }, + { + name: "with inline and files", + username: "testuser", + password: "testpass", + usernameFile: usernameFilepath, + passwordFile: passwordFilepath, + wantRunErr: "you cannot specify both inline credentials and files for credentials", + }, + { + name: "username without password", + username: "testuser", + password: "", + usernameFile: "", + passwordFile: "", + wantRunErr: "if you specify username or password, you must provide both of them", + }, + { + name: "password without username", + username: "", + password: "testpass", + usernameFile: "", + passwordFile: "", + wantRunErr: "if you specify username or password, you must provide both of them", + }, + { + name: "username file without password file", + username: "", + password: "", + usernameFile: usernameFilepath, + passwordFile: "", + wantRunErr: "if you specify username file or password file, you must provide both of them", + }, + { + name: "password file without username file", + username: "", + password: "", + usernameFile: "", + passwordFile: passwordFilepath, + wantRunErr: "if you specify username file or password file, you must provide both of them", + }, + { + name: "username file invalid mount path", + username: "", + password: "", + usernameFile: "nonexistent_username.txt", + passwordFile: passwordFilepath, + wantRunErr: "mount path must be absolute", + }, + { + name: "password file invalid mount path", + username: "", + password: "", + usernameFile: usernameFilepath, + passwordFile: "nonexistent_password.txt", + wantRunErr: "mount path must be absolute", + }, + { + name: "username file is absolute but does not exist", + username: "", + password: "", + usernameFile: "/nonexistent/username.txt", + passwordFile: passwordFilepath, + wantRunErr: "does not exist or is not accessible", + }, + { + name: "password file is absolute but does not exist", + username: "", + password: "", + usernameFile: usernameFilepath, + passwordFile: "/nonexistent/password.txt", + wantRunErr: "does not exist or is not accessible", + }, + { + name: "username file is a directory", + username: "", + password: "", + usernameFile: tmpDir, + passwordFile: passwordFilepath, + wantRunErr: "must be a file", + }, + { + name: "password file is a directory", + username: "", + password: "", + usernameFile: usernameFilepath, + passwordFile: tmpDir, + wantRunErr: "must be a file", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + // Construct the custom options for the MongoDB Atlas Local container. + opts := []testcontainers.ContainerCustomizer{} + + if tc.username != "" { + opts = append(opts, atlaslocal.WithUsername(tc.username)) + } + + if tc.password != "" { + opts = append(opts, atlaslocal.WithPassword(tc.password)) + } + + if tc.usernameFile != "" { + opts = append(opts, atlaslocal.WithUsernameFile(tc.usernameFile)) + } + + if tc.passwordFile != "" { + opts = append(opts, atlaslocal.WithPasswordFile(tc.passwordFile)) + } + + // Randomize the order of the options + rand.Shuffle(len(opts), func(i, j int) { + opts[i], opts[j] = opts[j], opts[i] + }) + + // Create the MongoDB Atlas Local container with the specified options. + ctr, err := atlaslocal.Run(context.Background(), latestImage, opts...) + testcontainers.CleanupContainer(t, ctr) + + if tc.wantRunErr != "" { + require.ErrorContains(t, err, tc.wantRunErr) + + return + } + + require.NoError(t, err) + + // Verify the environment variables are set correctly. + requireEnvVar(t, ctr, "MONGODB_INITDB_ROOT_USERNAME", tc.username) + requireEnvVar(t, ctr, "MONGODB_INITDB_ROOT_PASSWORD", tc.password) + + if tc.usernameFile != "" { + requireEnvVar(t, ctr, "MONGODB_INITDB_ROOT_USERNAME_FILE", "/run/secrets/mongo-root-username") + } + + if tc.passwordFile != "" { + requireEnvVar(t, ctr, "MONGODB_INITDB_ROOT_PASSWORD_FILE", "/run/secrets/mongo-root-password") + } + + client, td := newMongoClient(t, context.Background(), ctr) + defer td() + + // Execute an insert operation to verify the connection and + // authentication. + coll := client.Database("test").Collection("foo") + + _, err = coll.InsertOne(context.Background(), bson.D{{Key: "test", Value: "value"}}) + require.NoError(t, err, "Failed to insert document with authentication") + }) + } +} + +func TestWithNoTelemetry(t *testing.T) { + t.Run("with", func(t *testing.T) { + ctr, err := atlaslocal.Run(context.Background(), latestImage, atlaslocal.WithNoTelemetry()) + testcontainers.CleanupContainer(t, ctr) + require.NoError(t, err) + + requireEnvVar(t, ctr, "DO_NOT_TRACK", "1") + }) + + t.Run("without", func(t *testing.T) { + ctr, err := atlaslocal.Run(context.Background(), latestImage) + testcontainers.CleanupContainer(t, ctr) + require.NoError(t, err) + + requireEnvVar(t, ctr, "DO_NOT_TRACK", "") + }) +} + +func TestWithMongotLogFile(t *testing.T) { + t.Run("with", func(t *testing.T) { + ctr, err := atlaslocal.Run(context.Background(), latestImage, atlaslocal.WithMongotLogFile()) + testcontainers.CleanupContainer(t, ctr) + require.NoError(t, err) + + requireEnvVar(t, ctr, "MONGOT_LOG_FILE", "/tmp/mongot.log") + + executeAggregation(t, ctr) + requireMongotLogs(t, ctr) + }) + + t.Run("without", func(t *testing.T) { + ctr, err := atlaslocal.Run(context.Background(), latestImage) + testcontainers.CleanupContainer(t, ctr) + require.NoError(t, err) + + requireEnvVar(t, ctr, "MONGOT_LOG_FILE", "") + + executeAggregation(t, ctr) + requireNoMongotLogs(t, ctr) + }) + + t.Run("to stdout", func(t *testing.T) { + ctr, err := atlaslocal.Run(context.Background(), latestImage, + atlaslocal.WithMongotLogToStdout()) + testcontainers.CleanupContainer(t, ctr) + + require.NoError(t, err) + + requireEnvVar(t, ctr, "MONGOT_LOG_FILE", "/dev/stdout") + + executeAggregation(t, ctr) + + requireMongotLogs(t, ctr) + requireContainerLogsNotEmpty(t, ctr) + }) + + t.Run("to stderr", func(t *testing.T) { + ctr, err := atlaslocal.Run(context.Background(), latestImage, + atlaslocal.WithMongotLogToStderr()) + testcontainers.CleanupContainer(t, ctr) + require.NoError(t, err) + + requireEnvVar(t, ctr, "MONGOT_LOG_FILE", "/dev/stderr") + + executeAggregation(t, ctr) + + requireMongotLogs(t, ctr) + requireContainerLogsNotEmpty(t, ctr) + }) +} + +func TestWithRunnerLogFile(t *testing.T) { + const runnerLogFile = "/tmp/runner.log" + + t.Run("with", func(t *testing.T) { + ctr, err := atlaslocal.Run(context.Background(), latestImage, atlaslocal.WithRunnerLogFile()) + testcontainers.CleanupContainer(t, ctr) + require.NoError(t, err) + + requireEnvVar(t, ctr, "RUNNER_LOG_FILE", runnerLogFile) + requireRunnerLogs(t, ctr) + }) + + t.Run("without", func(t *testing.T) { + ctr, err := atlaslocal.Run(context.Background(), latestImage) + testcontainers.CleanupContainer(t, ctr) + + require.NoError(t, err) + + requireEnvVar(t, ctr, "RUNNER_LOG_FILE", "") + requireNoRunnerLogs(t, ctr) + }) + + t.Run("to stdout", func(t *testing.T) { + ctr, err := atlaslocal.Run(context.Background(), latestImage, + atlaslocal.WithRunnerLogToStdout()) + testcontainers.CleanupContainer(t, ctr) + require.NoError(t, err) + + requireEnvVar(t, ctr, "RUNNER_LOG_FILE", "/dev/stdout") + + executeAggregation(t, ctr) + + requireRunnerLogs(t, ctr) + requireContainerLogsNotEmpty(t, ctr) + }) + + t.Run("to stderr", func(t *testing.T) { + ctr, err := atlaslocal.Run(context.Background(), latestImage, + atlaslocal.WithRunnerLogToStderr()) + testcontainers.CleanupContainer(t, ctr) + require.NoError(t, err) + + requireEnvVar(t, ctr, "RUNNER_LOG_FILE", "/dev/stderr") + + executeAggregation(t, ctr) + + requireRunnerLogs(t, ctr) + requireContainerLogsNotEmpty(t, ctr) + }) +} + +func TestWithInitDatabase(t *testing.T) { + initScripts := map[string]string{ + "01-seed.js": `db.foo.insertOne({ _id: 1, seeded: true });`, + } + + tmpDir := createInitScripts(t, initScripts) + opts := []testcontainers.ContainerCustomizer{ + atlaslocal.WithInitDatabase("mydb"), + atlaslocal.WithInitScripts(tmpDir), + } + + ctr, err := atlaslocal.Run(context.Background(), latestImage, opts...) + testcontainers.CleanupContainer(t, ctr) + require.NoError(t, err) + + requireInitScriptsExist(t, ctr, initScripts) + requireEnvVar(t, ctr, "MONGODB_INITDB_DATABASE", "mydb") + + client, td := newMongoClient(t, context.Background(), ctr) + defer td() + + coll := client.Database("mydb").Collection("foo") + + seed := bson.D{{Key: "_id", Value: int32(1)}, {Key: "seeded", Value: true}} + + res := coll.FindOne(context.Background(), seed) + require.NoError(t, res.Err()) + + var doc bson.D + require.NoError(t, res.Decode(&doc), "Failed to decode seeded document") + require.Equal(t, seed, doc, "Seeded document does not match expected values") +} + +func TestWithInitScripts(t *testing.T) { + seed1 := bson.D{{Key: "_id", Value: int32(1)}, {Key: "seeded", Value: true}} + seed2 := bson.D{{Key: "_id", Value: int32(2)}, {Key: "seeded", Value: true}} + + cases := []struct { + name string + initScripts map[string]string // filename -> content + want []bson.D + }{ + { + name: "no scripts", + initScripts: map[string]string{}, + want: []bson.D{}, + }, + { + name: "single shell script", + initScripts: map[string]string{ + "01-seed.sh": `mongosh --eval 'db.foo.insertOne({ _id: 1, seeded: true })'`, + }, + want: []bson.D{seed1}, + }, + { + name: "single js script", + initScripts: map[string]string{ + "01-seed.js": `db.foo.insertOne({ _id: 1, seeded: true });`, + }, + want: []bson.D{seed1}, + }, + { + name: "mixed shell and js scripts", + initScripts: map[string]string{ + "01-seed.sh": `mongosh --eval 'db.foo.insertOne({ _id: 1, seeded: true })'`, + "02-seed.js": `db.foo.insertOne({ _id: 2, seeded: true });`, + }, + want: []bson.D{seed1, seed2}, + }, + { + name: "mixed ordered shell", + initScripts: map[string]string{ + "01-seed.sh": `mongosh --eval 'db.foo.insertOne({ _id: 1, seeded: true })'`, + "02-seed.sh": `mongosh --eval 'db.foo.deleteOne({ _id: 1, seeded: true })'`, + }, + want: []bson.D{}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + tmpDir := createInitScripts(t, tc.initScripts) + + // Start container with the init scripts mounted. + opts := []testcontainers.ContainerCustomizer{ + atlaslocal.WithInitScripts(tmpDir), + } + + ctr, err := atlaslocal.Run(context.Background(), latestImage, opts...) + testcontainers.CleanupContainer(t, ctr) + require.NoError(t, err) + + requireInitScriptsExist(t, ctr, tc.initScripts) + + // Connect to the server. + client, td := newMongoClient(t, context.Background(), ctr) + defer td() + + // Fetch the seeded data. + coll := client.Database("test").Collection("foo") + + cur, err := coll.Find(context.Background(), bson.D{}) + require.NoError(t, err) + + var results []bson.D + require.NoError(t, cur.All(context.Background(), &results)) + + require.ElementsMatch(t, results, tc.want, "Seeded documents do not match expected values") + }) + } +} + +// Ensure that we can chain multiple scripts. +func TestWithInitScripts_MultipleScripts(t *testing.T) { + scripts1 := map[string]string{ + "01-seed.sh": `mongosh --eval 'db.foo.insertOne({ _id: 1, seeded: true })'`, + "02-seed.js": `db.foo.insertOne({ _id: 2, seeded: true });`, + } + + tmpDir1 := createInitScripts(t, scripts1) + + scripts2 := map[string]string{ + "03-seed.sh": `mongosh --eval 'db.foo.insertOne({ _id: 3, seeded: true })'`, + } + + tmpDir2 := createInitScripts(t, scripts2) + + // Start container with the init scripts mounted. + opts := []testcontainers.ContainerCustomizer{ + atlaslocal.WithInitScripts(tmpDir1), + atlaslocal.WithInitScripts(tmpDir2), + } + + ctr, err := atlaslocal.Run(context.Background(), latestImage, opts...) + testcontainers.CleanupContainer(t, ctr) + require.NoError(t, err) + + requireInitScriptsDoesNotExist(t, ctr, scripts1) + requireInitScriptsExist(t, ctr, scripts2) +} + +func TestConnectionString(t *testing.T) { + _, usernameFilepath, passwordFilepath := newAuthFiles(t) + + testcases := []struct { + name string + opts []testcontainers.ContainerCustomizer + wantUsername string + wantPassword string + wantDatabase string + }{ + { + name: "default", + opts: []testcontainers.ContainerCustomizer{}, + wantUsername: "", + wantPassword: "", + wantDatabase: "", + }, + { + name: "with auth options", + opts: []testcontainers.ContainerCustomizer{ + atlaslocal.WithUsername("testuser"), + atlaslocal.WithPassword("testpass"), + atlaslocal.WithInitDatabase("testdb"), + }, + wantUsername: "testuser", + wantPassword: "testpass", + wantDatabase: "testdb", + }, + { + name: "with auth files", + opts: []testcontainers.ContainerCustomizer{ + atlaslocal.WithUsernameFile(usernameFilepath), + atlaslocal.WithPasswordFile(passwordFilepath), + atlaslocal.WithInitDatabase("testdb"), + }, + wantUsername: "file_testuser", + wantPassword: "file_testpass", + wantDatabase: "testdb", + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + ctr, err := atlaslocal.Run(context.Background(), latestImage, tc.opts...) + testcontainers.CleanupContainer(t, ctr) + require.NoError(t, err) + + csRaw, err := ctr.ConnectionString(context.Background()) + require.NoError(t, err) + + connString, err := connstring.ParseAndValidate(csRaw) + require.NoError(t, err, "Failed to parse connection string") + + require.Equal(t, "mongodb", connString.Scheme) + require.Equal(t, "localhost", connString.Hosts[0][:9]) + require.NotEmpty(t, connString.Hosts[0][10:], "Port should be non-empty") + require.Equal(t, tc.wantUsername, connString.Username) + require.Equal(t, tc.wantPassword, connString.Password) + require.Equal(t, tc.wantDatabase, connString.Database) + require.True(t, connString.DirectConnection) + }) + } +} + +// Test Helper Functions + +func requireEnvVar(t *testing.T, ctr testcontainers.Container, envVarName, expected string) { + t.Helper() + + exitCode, reader, err := ctr.Exec(context.Background(), []string{"sh", "-c", "echo $" + envVarName}) + require.NoError(t, err) + require.Equal(t, 0, exitCode) + + outBytes, err := io.ReadAll(reader) + require.NoError(t, err) + + // testcontainers-go's Exec() returns a multiplexed stream in the same format + // used by the Docker API. Each frame is prefixed with an 8-byte header. + require.Greater(t, len(outBytes), 8, "Exec output too short to contain env var value") + + out := strings.TrimSpace(string(outBytes[8:])) + require.Equal(t, expected, out, "DO_NOT_TRACK env var value mismatch") +} + +func requireMongotLogs(t *testing.T, ctr testcontainers.Container) { + t.Helper() + + // Pull the log file and require non-empty. + reader, err := ctr.(*atlaslocal.Container).ReadMongotLogs(context.Background()) + require.NoError(t, err) + defer reader.Close() + + buf := make([]byte, 1) + _, _ = reader.Read(buf) // read at least one byte to ensure non-empty +} + +func requireNoMongotLogs(t *testing.T, ctr testcontainers.Container) { + t.Helper() + + // Pull the log file and require non-empty. + reader, err := ctr.(*atlaslocal.Container).ReadMongotLogs(context.Background()) + require.ErrorIs(t, err, os.ErrNotExist) + + if reader != nil { // Failure case where reader is non-nil + _ = reader.Close() + } +} + +func requireRunnerLogs(t *testing.T, ctr testcontainers.Container) { + t.Helper() + + // Pull the log file and require non-empty. + reader, err := ctr.(*atlaslocal.Container).ReadRunnerLogs(context.Background()) + require.NoError(t, err) + + defer reader.Close() + + buf := make([]byte, 1) + _, _ = reader.Read(buf) // Read at least one byte to ensure non-empty +} + +func requireNoRunnerLogs(t *testing.T, ctr testcontainers.Container) { + t.Helper() + + // Pull the log file and require non-empty. + reader, err := ctr.(*atlaslocal.Container).ReadRunnerLogs(context.Background()) + require.ErrorIs(t, err, os.ErrNotExist) + + if reader != nil { // Failure case where reader is non-nil + _ = reader.Close() + } +} + +// createSearchIndex creates a search index with the given name on the provided +// collection and waits for it to be acknowledged server-side. +func createSearchIndex(t *testing.T, ctx context.Context, coll *mongo.Collection, indexName string) { + t.Helper() + + // Create the default definition for search index + definition := bson.D{{Key: "mappings", Value: bson.D{{Key: "dynamic", Value: true}}}} + indexModel := mongo.SearchIndexModel{ + Definition: definition, + Options: options.SearchIndexes().SetName(indexName), + } + + _, err := coll.SearchIndexes().CreateOne(ctx, indexModel) + require.NoError(t, err) +} + +// executeAggregation connects to the MongoDB Atlas Local instance, creates a +// collection with a search index, inserts a document, and performs an +// aggregation using the search index. +func executeAggregation(t *testing.T, ctr testcontainers.Container) { + t.Helper() + + client, td := newMongoClient(t, context.Background(), ctr) + defer td() + + err := client.Database("test").CreateCollection(context.Background(), "search") + require.NoError(t, err) + + coll := client.Database("test").Collection("search") + + siCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + // Create a search index on the collection. + createSearchIndex(t, siCtx, coll, "test_search_index") + + // Insert a document into the collection and aggregate it using the search + // index which should log the operation to the mongot log file. + _, err = coll.InsertOne(context.Background(), bson.D{{Key: "txt", Value: "hello"}}) + require.NoError(t, err) + + pipeline := mongo.Pipeline{{ + {Key: "$search", Value: bson.D{ + {Key: "text", Value: bson.D{{Key: "query", Value: "hello"}, {Key: "path", Value: "txt"}}}, + }}, + }} + + cur, err := coll.Aggregate(context.Background(), pipeline) + require.NoError(t, err) + + err = cur.Close(context.Background()) + require.NoError(t, err) +} + +func newMongoClient( + t *testing.T, + ctx context.Context, + ctr testcontainers.Container, + opts ...*options.ClientOptions, +) (*mongo.Client, func()) { + t.Helper() + + connString, err := ctr.(*atlaslocal.Container).ConnectionString(ctx) + require.NoError(t, err) + + copts := []*options.ClientOptions{ + options.Client().ApplyURI(connString), + } + + copts = append(copts, opts...) + + client, err := mongo.Connect(copts...) + require.NoError(t, err) + + return client, func() { + err := client.Disconnect(context.Background()) + require.NoError(t, err, "Failed to disconnect MongoDB client") + } +} + +func createInitScripts(t *testing.T, scripts map[string]string) string { + t.Helper() + + tmpDir := t.TempDir() + + for filename, content := range scripts { + scriptPath := filepath.Join(tmpDir, filename) + require.NoError(t, os.WriteFile(scriptPath, []byte(content), 0o755)) + + // Sanity check to verify that the script content is as expected. + got, err := os.ReadFile(scriptPath) + require.NoError(t, err, "Failed to read init script %s", filename) + require.Equal(t, string(got), content, "Content of init script %s does not match", filename) + } + + return tmpDir +} + +func requireInitScriptsExist(t *testing.T, ctr testcontainers.Container, expectedScripts map[string]string) { + t.Helper() + + const dstDir = "/docker-entrypoint-initdb.d" + + exit, r, err := ctr.Exec(context.Background(), []string{"sh", "-lc", "ls -l " + dstDir}) + require.NoError(t, err) + + // If the map is empty, the command returns exit code 2. + if len(expectedScripts) == 0 { + require.Equal(t, 2, exit) + } else { + require.Equal(t, 0, exit) + } + + listingBytes, err := io.ReadAll(r) + require.NoError(t, err) + + listing := string(listingBytes) + + for name, want := range expectedScripts { + require.Contains(t, listing, name, "Init script %s not found in container", name) + + rc, err := ctr.CopyFileFromContainer(context.Background(), filepath.Join(dstDir, name)) + require.NoError(t, err, "Failed to copy init script %s from container", name) + + got, err := io.ReadAll(rc) + require.NoError(t, err, "Failed to read init script %s content", name) + + err = rc.Close() + require.NoError(t, err, "Failed to close reader for init script %s", name) + + require.Equal(t, want, string(got), "Content of init script %s does not match", name) + } +} + +func requireInitScriptsDoesNotExist(t *testing.T, ctr testcontainers.Container, expectedScripts map[string]string) { + t.Helper() + + // Sanity check to verify that all scripts are present. + for filename := range expectedScripts { + cmd := []string{"sh", "-c", "cd docker-entrypoint-initdb.d && ls -l"} + + exitCode, reader, err := ctr.Exec(context.Background(), cmd) + require.NoError(t, err) + require.Zero(t, exitCode, "Expected exit code 0 for command: %v", cmd) + + content, _ := io.ReadAll(reader) + require.NotContains(t, string(content), filename) + } +} + +func requireContainerLogsNotEmpty(t *testing.T, ctr testcontainers.Container) { + t.Helper() + + logs, err := ctr.Logs(context.Background()) + require.NoError(t, err) + + defer logs.Close() + + logBytes, err := io.ReadAll(logs) + require.NoError(t, err) + + require.NotEmpty(t, logBytes, "Container logs should not be empty") +} + +func newAuthFiles(t *testing.T) (string, string, string) { + t.Helper() + + tmpDir := t.TempDir() + + // Create username and password files. + usernameFilepath := filepath.Join(tmpDir, "username.txt") + + err := os.WriteFile(usernameFilepath, []byte("file_testuser"), 0o755) + require.NoError(t, err) + + _, err = os.Stat(usernameFilepath) + require.NoError(t, err, "Username file should exist") + + // Create the password file. + passwordFilepath := filepath.Join(tmpDir, "password.txt") + + err = os.WriteFile(passwordFilepath, []byte("file_testpass"), 0o755) + require.NoError(t, err) + + _, err = os.Stat(passwordFilepath) + require.NoError(t, err, "Password file should exist") + + return tmpDir, usernameFilepath, passwordFilepath +} diff --git a/modules/mongodb/atlaslocal/examples_test.go b/modules/mongodb/atlaslocal/examples_test.go new file mode 100644 index 0000000000..0988dfffed --- /dev/null +++ b/modules/mongodb/atlaslocal/examples_test.go @@ -0,0 +1,174 @@ +package atlaslocal_test + +import ( + "context" + "fmt" + "io" + "log" + + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" + + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/modules/mongodb/atlaslocal" +) + +func ExampleRun() { + // runMongoDBAtlasLocalContainer { + ctx := context.Background() + + atlaslocalContainer, err := atlaslocal.Run(ctx, "mongodb/mongodb-atlas-local:latest") + defer func() { + if err := testcontainers.TerminateContainer(atlaslocalContainer); err != nil { + log.Printf("failed to terminate container: %s", err) + } + }() + if err != nil { + log.Printf("failed to start container: %s", err) + return + } + // } + + state, err := atlaslocalContainer.State(ctx) + if err != nil { + log.Printf("failed to get container state: %s", err) + return + } + + fmt.Println(state.Running) + + // Output: + // true +} + +func ExampleRun_connect() { + // connectToMongo { + ctx := context.Background() + + atlaslocalContainer, err := atlaslocal.Run(ctx, "mongodb/mongodb-atlas-local:latest") + defer func() { + if err := testcontainers.TerminateContainer(atlaslocalContainer); err != nil { + log.Printf("failed to terminate container: %s", err) + } + }() + if err != nil { + log.Printf("failed to start container: %s", err) + return + } + + connString, err := atlaslocalContainer.ConnectionString(ctx) + if err != nil { + log.Printf("failed to get connection string: %s", err) + return + } + + mongoClient, err := mongo.Connect(options.Client().ApplyURI(connString)) + if err != nil { + log.Printf("failed to connect to MongoDB: %s", err) + return + } + // } + + err = mongoClient.Ping(ctx, nil) + if err != nil { + log.Printf("failed to ping MongoDB: %s", err) + return + } + + fmt.Println(mongoClient.Database("test").Name()) + + // Output: + // test +} + +func ExampleRun_readMongotLogs() { + // mongotLogsRead { + ctx := context.Background() + + atlaslocalContainer, err := atlaslocal.Run(ctx, "mongodb/mongodb-atlas-local:latest", + atlaslocal.WithMongotLogFile()) + + defer func() { + if err := testcontainers.TerminateContainer(atlaslocalContainer); err != nil { + log.Printf("failed to terminate container: %s", err) + } + }() + + if err != nil { + log.Printf("failed to start container: %s", err) + return + } + + connString, err := atlaslocalContainer.ConnectionString(ctx) + if err != nil { + log.Printf("failed to get connection string: %s", err) + return + } + + _, err = mongo.Connect(options.Client().ApplyURI(connString)) + if err != nil { + log.Printf("failed to connect to MongoDB: %s", err) + return + } + + reader, err := atlaslocalContainer.ReadMongotLogs(ctx) + if err != nil { + log.Printf("failed to read mongot logs: %s", err) + return + } + defer reader.Close() + + if _, err := io.Copy(io.Discard, reader); err != nil { + log.Printf("failed to write mongot logs: %s", err) + return + } + // } + + // Output: +} + +func ExampleRun_readRunnerLogs() { + // runnerLogsRead { + ctx := context.Background() + + atlaslocalContainer, err := atlaslocal.Run(ctx, "mongodb/mongodb-atlas-local:latest", + atlaslocal.WithRunnerLogFile()) + + defer func() { + if err := testcontainers.TerminateContainer(atlaslocalContainer); err != nil { + log.Printf("failed to terminate container: %s", err) + } + }() + + if err != nil { + log.Printf("failed to start container: %s", err) + return + } + + connString, err := atlaslocalContainer.ConnectionString(ctx) + if err != nil { + log.Printf("failed to get connection string: %s", err) + return + } + + _, err = mongo.Connect(options.Client().ApplyURI(connString)) + if err != nil { + log.Printf("failed to connect to MongoDB: %s", err) + return + } + + reader, err := atlaslocalContainer.ReadRunnerLogs(ctx) + if err != nil { + log.Printf("failed to read runner logs: %s", err) + return + } + defer reader.Close() + + if _, err := io.Copy(io.Discard, reader); err != nil { + log.Printf("failed to write runner logs: %s", err) + return + } + // } + + // Output: +} diff --git a/modules/mongodb/atlaslocal/options.go b/modules/mongodb/atlaslocal/options.go new file mode 100644 index 0000000000..0dc5734b92 --- /dev/null +++ b/modules/mongodb/atlaslocal/options.go @@ -0,0 +1,399 @@ +package atlaslocal + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/testcontainers/testcontainers-go" +) + +const ( + passwordContainerPath = "/run/secrets/mongo-root-password" + usernameContainerPath = "/run/secrets/mongo-root-username" + envMongotLogFile = "MONGOT_LOG_FILE" + envRunnerLogFile = "RUNNER_LOG_FILE" + envMongoDBInitDatabase = "MONGODB_INITDB_DATABASE" + envMongoDBInitUsername = "MONGODB_INITDB_ROOT_USERNAME" + envMongoDBInitPassword = "MONGODB_INITDB_ROOT_PASSWORD" + envMongoDBInitUsernameFile = "MONGODB_INITDB_ROOT_USERNAME_FILE" + envMongoDBInitPasswordFile = "MONGODB_INITDB_ROOT_PASSWORD_FILE" + envDoNotTrack = "DO_NOT_TRACK" +) + +// Compiler check to ensure that Option implements the testcontainers.ContainerCustomizer interface. +var _ testcontainers.ContainerCustomizer = (Option)(nil) + +type options struct { + username string + password string + localUsernameFile string + localPasswordFile string + noTelemetry bool + database string + mongotLogPath string + runnerLogPath string + + files []testcontainers.ContainerFile +} + +func (opts options) env() map[string]string { + env := map[string]string{} + + if opts.username != "" { + env[envMongoDBInitUsername] = opts.username + } + + if opts.password != "" { + env[envMongoDBInitPassword] = opts.password + } + + if opts.localUsernameFile != "" { + env[envMongoDBInitUsernameFile] = usernameContainerPath + } + + if opts.localPasswordFile != "" { + env[envMongoDBInitPasswordFile] = passwordContainerPath + } + + if opts.noTelemetry { + env[envDoNotTrack] = "1" + } + + if opts.database != "" { + env[envMongoDBInitDatabase] = opts.database + } + + if opts.mongotLogPath != "" { + env[envMongotLogFile] = opts.mongotLogPath + } + + if opts.runnerLogPath != "" { + env[envRunnerLogFile] = opts.runnerLogPath + } + + return env +} + +func (opts options) validate() error { + username := opts.username + password := opts.password + + // If username or password is specified, both must be provided. + if username != "" && password == "" || username == "" && password != "" { + return errors.New("if you specify username or password, you must provide both of them") + } + + usernameFile := opts.localUsernameFile + passwordFile := opts.localPasswordFile + + // If username file or password file is specified, both must be provided. + if usernameFile != "" && passwordFile == "" || usernameFile == "" && passwordFile != "" { + return errors.New("if you specify username file or password file, you must provide both of them") + } + + // Setting credentials both inline and using files will result in a panic + // from the container, so we short circuit here. + if (username != "" || password != "") && (usernameFile != "" || passwordFile != "") { + return errors.New("you cannot specify both inline credentials and files for credentials") + } + + return nil +} + +// parseUsername will return either the username provided by WithUsername or +// from the local file specified by WithUsernameFile. If both are provided, this +// function will return an error. If neither is provided, an empty string is +// returned. +func (opts options) parseUsername() (string, error) { + if opts.username == "" && opts.localUsernameFile == "" { + return "", nil + } + + if opts.username != "" && opts.localUsernameFile != "" { + return "", errors.New("cannot specify both inline credentials and files for credentials") + } + + if opts.username != "" { + return opts.username, nil + } + + r, err := os.ReadFile(opts.localUsernameFile) + return strings.TrimSpace(string(r)), err +} + +// parsePassword will return either the password provided by WithPassword or +// from the local file specified by WithPasswordFile. If both are provided, this +// function will return an error. If neither is provided, an empty string is +// returned. +func (opts options) parsePassword() (string, error) { + if opts.password == "" && opts.localPasswordFile == "" { + return "", nil + } + + if opts.password != "" && opts.localPasswordFile != "" { + return "", errors.New("cannot specify both inline credentials and files for credentials") + } + + if opts.password != "" { + return opts.password, nil + } + + r, err := os.ReadFile(opts.localPasswordFile) + return strings.TrimSpace(string(r)), err +} + +// 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 +} + +// WithUsername sets the MongoDB root username by setting the +// MONGODB_INITDB_ROOT_USERNAME environment variable. +func WithUsername(username string) Option { + return func(opts *options) error { + if username != "" { + opts.username = username + } + + return nil + } +} + +// WithPassword sets the MongoDB root password by setting the the +// MONGODB_INITDB_ROOT_PASSWORD environment variable. +func WithPassword(password string) Option { + return func(opts *options) error { + if password != "" { + opts.password = password + } + + return nil + } +} + +// WithUsernameFile mounts a local file as the MongoDB root username secret at +// /run/secrets/mongo-root-username and sets MONGODB_INITDB_ROOT_USERNAME_FILE. +// The path must be absolute and exist; no-op if empty. +func WithUsernameFile(usernameFile string) Option { + return func(opts *options) error { + if usernameFile == "" { + return nil + } + + // Must be an absolute path. + if !filepath.IsAbs(usernameFile) { + return fmt.Errorf("username file mount path must be absolute, got: %s", usernameFile) + } + + // Must exist and be a file. + info, err := os.Stat(usernameFile) + if err != nil { + return fmt.Errorf("username file does not exist or is not accessible: %w", err) + } + + if info.IsDir() { + return fmt.Errorf("username file must be a file, got a directory: %s", usernameFile) + } + + opts.localUsernameFile = usernameFile + + opts.files = append(opts.files, testcontainers.ContainerFile{ + HostFilePath: usernameFile, + ContainerFilePath: usernameContainerPath, + FileMode: 0o444, + }) + + return nil + } +} + +// WithPasswordFile mounts a local file as the MongoDB root password secret at +// /run/secrets/mongo-root-password and sets MONGODB_INITDB_ROOT_PASSWORD_FILE. +// Path must be absolute and an existing file; no-op if empty. +func WithPasswordFile(passwordFile string) Option { + return func(opts *options) error { + if passwordFile == "" { + return nil + } + + // Must be an absolute path. + if !filepath.IsAbs(passwordFile) { + return fmt.Errorf("password file mount path must be absolute, got: %s", passwordFile) + } + + // Must exist and be a file. + info, err := os.Stat(passwordFile) + if err != nil { + return fmt.Errorf("password file does not exist or is not accessible: %w", err) + } + + if info.IsDir() { + return fmt.Errorf("password file must be a file, got a directory: %s", passwordFile) + } + + opts.localPasswordFile = passwordFile + + opts.files = append(opts.files, testcontainers.ContainerFile{ + HostFilePath: passwordFile, + ContainerFilePath: passwordContainerPath, + FileMode: 0o444, + }) + + return nil + } +} + +// WithNoTelemetry opts out of telemetry for the MongoDB Atlas Local +// container by setting the DO_NOT_TRACK environment variable to 1. +func WithNoTelemetry() Option { + return func(opts *options) error { + opts.noTelemetry = true + + return nil + } +} + +// WithInitDatabase sets MONGODB_INITDB_DATABASE environment variable so the +// init scripts and the default connection string target the specified database +// instead of the default "test" database. +func WithInitDatabase(database string) Option { + return func(opts *options) error { + opts.database = database + + return nil + } +} + +// WithInitScripts mounts a directory containing .sh/.js init scripts into +// /docker-entrypoint-initdb.d so they run in alphabetical order on startup. If +// called multiple times, this function removes any prior init-scripts bind and +// uses only the latest on specified. +func WithInitScripts(scriptsDir string) Option { + return func(opts *options) error { + if scriptsDir == "" { + return nil + } + + abs, err := filepath.Abs(scriptsDir) + if err != nil { + return fmt.Errorf("get absolute path of init scripts dir: %w", err) + } + + st, err := os.Stat(abs) + if err != nil { + return fmt.Errorf("stat init scripts dir: %w", err) + } + + if !st.IsDir() { + return fmt.Errorf("init scripts path is not a directory: %s", abs) + } + + const dstDir = "/docker-entrypoint-initdb.d/" + + filtered := opts.files[:0] + for _, file := range opts.files { + if !strings.HasPrefix(file.ContainerFilePath, dstDir) { + filtered = append(filtered, file) + } + } + + opts.files = filtered + + entries, err := os.ReadDir(abs) + if err != nil { + return fmt.Errorf("read init scripts dir: %w", err) + } + + for _, entry := range entries { + if entry.IsDir() { + continue + } + + name := entry.Name() + if !strings.HasSuffix(name, ".sh") && !strings.HasSuffix(name, ".js") { + continue + } + + f := testcontainers.ContainerFile{ + HostFilePath: filepath.Join(abs, name), + ContainerFilePath: filepath.Join(dstDir, name), + FileMode: 0o644, + } + + if strings.HasSuffix(name, ".sh") { + f.FileMode = 0o755 // Make shell scripts executable. + } + + opts.files = append(opts.files, f) + } + + return nil + } +} + +// WithMongotLogStdout writes to /dev/stdout inside the container. See +// (*Container).ReadMongotLogs to read the logs locally. +func WithMongotLogToStdout() Option { + return func(opts *options) error { + opts.mongotLogPath = "/dev/stdout" + + return nil + } +} + +// WithMongotLogToStderr writes to /dev/stderr inside the container. See +// (*Container).ReadMongotLogs to read the logs locally. +func WithMongotLogToStderr() Option { + return func(opts *options) error { + opts.mongotLogPath = "/dev/stderr" + + return nil + } +} + +// WithMongotLogFile writes the mongot logs to /tmp/mongot.log inside the +// container. See (*Container).ReadMongotLogs to read the logs locally. +func WithMongotLogFile() Option { + return func(opts *options) error { + opts.mongotLogPath = "/tmp/mongot.log" + + return nil + } +} + +// WithRunnerLogToStdout writes to /dev/stdout inside the container. See +// (*Container).ReadRunnerLogs to read the logs locally. +func WithRunnerLogToStdout() Option { + return func(opts *options) error { + opts.runnerLogPath = "/dev/stdout" + + return nil + } +} + +// WithRunnerLogToStderr writes to /dev/stderr inside the container. See +// (*Container).ReadRunnerLogs to read the logs locally. +func WithRunnerLogToStderr() Option { + return func(opts *options) error { + opts.runnerLogPath = "/dev/stderr" + + return nil + } +} + +// WithRunnerLogFile writes the runner logs to /tmp/runner.log inside the +// container. See (*Container).ReadRunnerLogs to read the logs locally. +func WithRunnerLogFile() Option { + return func(opts *options) error { + opts.runnerLogPath = "/tmp/runner.log" + + return nil + } +} diff --git a/modules/mongodb/mongodb.go b/modules/mongodb/mongodb.go index 76a3c57125..844c525e11 100644 --- a/modules/mongodb/mongodb.go +++ b/modules/mongodb/mongodb.go @@ -17,6 +17,7 @@ import ( var entrypointContent []byte const ( + defaultPort = "27017/tcp" entrypointPath = "/tmp/entrypoint-tc.sh" keyFilePath = "/tmp/mongo_keyfile" replicaSetOptEnvKey = "testcontainers.mongodb.replicaset_name" @@ -40,10 +41,10 @@ func RunContainer(ctx context.Context, opts ...testcontainers.ContainerCustomize func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*MongoDBContainer, error) { req := testcontainers.ContainerRequest{ Image: img, - ExposedPorts: []string{"27017/tcp"}, + ExposedPorts: []string{defaultPort}, WaitingFor: wait.ForAll( wait.ForLog("Waiting for connections"), - wait.ForListeningPort("27017/tcp"), + wait.ForListeningPort(defaultPort), ), Env: map[string]string{}, } @@ -118,7 +119,7 @@ func WithReplicaSet(replSetName string) testcontainers.CustomizeRequestOption { // ConnectionString returns the connection string for the MongoDB container. // If you provide a username and a password, the connection string will also include them. func (c *MongoDBContainer) ConnectionString(ctx context.Context) (string, error) { - endpoint, err := c.PortEndpoint(ctx, "27017/tcp", "") + endpoint, err := c.PortEndpoint(ctx, defaultPort, "") if err != nil { return "", err }