diff --git a/.github/dependabot.yml b/.github/dependabot.yml index af64b8d3a7..71139abb15 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -42,12 +42,6 @@ updates: interval: monthly open-pull-requests-limit: 3 rebase-strategy: disabled - - package-ecosystem: gomod - directory: /examples/mysql - schedule: - interval: monthly - open-pull-requests-limit: 3 - rebase-strategy: disabled - package-ecosystem: gomod directory: /examples/nginx schedule: @@ -102,6 +96,12 @@ updates: interval: monthly open-pull-requests-limit: 3 rebase-strategy: disabled + - package-ecosystem: gomod + directory: /modules/mysql + schedule: + interval: monthly + open-pull-requests-limit: 3 + rebase-strategy: disabled - package-ecosystem: gomod directory: /modules/postgres schedule: diff --git a/.github/workflows/mysql-example.yml b/.github/workflows/module-mysql.yml similarity index 78% rename from .github/workflows/mysql-example.yml rename to .github/workflows/module-mysql.yml index 025a18043a..124ade3b5e 100644 --- a/.github/workflows/mysql-example.yml +++ b/.github/workflows/module-mysql.yml @@ -1,9 +1,9 @@ -name: Mysql example pipeline +name: MySQL module pipeline on: [push, pull_request] concurrency: - group: "${{ github.workflow }}-${{ github.head_ref || github.sha }}" + group: ${{ github.workflow }}-${{ github.head_ref || github.sha }} cancel-in-progress: true jobs: @@ -24,15 +24,15 @@ jobs: uses: actions/checkout@v3 - name: modVerify - working-directory: ./examples/mysql + working-directory: ./modules/mysql run: go mod verify - name: modTidy - working-directory: ./examples/mysql + working-directory: ./modules/mysql run: make tools-tidy - name: gotestsum - working-directory: ./examples/mysql + working-directory: ./modules/mysql run: make test-unit - name: Run checker diff --git a/docs/examples/mysql.md b/docs/examples/mysql.md deleted file mode 100644 index 9aee5f40ca..0000000000 --- a/docs/examples/mysql.md +++ /dev/null @@ -1,9 +0,0 @@ -# Mysql - - -[Creating a Mysql container](../../examples/mysql/mysql.go) - - - -[Test for a Mysql container](../../examples/mysql/mysql_test.go) - diff --git a/docs/modules/mysql.md b/docs/modules/mysql.md new file mode 100644 index 0000000000..468215f9ea --- /dev/null +++ b/docs/modules/mysql.md @@ -0,0 +1,68 @@ +# MySQL + +## Adding this module to your project dependencies + +Please run the following command to add the MySQL module to your Go dependencies: + +``` +go get github.com/testcontainers/testcontainers-go/modules/mysql +``` + +## Usage example + + +[Creating a MySQL container](../../modules/mysql/mysql.go) + + +## Module Reference + +The MySQL module exposes one entrypoint function to create the container, and this function receives two parameters: + +```golang +func StartContainer(ctx context.Context, opts ...Option) (*MySQLContainer, error) { +``` + +- `context.Context`, the Go context. +- `Option`, a variad argument for passing options. + +## Container Options + +When starting the MySQL container, you can pass options in a variadic way to configure it. + +!!!tip + + You can find all the available configuration and environment variables for the MySQL Docker image on [Docker Hub](https://hub.docker.com/_/mysql). + +### Set Image + +By default, the image used is `mysql:8`. If you need to use a different image, you can use `WithImage` option. + + +[Custom Image](../../modules/mysql/mysql_test.go) inside_block:withConfigFile + + +### Set username, password and database name + +If you need to set a different database, and its credentials, you can use `WithUsername`, `WithPassword`, `WithDatabase` +options. By default, the username, the password and the database name is `test`. + + +[Custom Database initialization](../../modules/mysql/mysql_test.go) inside_block:customInitialization + + +### Init Scripts + +If you would like to perform DDL or DML operations in the MySQL container, add one or more `*.sql`, `*.sql.gz`, or `*.sh` +scripts to the container request. Those files will be copied under `/docker-entrypoint-initdb.d`. + + +[Include init scripts](../../modules/mysql/mysql_test.go) inside_block:withScripts + + +### Custom configuration + +If you need to set a custom configuration, you can use `WithConfigFile` option. + + +[Custom MySQL config file](../../modules/mysql/mysql_test.go) inside_block:withConfigFile + diff --git a/examples/mysql/mysql.go b/examples/mysql/mysql.go deleted file mode 100644 index 2a8cf42192..0000000000 --- a/examples/mysql/mysql.go +++ /dev/null @@ -1,38 +0,0 @@ -package mysql - -import ( - "context" - - "github.com/testcontainers/testcontainers-go" - "github.com/testcontainers/testcontainers-go/wait" -) - -// mysqlContainer represents the mysql container type used in the module -type mysqlContainer struct { - testcontainers.Container -} - -// startContainer creates an instance of the mysql container type -func startContainer(ctx context.Context) (*mysqlContainer, error) { - req := testcontainers.ContainerRequest{ - Image: "mysql:8", - ExposedPorts: []string{"3306/tcp", "33060/tcp"}, - Env: map[string]string{ - "MYSQL_ROOT_PASSWORD": "password", - "MYSQL_DATABASE": "database", - }, - WaitingFor: wait.ForAll( - wait.ForLog("port: 3306 MySQL Community Server - GPL"), - wait.ForListeningPort("3306/tcp"), - ), - } - container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ - ContainerRequest: req, - Started: true, - }) - if err != nil { - return nil, err - } - - return &mysqlContainer{Container: container}, nil -} diff --git a/examples/mysql/mysql_test.go b/examples/mysql/mysql_test.go deleted file mode 100644 index 7e450c4644..0000000000 --- a/examples/mysql/mysql_test.go +++ /dev/null @@ -1,55 +0,0 @@ -package mysql - -import ( - "context" - "database/sql" - "fmt" - "testing" - - // Import mysql into the scope of this package (required) - _ "github.com/go-sql-driver/mysql" -) - -func TestMysql(t *testing.T) { - ctx := context.Background() - - container, err := startContainer(ctx) - 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) - } - }) - - // perform assertions - - host, _ := container.Host(ctx) - - p, _ := container.MappedPort(ctx, "3306/tcp") - port := p.Int() - - connectionString := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?tls=skip-verify", - "root", "password", host, port, "database") - - db, err := sql.Open("mysql", connectionString) - if err != nil { - t.Fatal(err) - } - defer db.Close() - - if err = db.Ping(); err != nil { - t.Errorf("error pinging db: %+v\n", err) - } - _, err = db.Exec("CREATE TABLE IF NOT EXISTS a_table ( \n" + - " `col_1` VARCHAR(128) NOT NULL, \n" + - " `col_2` VARCHAR(128) NOT NULL, \n" + - " PRIMARY KEY (`col_1`, `col_2`) \n" + - ")") - if err != nil { - t.Errorf("error creating table: %+v\n", err) - } -} diff --git a/mkdocs.yml b/mkdocs.yml index c1f181d4a7..6f7e79f71a 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -53,6 +53,7 @@ nav: - modules/index.md - modules/couchbase.md - modules/localstack.md + - modules/mysql.md - modules/postgres.md - modules/pulsar.md - modules/vault.md @@ -64,7 +65,6 @@ nav: - examples/datastore.md - examples/firestore.md - examples/mongodb.md - - examples/mysql.md - examples/nginx.md - examples/pubsub.md - examples/redis.md diff --git a/examples/mysql/Makefile b/modules/mysql/Makefile similarity index 100% rename from examples/mysql/Makefile rename to modules/mysql/Makefile diff --git a/examples/mysql/go.mod b/modules/mysql/go.mod similarity index 97% rename from examples/mysql/go.mod rename to modules/mysql/go.mod index ab3dd76d55..3c6229d190 100644 --- a/examples/mysql/go.mod +++ b/modules/mysql/go.mod @@ -1,4 +1,4 @@ -module github.com/testcontainers/testcontainers-go/examples/mysql +module github.com/testcontainers/testcontainers-go/modules/mysql go 1.19 diff --git a/examples/mysql/go.sum b/modules/mysql/go.sum similarity index 100% rename from examples/mysql/go.sum rename to modules/mysql/go.sum diff --git a/modules/mysql/mysql.go b/modules/mysql/mysql.go new file mode 100644 index 0000000000..8408075e21 --- /dev/null +++ b/modules/mysql/mysql.go @@ -0,0 +1,158 @@ +package mysql + +import ( + "context" + "fmt" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" + "path/filepath" + "strings" +) + +const rootUser = "root" +const defaultUser = "test" +const defaultPassword = "test" +const defaultDatabaseName = "test" +const defaultImage = "mysql:8" + +// MySQLContainer represents the MySQL container type used in the module +type MySQLContainer struct { + testcontainers.Container + username string + password string + database string +} + +type MySQLContainerOption func(req *testcontainers.ContainerRequest) + +// StartContainer creates an instance of the MySQL container type +func StartContainer(ctx context.Context, opts ...MySQLContainerOption) (*MySQLContainer, error) { + req := testcontainers.ContainerRequest{ + Image: defaultImage, + ExposedPorts: []string{"3306/tcp", "33060/tcp"}, + Env: map[string]string{ + "MYSQL_USER": defaultUser, + "MYSQL_PASSWORD": defaultPassword, + "MYSQL_DATABASE": defaultDatabaseName, + }, + WaitingFor: wait.ForLog("port: 3306 MySQL Community Server"), + } + + opts = append(opts, func(req *testcontainers.ContainerRequest) { + username := req.Env["MYSQL_USER"] + password := req.Env["MYSQL_PASSWORD"] + if strings.EqualFold(rootUser, username) { + delete(req.Env, "MYSQL_USER") + } + if len(password) != 0 && password != "" { + req.Env["MYSQL_ROOT_PASSWORD"] = password + } else if strings.EqualFold(rootUser, username) { + req.Env["MYSQL_ALLOW_EMPTY_PASSWORD"] = "yes" + delete(req.Env, "MYSQL_PASSWORD") + } + }) + + for _, opt := range opts { + opt(&req) + } + + username, ok := req.Env["MYSQL_USER"] + if !ok { + username = rootUser + } + password := req.Env["MYSQL_PASSWORD"] + + if len(password) == 0 && password == "" && !strings.EqualFold(rootUser, username) { + return nil, fmt.Errorf("empty password can be used only with the root user") + } + + container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: req, + Started: true, + }) + if err != nil { + return nil, err + } + + database := req.Env["MYSQL_DATABASE"] + + return &MySQLContainer{container, username, password, database}, nil +} + +func (c *MySQLContainer) ConnectionString(ctx context.Context, args ...string) (string, error) { + containerPort, err := c.MappedPort(ctx, "3306/tcp") + if err != nil { + return "", err + } + + host, err := c.Host(ctx) + if err != nil { + return "", err + } + + extraArgs := "" + if len(args) > 0 { + extraArgs = strings.Join(args, "&") + } + if extraArgs != "" { + extraArgs = "?" + extraArgs + } + + connectionString := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s%s", c.username, c.password, host, containerPort.Port(), c.database, extraArgs) + return connectionString, nil +} + +// WithImage sets the image to be used for the mysql container +func WithImage(image string) func(req *testcontainers.ContainerRequest) { + return func(req *testcontainers.ContainerRequest) { + if image == "" { + image = "mysql:8" + } + + req.Image = image + } +} + +func WithUsername(username string) func(req *testcontainers.ContainerRequest) { + return func(req *testcontainers.ContainerRequest) { + req.Env["MYSQL_USER"] = username + } +} + +func WithPassword(password string) func(req *testcontainers.ContainerRequest) { + return func(req *testcontainers.ContainerRequest) { + req.Env["MYSQL_PASSWORD"] = password + } +} + +func WithDatabase(database string) func(req *testcontainers.ContainerRequest) { + return func(req *testcontainers.ContainerRequest) { + req.Env["MYSQL_DATABASE"] = database + } +} + +func WithConfigFile(configFile string) func(req *testcontainers.ContainerRequest) { + return func(req *testcontainers.ContainerRequest) { + cf := testcontainers.ContainerFile{ + HostFilePath: configFile, + ContainerFilePath: "/etc/mysql/conf.d/my.cnf", + FileMode: 0755, + } + req.Files = append(req.Files, cf) + } +} + +func WithScripts(scripts ...string) func(req *testcontainers.ContainerRequest) { + return func(req *testcontainers.ContainerRequest) { + var initScripts []testcontainers.ContainerFile + for _, script := range scripts { + cf := testcontainers.ContainerFile{ + HostFilePath: script, + ContainerFilePath: "/docker-entrypoint-initdb.d/" + filepath.Base(script), + FileMode: 0755, + } + initScripts = append(initScripts, cf) + } + req.Files = append(req.Files, initScripts...) + } +} diff --git a/modules/mysql/mysql_test.go b/modules/mysql/mysql_test.go new file mode 100644 index 0000000000..bb15014d3f --- /dev/null +++ b/modules/mysql/mysql_test.go @@ -0,0 +1,194 @@ +package mysql + +import ( + "context" + "database/sql" + "path/filepath" + "testing" + + // Import mysql into the scope of this package (required) + _ "github.com/go-sql-driver/mysql" +) + +func TestMySQL(t *testing.T) { + ctx := context.Background() + + container, err := StartContainer(ctx) + 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) + } + }) + + // perform assertions + connectionString, _ := container.ConnectionString(ctx, "tls=skip-verify") + + db, err := sql.Open("mysql", connectionString) + if err != nil { + t.Fatal(err) + } + defer db.Close() + + if err = db.Ping(); err != nil { + t.Errorf("error pinging db: %+v\n", err) + } + _, err = db.Exec("CREATE TABLE IF NOT EXISTS a_table ( \n" + + " `col_1` VARCHAR(128) NOT NULL, \n" + + " `col_2` VARCHAR(128) NOT NULL, \n" + + " PRIMARY KEY (`col_1`, `col_2`) \n" + + ")") + if err != nil { + t.Errorf("error creating table: %+v\n", err) + } +} + +func TestMySQLWithNonRootUserAndEmptyPassword(t *testing.T) { + ctx := context.Background() + + _, err := StartContainer(ctx, + WithDatabase("foo"), + WithUsername("test"), + WithPassword("")) + if err.Error() != "empty password can be used only with the root user" { + t.Fatal(err) + } +} + +func TestMySQLWithRootUserAndEmptyPassword(t *testing.T) { + ctx := context.Background() + + // customInitialization { + container, err := StartContainer(ctx, + WithDatabase("foo"), + WithUsername("root"), + WithPassword("")) + 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) + } + }) + + // perform assertions + connectionString, _ := container.ConnectionString(ctx) + + db, err := sql.Open("mysql", connectionString) + if err != nil { + t.Fatal(err) + } + defer db.Close() + + if err = db.Ping(); err != nil { + t.Errorf("error pinging db: %+v\n", err) + } + _, err = db.Exec("CREATE TABLE IF NOT EXISTS a_table ( \n" + + " `col_1` VARCHAR(128) NOT NULL, \n" + + " `col_2` VARCHAR(128) NOT NULL, \n" + + " PRIMARY KEY (`col_1`, `col_2`) \n" + + ")") + if err != nil { + t.Errorf("error creating table: %+v\n", err) + } +} + +func TestMySQLWithConfigFile(t *testing.T) { + ctx := context.Background() + + // withConfigFile { + container, err := StartContainer(ctx, WithImage("mysql:5.6.51"), + WithConfigFile("./testresources/my.cnf")) + 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) + } + }) + + // perform assertions + connectionString, _ := container.ConnectionString(ctx) + + db, err := sql.Open("mysql", connectionString) + if err != nil { + t.Fatal(err) + } + defer db.Close() + + if err = db.Ping(); err != nil { + t.Errorf("error pinging db: %+v\n", err) + } + stmt, _ := db.Prepare("SELECT @@GLOBAL.innodb_file_format") + if err != nil { + t.Fatal(err) + } + defer stmt.Close() + row := stmt.QueryRow() + var innodbFileFormat = "" + err = row.Scan(&innodbFileFormat) + if err != nil { + t.Errorf("error fetching innodb_file_format value") + } + if innodbFileFormat != "Barracuda" { + t.Fatal("The InnoDB file format has been set by the ini file content") + } +} + +func TestMySQLWithScripts(t *testing.T) { + ctx := context.Background() + + // withScripts { + container, err := StartContainer(ctx, + WithScripts(filepath.Join("testresources", "schema.sql"))) + 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) + } + }) + + // perform assertions + connectionString, _ := container.ConnectionString(ctx) + + db, err := sql.Open("mysql", connectionString) + if err != nil { + t.Fatal(err) + } + defer db.Close() + + if err = db.Ping(); err != nil { + t.Errorf("error pinging db: %+v\n", err) + } + stmt, err := db.Prepare("SELECT name from profile") + if err != nil { + t.Fatal(err) + } + defer stmt.Close() + row := stmt.QueryRow() + var name string + err = row.Scan(&name) + if err != nil { + t.Errorf("error fetching data") + } + if name != "profile 1" { + t.Fatal("The expected record was not found in the database.") + } +} diff --git a/modules/mysql/testresources/my.cnf b/modules/mysql/testresources/my.cnf new file mode 100644 index 0000000000..78012cc4bf --- /dev/null +++ b/modules/mysql/testresources/my.cnf @@ -0,0 +1,54 @@ +[mysqld] +user = mysql +port = 3306 +#socket = /tmp/mysql.sock +skip-external-locking +key_buffer_size = 16K +max_allowed_packet = 1M +table_open_cache = 4 +sort_buffer_size = 64K +read_buffer_size = 256K +read_rnd_buffer_size = 256K +net_buffer_length = 2K +thread_stack = 128K + + +innodb_file_format=Barracuda + + + + + +# Don't listen on a TCP/IP port at all. This can be a security enhancement, +# if all processes that need to connect to mysqld run on the same host. +# All interaction with mysqld must be made via Unix sockets or named pipes. +# Note that using this option without enabling named pipes on Windows +# (using the "enable-named-pipe" option) will render mysqld useless! +# +#skip-networking +#server-id = 1 + +# Uncomment the following if you want to log updates +#log-bin=mysql-bin + +# binary logging format - mixed recommended +#binlog_format=mixed + +# Causes updates to non-transactional engines using statement format to be +# written directly to binary log. Before using this option make sure that +# there are no dependencies between transactional and non-transactional +# tables such as in the statement INSERT INTO t_myisam SELECT * FROM +# t_innodb; otherwise, slaves may diverge from the master. +#binlog_direct_non_transactional_updates=TRUE + +# Uncomment the following if you are using InnoDB tables +innodb_data_file_path = ibdata1:10M:autoextend +# You can set .._buffer_pool_size up to 50 - 80 % +# of RAM but beware of setting memory usage too high +innodb_buffer_pool_size = 16M +#innodb_additional_mem_pool_size = 2M +# Set .._log_file_size to 25 % of buffer pool size +innodb_log_file_size = 5M +innodb_log_buffer_size = 8M +innodb_flush_log_at_trx_commit = 1 +innodb_lock_wait_timeout = 50 \ No newline at end of file diff --git a/modules/mysql/testresources/schema.sql b/modules/mysql/testresources/schema.sql new file mode 100644 index 0000000000..590774b81c --- /dev/null +++ b/modules/mysql/testresources/schema.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS profile ( + id MEDIUMINT NOT NULL AUTO_INCREMENT, + name VARCHAR(30) NOT NULL, + PRIMARY KEY (id) +); + +INSERT INTO profile (name) values ('profile 1'); diff --git a/examples/mysql/tools/tools.go b/modules/mysql/tools/tools.go similarity index 65% rename from examples/mysql/tools/tools.go rename to modules/mysql/tools/tools.go index a4301be862..ecc12882eb 100644 --- a/examples/mysql/tools/tools.go +++ b/modules/mysql/tools/tools.go @@ -1,7 +1,7 @@ //go:build tools // +build tools -// This package contains the tool dependencies of the mysql example. +// This package contains the tool dependencies of the MySQL module. package tools