Skip to content

Commit

Permalink
fix: mongodb replicaset should work with auth (testcontainers#2847)
Browse files Browse the repository at this point in the history
* added custom entrypoint to setup keyfile with proper user permissions

* added testcases for replicaset with auth

* implementation to support replicaset with auth

* removed unnecessary files

* cleanup

* added autodetection for user:group and entrypoint

* added tests for replica set and auth for different images

* renamed entrypoint to differentiate between custom entrypoint of testcontainers

* code cleanup

* renamed newly added tests

* fixed names of tests to use slash separated options

* fix: lint

---------

Co-authored-by: Manuel de la Peña <[email protected]>
  • Loading branch information
abhipranay and mdelapenya authored Oct 28, 2024
1 parent b7511cd commit 11eb809
Show file tree
Hide file tree
Showing 4 changed files with 218 additions and 34 deletions.
32 changes: 32 additions & 0 deletions modules/mongodb/cli.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package mongodb

import "fmt"

// mongoCli is cli to interact with MongoDB. If username and password are provided
// it will use credentials to authenticate.
type mongoCli struct {
mongoshBaseCmd string
mongoBaseCmd string
}

func newMongoCli(username string, password string) mongoCli {
authArgs := ""
if username != "" && password != "" {
authArgs = fmt.Sprintf("--username %s --password %s", username, password)
}

return mongoCli{
mongoshBaseCmd: fmt.Sprintf("mongosh %s --quiet", authArgs),
mongoBaseCmd: fmt.Sprintf("mongo %s --quiet", authArgs),
}
}

func (m mongoCli) eval(command string, args ...any) []string {
command = "\"" + fmt.Sprintf(command, args...) + "\""

return []string{
"sh",
"-c",
m.mongoshBaseCmd + " --eval " + command + " || " + m.mongoBaseCmd + " --eval " + command,
}
}
129 changes: 98 additions & 31 deletions modules/mongodb/mongodb.go
Original file line number Diff line number Diff line change
@@ -1,19 +1,31 @@
package mongodb

import (
"bytes"
"context"
_ "embed"
"fmt"
"time"

"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/wait"
)

//go:embed mount/entrypoint-tc.sh
var entrypointContent []byte

const (
entrypointPath = "/tmp/entrypoint-tc.sh"
keyFilePath = "/tmp/mongo_keyfile"
replicaSetOptEnvKey = "testcontainers.mongodb.replicaset_name"
)

// MongoDBContainer represents the MongoDB container type used in the module
type MongoDBContainer struct {
testcontainers.Container
username string
password string
username string
password string
replicaSet string
}

// Deprecated: use Run instead
Expand Down Expand Up @@ -50,10 +62,17 @@ func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustom
return nil, fmt.Errorf("if you specify username or password, you must provide both of them")
}

replicaSet := req.Env[replicaSetOptEnvKey]
if replicaSet != "" {
if err := configureRequestForReplicaset(username, password, replicaSet, &genericContainerReq); err != nil {
return nil, err
}
}

container, err := testcontainers.GenericContainer(ctx, genericContainerReq)
var c *MongoDBContainer
if container != nil {
c = &MongoDBContainer{Container: container, username: username, password: password}
c = &MongoDBContainer{Container: container, username: username, password: password, replicaSet: replicaSet}
}

if err != nil {
Expand Down Expand Up @@ -85,28 +104,10 @@ func WithPassword(password string) testcontainers.CustomizeRequestOption {
}
}

// WithReplicaSet configures the container to run a single-node MongoDB replica set named "rs".
// It will wait until the replica set is ready.
// WithReplicaSet sets the replica set name for Single node MongoDB replica set.
func WithReplicaSet(replSetName string) testcontainers.CustomizeRequestOption {
return func(req *testcontainers.GenericContainerRequest) error {
req.Cmd = append(req.Cmd, "--replSet", replSetName)
req.WaitingFor = wait.ForAll(
req.WaitingFor,
wait.ForExec(eval("rs.status().ok")),
).WithDeadline(60 * time.Second)
req.LifecycleHooks = append(req.LifecycleHooks, testcontainers.ContainerLifecycleHooks{
PostStarts: []testcontainers.ContainerHook{
func(ctx context.Context, c testcontainers.Container) error {
ip, err := c.ContainerIP(ctx)
if err != nil {
return fmt.Errorf("container ip: %w", err)
}

cmd := eval("rs.initiate({ _id: '%s', members: [ { _id: 0, host: '%s:27017' } ] })", replSetName, ip)
return wait.ForExec(cmd).WaitUntilReady(ctx, c)
},
},
})
req.Env[replicaSetOptEnvKey] = replSetName

return nil
}
Expand All @@ -129,14 +130,80 @@ func (c *MongoDBContainer) ConnectionString(ctx context.Context) (string, error)
return c.Endpoint(ctx, "mongodb")
}

// eval builds an mongosh|mongo eval command.
func eval(command string, args ...any) []string {
command = "\"" + fmt.Sprintf(command, args...) + "\""
func setupEntrypointForAuth(req *testcontainers.GenericContainerRequest) {
req.Files = append(
req.Files, testcontainers.ContainerFile{
Reader: bytes.NewReader(entrypointContent),
ContainerFilePath: entrypointPath,
FileMode: 0o755,
},
)
req.Entrypoint = []string{entrypointPath}
req.Env["MONGO_KEYFILE"] = keyFilePath
}

func configureRequestForReplicaset(
username string,
password string,
replicaSet string,
genericContainerReq *testcontainers.GenericContainerRequest,
) error {
if !(username != "" && password != "") {
return noAuthReplicaSet(replicaSet)(genericContainerReq)
}

return []string{
"sh",
"-c",
// In previous versions, the binary "mongosh" was named "mongo".
"mongosh --quiet --eval " + command + " || mongo --quiet --eval " + command,
return withAuthReplicaset(replicaSet, username, password)(genericContainerReq)
}

func noAuthReplicaSet(replSetName string) testcontainers.CustomizeRequestOption {
return func(req *testcontainers.GenericContainerRequest) error {
cli := newMongoCli("", "")
req.Cmd = append(req.Cmd, "--replSet", replSetName)
initiateReplicaSet(req, cli, replSetName)

return nil
}
}

func initiateReplicaSet(req *testcontainers.GenericContainerRequest, cli mongoCli, replSetName string) {
req.WaitingFor = wait.ForAll(
req.WaitingFor,
wait.ForExec(cli.eval("rs.status().ok")),
).WithDeadline(60 * time.Second)

req.LifecycleHooks = append(
req.LifecycleHooks, testcontainers.ContainerLifecycleHooks{
PostStarts: []testcontainers.ContainerHook{
func(ctx context.Context, c testcontainers.Container) error {
ip, err := c.ContainerIP(ctx)
if err != nil {
return fmt.Errorf("container ip: %w", err)
}

cmd := cli.eval(
"rs.initiate({ _id: '%s', members: [ { _id: 0, host: '%s:27017' } ] })",
replSetName,
ip,
)

return wait.ForExec(cmd).WaitUntilReady(ctx, c)
},
},
},
)
}

func withAuthReplicaset(
replSetName string,
username string,
password string,
) testcontainers.CustomizeRequestOption {
return func(req *testcontainers.GenericContainerRequest) error {
setupEntrypointForAuth(req)
cli := newMongoCli(username, password)
req.Cmd = append(req.Cmd, "--replSet", replSetName, "--keyFile", keyFilePath)
initiateReplicaSet(req, cli, replSetName)

return nil
}
}
59 changes: 56 additions & 3 deletions modules/mongodb/mongodb_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,26 +36,79 @@ func TestMongoDB(t *testing.T) {
opts: []testcontainers.ContainerCustomizer{},
},
{
name: "With Replica set and mongo:4",
name: "with-replica/mongo:4",
img: "mongo:4",
opts: []testcontainers.ContainerCustomizer{
mongodb.WithReplicaSet("rs"),
},
},
{
name: "With Replica set and mongo:6",
name: "with-replica/mongo:6",
img: "mongo:6",
opts: []testcontainers.ContainerCustomizer{
mongodb.WithReplicaSet("rs"),
},
},
{
name: "With Replica set and mongo:7",
name: "with-replica/mongo:7",
img: "mongo:7",
opts: []testcontainers.ContainerCustomizer{
mongodb.WithReplicaSet("rs"),
},
},
{
name: "with-auth/replica/mongo:7",
img: "mongo:7",
opts: []testcontainers.ContainerCustomizer{
mongodb.WithReplicaSet("rs"),
mongodb.WithUsername("tester"),
mongodb.WithPassword("testerpass"),
},
},
{
name: "with-auth/replica/mongo:6",
img: "mongo:6",
opts: []testcontainers.ContainerCustomizer{
mongodb.WithReplicaSet("rs"),
mongodb.WithUsername("tester"),
mongodb.WithPassword("testerpass"),
},
},
{
name: "with-auth/mongo:6",
img: "mongo:6",
opts: []testcontainers.ContainerCustomizer{
mongodb.WithUsername("tester"),
mongodb.WithPassword("testerpass"),
},
},
{
name: "with-auth/replica/mongodb-enterprise-server:7.0.0-ubi8",
img: "mongodb/mongodb-enterprise-server:7.0.0-ubi8",
opts: []testcontainers.ContainerCustomizer{
mongodb.WithReplicaSet("rs"),
mongodb.WithUsername("tester"),
mongodb.WithPassword("testerpass"),
},
},
{
name: "with-auth/replica/mongodb-community-server:7.0.2-ubi8",
img: "mongodb/mongodb-community-server:7.0.2-ubi8",
opts: []testcontainers.ContainerCustomizer{
mongodb.WithReplicaSet("rs"),
mongodb.WithUsername("tester"),
mongodb.WithPassword("testerpass"),
},
},
{
name: "with-auth/replica/mongo:4",
img: "mongo:4",
opts: []testcontainers.ContainerCustomizer{
mongodb.WithReplicaSet("rs"),
mongodb.WithUsername("tester"),
mongodb.WithPassword("testerpass"),
},
},
}

for _, tc := range testCases {
Expand Down
32 changes: 32 additions & 0 deletions modules/mongodb/mount/entrypoint-tc.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
#!/bin/bash

set -Eeuo pipefail

# detect mongo user and group
function get_user_group() {
user_group=$(cut -d: -f1,5 /etc/passwd | grep mongo)
echo "${user_group}"
}

# detect the entrypoint
function get_entrypoint() {
entrypoint=$(find /usr/local/bin -name 'docker-entrypoint.*')
if [[ "${entrypoint}" == *.py ]]; then
entrypoint="python3 ${entrypoint}"
else
entrypoint="exec ${entrypoint}"
fi
echo "${entrypoint}"
}

ENTRYPOINT=$(get_entrypoint)
MONGO_USER_GROUP=$(get_user_group)

# Create the keyfile
openssl rand -base64 756 > "${MONGO_KEYFILE}"

# Set the permissions and ownership of the keyfile
chown "${MONGO_USER_GROUP}" "${MONGO_KEYFILE}"
chmod 400 "${MONGO_KEYFILE}"

${ENTRYPOINT} "$@"

0 comments on commit 11eb809

Please sign in to comment.