Repository: ariga/atlas
Pull request #651 (2022-03-15 07:50:46)

internal/docker: docker client to spin up containers on the fly
This will be useful for spinning up an ephemeral dev db when replaying a migration directory or for normalizing. This is a first draft. 


Patch:
diff --git a/cmd/action/docker.go b/cmd/action/docker.go
new file mode 100644
index 00000000..6d4e12be
--- /dev/null
+++ b/cmd/action/docker.go
@@ -0,0 +1,232 @@
+// Copyright 2021-present The Atlas Authors. All rights reserved.
+// This source code is licensed under the Apache 2.0 license found
+// in the LICENSE file in the root directory of this source tree.
+
+package action
+
+import (
+	"bytes"
+	"context"
+	"database/sql"
+	"errors"
+	"fmt"
+	"io"
+	"io/ioutil"
+	"log"
+	"net"
+	"os"
+	"os/exec"
+	"strconv"
+	"strings"
+	"time"
+
+	mysqld "github.com/go-sql-driver/mysql"
+)
+
+const pass = "pass"
+
+type (
+	// DockerConfig is used to configure container creation.
+	DockerConfig struct {
+		Image string
+		Env   []string  // env vars to pass to the container
+		Port  string    // internal port to expose and connect to
+		Out   io.Writer // custom writer to send docker cli output to
+	}
+	// A Container is an instance of a created container.
+	Container struct {
+		cfg DockerConfig // DockerConfig used to create this container
+		// ID of the container.
+		ID string
+		// Passphrase of the root user.
+		Passphrase string
+		// Port on the host this containers service is bound to.
+		Port string
+	}
+	// DockerConfigOption allows configuring DockerConfig with functional arguments.
+	DockerConfigOption func(*DockerConfig) error
+)
+
+// NewConfig returns a new config with the given options applied.
+func NewConfig(opts ...DockerConfigOption) (*DockerConfig, error) {
+	c := &DockerConfig{Out: os.Stdout}
+	for _, opt := range opts {
+		if err := opt(c); err != nil {
+			return nil, err
+		}
+	}
+	return c, nil
+}
+
+// MySQL returns a new DockerConfig for a MySQL image.
+func MySQL(version string, opts ...DockerConfigOption) (*DockerConfig, error) {
+	return NewConfig(append(
+		[]DockerConfigOption{
+			Image("mysql:" + version),
+			Port("3306"),
+			Env("MYSQL_ROOT_PASSWORD=" + pass),
+		},
+		opts...,
+	)...,
+	)
+}
+
+// Image sets the docker image to use. For example:
+//
+//	Image("mysql")
+//	Image("postgres:13")
+//
+func Image(i string) DockerConfigOption {
+	return func(c *DockerConfig) error {
+		c.Image = i
+		return nil
+	}
+}
+
+// Port sets the port the container services exposes.
+//
+// Common configuration include:
+//   - mysql: Port("3306")
+//   - pg:    Port("5432")
+func Port(p string) DockerConfigOption {
+	return func(c *DockerConfig) error {
+		c.Port = p
+		return nil
+	}
+}
+
+// Env sets the environment variables to pass to the container. For example:
+//
+// 	Config(Image("mysql"), Env("MYSQL_ROOT_PASSWORD=password"))
+// 	Config(Image("postgres"), Env("MYSQL_ROOT_PASSWORD=password"))
+//
+func Env(env ...string) DockerConfigOption {
+	return func(c *DockerConfig) error {
+		c.Env = env
+		return nil
+	}
+}
+
+// Out sets an io.Writer to use when running docker commands.
+//
+// Example:
+//   buf := new(bytes.Buffer)
+//   NewConfig(Out(buf))
+func Out(w io.Writer) DockerConfigOption {
+	return func(c *DockerConfig) error {
+		c.Out = w
+		return nil
+	}
+}
+
+// Run pulls and starts a new docker container from the DockerConfig.
+func (c *DockerConfig) Run(ctx context.Context) (*Container, error) {
+	// Make sure the configuration is not missing critical values.
+	if err := c.validate(); err != nil {
+		return nil, err
+	}
+	// Get a free host TCP port the container can bind its exposed service port on.
+	p, err := freePort()
+	if err != nil {
+		return nil, err
+	}
+	// Make sure the image is up-to-date.
+	cmd := exec.CommandContext(ctx, "docker", "pull", c.Image) //nolint:gosec
+	cmd.Stdout = c.Out
+	if err := cmd.Run(); err != nil {
+		return nil, err
+	}
+	// Run the container.
+	args := []string{"docker", "run", "--rm", "--detach"}
+	for _, e := range c.Env {
+		args = append(args, "-e", e)
+	}
+	args = append(args, "-p", fmt.Sprintf("%s:%s", p, c.Port), c.Image)
+	cmd = exec.CommandContext(ctx, args[0], args[1:]...) //nolint:gosec
+	out := &bytes.Buffer{}
+	cmd.Stdout = io.MultiWriter(c.Out, out)
+	if err := cmd.Run(); err != nil {
+		return nil, err
+	}
+	return &Container{
+		cfg:        *c,
+		ID:         strings.TrimSpace(out.String()),
+		Passphrase: pass,
+		Port:       p,
+	}, nil
+}
+
+// Down stops and removes this container.
+func (c *Container) Down(ctx context.Context) error {
+	return exec.CommandContext(ctx, "docker", "stop", c.ID).Run() //nolint:gosec
+}
+
+// Wait waits for this container to be ready.
+func (c *Container) Wait(ctx context.Context, timeout time.Duration) error {
+	mysqld.SetLogger(log.New(ioutil.Discard, "", 1))
+	defer mysqld.SetLogger(log.New(os.Stderr, "[mysql] ", log.Ldate|log.Ltime|log.Lshortfile))
+	if timeout > time.Minute {
+		timeout = time.Minute
+	}
+	var (
+		done     = time.After(timeout)
+		drv      = c.Driver()
+		dsn, err = c.DSN()
+	)
+	if err != nil {
+		return err
+	}
+	for {
+		select {
+		case <-time.After(100 * time.Millisecond):
+			db, err := sql.Open(drv, dsn)
+			if err != nil {
+				continue
+			}
+			if err := db.PingContext(ctx); err != nil {
+				continue
+			}
+			return nil
+		case <-ctx.Done():
+			return ctx.Err()
+		case <-done:
+			return errors.New("timeout")
+		}
+	}
+}
+
+// Driver returns the database/sql driver name.
+func (c *Container) Driver() string {
+	return strings.SplitN(c.cfg.Image, ":", 2)[0]
+}
+
+// DSN returns a DSN to connect to the Container.
+func (c *Container) DSN() (string, error) {
+	switch drv := c.Driver(); drv {
+	case "mysql", "maria", "mariadb":
+		return fmt.Sprintf("root:%s@tcp(localhost:%s)/", c.Passphrase, c.Port), nil
+	default:
+		return "", fmt.Errorf("unknown driver: %q", drv)
+	}
+}
+
+// validate that no empty values are given.
+func (c *DockerConfig) validate() error {
+	if c == nil || c.Image == "" || c.Port == "" || c.Out == nil {
+		return fmt.Errorf("invalid configuration %q", c)
+	}
+	return nil
+}
+
+func freePort() (string, error) {
+	a, err := net.ResolveTCPAddr("tcp", ":0")
+	if err != nil {
+		return "", err
+	}
+	l, err := net.ListenTCP("tcp", a)
+	if err != nil {
+		return "", err
+	}
+	l.Close()
+	return strconv.Itoa(l.Addr().(*net.TCPAddr).Port), nil
+}
diff --git a/go.mod b/go.mod
index 5808025c..ebb0c03b 100644
--- a/go.mod
+++ b/go.mod
@@ -5,7 +5,6 @@ go 1.17
 require (
 	github.com/DATA-DOG/go-sqlmock v1.5.0
 	github.com/go-sql-driver/mysql v1.6.0
-	github.com/google/go-cmp v0.5.6 // indirect
 	github.com/hashicorp/hcl/v2 v2.10.0
 	github.com/lib/pq v1.10.4
 	github.com/manifoldco/promptui v0.9.0
@@ -13,8 +12,6 @@ require (
 	github.com/stretchr/testify v1.7.1-0.20210427113832-6241f9ab9942
 	github.com/zclconf/go-cty v1.8.0
 	golang.org/x/mod v0.5.1
-	golang.org/x/sys v0.0.0-20211205182925-97ca703d548d // indirect
-	golang.org/x/text v0.3.7 // indirect
 )
 
 require (
@@ -28,11 +25,15 @@ require (
 	github.com/apparentlymart/go-textseg/v13 v13.0.0 // indirect
 	github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect
 	github.com/davecgh/go-spew v1.1.1 // indirect
+	github.com/google/go-cmp v0.5.6 // indirect
 	github.com/inconshreveable/mousetrap v1.0.0 // indirect
 	github.com/kr/pretty v0.2.1 // indirect
+	github.com/kr/text v0.2.0 // indirect
 	github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 // indirect
 	github.com/pmezard/go-difflib v1.0.0 // indirect
 	github.com/spf13/pflag v1.0.5 // indirect
+	golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e // indirect
+	golang.org/x/text v0.3.7 // indirect
 	gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
 	gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
 )
diff --git a/go.sum b/go.sum
index 773ec56f..c7b6f9f0 100644
--- a/go.sum
+++ b/go.sum
@@ -101,6 +101,7 @@ github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3Ee
 github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
 github.com/cpuguy83/go-md2man/v2 v2.0.1 h1:r/myEWzV9lfsM1tFLgDyu0atFtJ1fXn261LKYj/3DxU=
 github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
+github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -266,8 +267,9 @@ github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfn
 github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
 github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
 github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
-github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
 github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348 h1:MtvEpTB6LX3vkb4ax0b5D2DHbNAUsen0Gx5wZoq3lV4=
 github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348/go.mod h1:B69LEHPfb2qLo0BaaOLcbitczOKLWTsrBG9LczfCD4k=
 github.com/lib/pq v1.10.4 h1:SO9z7FRPzA03QhHKJrH5BXA6HU1rS4V2nIVrrNC1iYk=
@@ -575,8 +577,9 @@ golang.org/x/sys v0.0.0-20210908233432-aa78b53d3365/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20211205182925-97ca703d548d h1:FjkYO/PPp4Wi0EAUOVLxePm7qVW4r4ctbWpURyuOD0E=
 golang.org/x/sys v0.0.0-20211205182925-97ca703d548d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e h1:fLOSk5Q00efkSvAm+4xcoXD+RRmLmmulPn5I3Y9F2EM=
+golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=


Test patch:
diff --git a/cmd/action/docker_test.go b/cmd/action/docker_test.go
new file mode 100644
index 00000000..faa76d89
--- /dev/null
+++ b/cmd/action/docker_test.go
@@ -0,0 +1,32 @@
+// Copyright 2021-present The Atlas Authors. All rights reserved.
+// This source code is licensed under the Apache 2.0 license found
+// in the LICENSE file in the root directory of this source tree.
+
+package action_test
+
+import (
+	"context"
+	"io/ioutil"
+	"testing"
+
+	"ariga.io/atlas/cmd/action"
+	"github.com/stretchr/testify/require"
+)
+
+func TestDockerConfig(t *testing.T) {
+	ctx := context.Background()
+
+	// invalid config
+	_, err := (&action.DockerConfig{}).Run(ctx)
+	require.Error(t, err)
+
+	// MySQL
+	cfg, err := action.MySQL("latest", action.Out(ioutil.Discard))
+	require.NoError(t, err)
+	require.Equal(t, &action.DockerConfig{
+		Image: "mysql:latest",
+		Env:   []string{"MYSQL_ROOT_PASSWORD=pass"},
+		Port:  "3306",
+		Out:   ioutil.Discard,
+	}, cfg)
+}