Home Download Docs Code Community
     1	/*
     2	Copyright 2014 The Perkeep Authors
     3	
     4	Licensed under the Apache License, Version 2.0 (the "License");
     5	you may not use this file except in compliance with the License.
     6	You may obtain a copy of the License at
     7	
     8	     http://www.apache.org/licenses/LICENSE-2.0
     9	
    10	Unless required by applicable law or agreed to in writing, software
    11	distributed under the License is distributed on an "AS IS" BASIS,
    12	WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    13	See the License for the specific language governing permissions and
    14	limitations under the License.
    15	*/
    16	
    17	/*
    18	Package dockertest contains helper functions for setting up and tearing down docker containers to aid in testing.
    19	*/
    20	package dockertest // import "perkeep.org/pkg/test/dockertest"
    21	
    22	import (
    23		"bufio"
    24		"bytes"
    25		"compress/gzip"
    26		"database/sql"
    27		"encoding/json"
    28		"errors"
    29		"fmt"
    30		"io"
    31		"log"
    32		"net/http"
    33		"os"
    34		"os/exec"
    35		"strings"
    36		"testing"
    37		"time"
    38	
    39		"perkeep.org/internal/netutil"
    40	)
    41	
    42	// Debug when set, prevents any container from being removed.
    43	var Debug bool
    44	
    45	// / runLongTest checks all the conditions for running a docker container
    46	// based on image.
    47	func runLongTest(t *testing.T, image string) {
    48		if testing.Short() {
    49			t.Skip("skipping in short mode")
    50		}
    51		if !haveDocker() {
    52			t.Skip("skipping test; 'docker' command not found")
    53		}
    54		if ok, err := haveImage(image); !ok || err != nil {
    55			if err != nil {
    56				t.Skipf("Error running docker to check for %s: %v", image, err)
    57			}
    58			log.Printf("Pulling docker image %s ...", image)
    59			if strings.HasPrefix(image, "camlistore/") {
    60				if err := loadCamliHubImage(image); err != nil {
    61					t.Skipf("Error pulling %s: %v", image, err)
    62				}
    63				return
    64			}
    65			if err := Pull(image); err != nil {
    66				t.Skipf("Error pulling %s: %v", image, err)
    67			}
    68		}
    69	}
    70	
    71	// loadCamliHubImage fetches a docker image saved as a .tar.gz in the
    72	// camlistore-docker bucket, and loads it in docker.
    73	func loadCamliHubImage(image string) error {
    74		if !strings.HasPrefix(image, "camlistore/") {
    75			return fmt.Errorf("not an image hosted on camlistore-docker")
    76		}
    77		imgURL := camliHub + strings.TrimPrefix(image, "camlistore/") + ".tar.gz"
    78		resp, err := http.Get(imgURL)
    79		if err != nil {
    80			return fmt.Errorf("error fetching image %s: %v", image, err)
    81		}
    82		defer resp.Body.Close()
    83	
    84		dockerLoad := exec.Command("docker", "load")
    85		dockerLoad.Stderr = os.Stderr
    86		tar, err := dockerLoad.StdinPipe()
    87		if err != nil {
    88			return err
    89		}
    90		errc1 := make(chan error)
    91		errc2 := make(chan error)
    92		go func() {
    93			defer tar.Close()
    94			zr, err := gzip.NewReader(resp.Body)
    95			if err != nil {
    96				errc1 <- fmt.Errorf("gzip reader error for image %s: %v", image, err)
    97				return
    98			}
    99			defer zr.Close()
   100			if _, err = io.Copy(tar, zr); err != nil {
   101				errc1 <- fmt.Errorf("error gunzipping image %s: %v", image, err)
   102				return
   103			}
   104			errc1 <- nil
   105		}()
   106		go func() {
   107			if err := dockerLoad.Run(); err != nil {
   108				errc2 <- fmt.Errorf("error running docker load %v: %v", image, err)
   109				return
   110			}
   111			errc2 <- nil
   112		}()
   113		select {
   114		case err := <-errc1:
   115			if err != nil {
   116				return err
   117			}
   118			return <-errc2
   119		case err := <-errc2:
   120			if err != nil {
   121				return err
   122			}
   123			return <-errc1
   124		}
   125	}
   126	
   127	// haveDocker returns whether the "docker" command was found.
   128	func haveDocker() bool {
   129		_, err := exec.LookPath("docker")
   130		return err == nil
   131	}
   132	
   133	// haveImage reports whether we have the the given docker image. The name can
   134	// either be of the <repository>, or <image id>, or <repository:tag> form.
   135	func haveImage(name string) (ok bool, err error) {
   136		out, err := exec.Command("docker", "images", "--no-trunc").Output()
   137		if err != nil {
   138			return
   139		}
   140		fields := strings.Split(name, ":")
   141		if len(fields) < 2 {
   142			return bytes.Contains(out, []byte(name)), nil
   143		}
   144		tag := fields[1]
   145		image := fields[0]
   146		sc := bufio.NewScanner(bytes.NewBuffer(out))
   147		for sc.Scan() {
   148			l := sc.Text()
   149			if !strings.HasPrefix(l, image) {
   150				continue
   151			}
   152			if strings.HasPrefix(strings.TrimSpace(strings.TrimPrefix(l, image)), tag) {
   153				return true, nil
   154			}
   155		}
   156		return false, sc.Err()
   157	}
   158	
   159	func run(args ...string) (containerID string, err error) {
   160		cmd := exec.Command("docker", append([]string{"run"}, args...)...)
   161		var stdout, stderr bytes.Buffer
   162		cmd.Stdout, cmd.Stderr = &stdout, &stderr
   163		if err = cmd.Run(); err != nil {
   164			err = fmt.Errorf("%v%v", stderr.String(), err)
   165			return
   166		}
   167		containerID = strings.TrimSpace(stdout.String())
   168		if containerID == "" {
   169			return "", errors.New("unexpected empty output from `docker run`")
   170		}
   171		return
   172	}
   173	
   174	func KillContainer(container string) error {
   175		return exec.Command("docker", "kill", container).Run()
   176	}
   177	
   178	// Pull retrieves the docker image with 'docker pull'.
   179	func Pull(image string) error {
   180		var stdout, stderr bytes.Buffer
   181		cmd := exec.Command("docker", "pull", image)
   182		cmd.Stdout = &stdout
   183		cmd.Stderr = &stderr
   184		err := cmd.Run()
   185		out := stdout.String()
   186		// TODO(mpl): if it turns out docker respects conventions and the
   187		// "Authentication is required" message does come from stderr, then quit
   188		// checking stdout.
   189		if err != nil || stderr.Len() != 0 || strings.Contains(out, "Authentication is required") {
   190			return fmt.Errorf("docker pull failed: stdout: %s, stderr: %s, err: %v", out, stderr.String(), err)
   191		}
   192		return nil
   193	}
   194	
   195	// IP returns the IP address of the container.
   196	func IP(containerID string) (string, error) {
   197		out, err := exec.Command("docker", "inspect", containerID).Output()
   198		if err != nil {
   199			return "", err
   200		}
   201		type networkSettings struct {
   202			IPAddress string
   203		}
   204		type container struct {
   205			NetworkSettings networkSettings
   206		}
   207		var c []container
   208		if err := json.NewDecoder(bytes.NewReader(out)).Decode(&c); err != nil {
   209			return "", err
   210		}
   211		if len(c) == 0 {
   212			return "", errors.New("no output from docker inspect")
   213		}
   214		if ip := c[0].NetworkSettings.IPAddress; ip != "" {
   215			return ip, nil
   216		}
   217		return "", errors.New("could not find an IP. Not running?")
   218	}
   219	
   220	type ContainerID string
   221	
   222	func (c ContainerID) IP() (string, error) {
   223		return IP(string(c))
   224	}
   225	
   226	func (c ContainerID) Kill() error {
   227		if string(c) == "" {
   228			return nil
   229		}
   230		return KillContainer(string(c))
   231	}
   232	
   233	// Remove runs "docker rm" on the container
   234	func (c ContainerID) Remove() error {
   235		if Debug {
   236			return nil
   237		}
   238		if string(c) == "" {
   239			return nil
   240		}
   241		return exec.Command("docker", "rm", "-v", string(c)).Run()
   242	}
   243	
   244	// KillRemove calls Kill on the container, and then Remove if there was
   245	// no error. It logs any error to t.
   246	func (c ContainerID) KillRemove(t *testing.T) {
   247		if err := c.Kill(); err != nil {
   248			t.Log(err)
   249			return
   250		}
   251		if err := c.Remove(); err != nil {
   252			t.Log(err)
   253		}
   254	}
   255	
   256	// lookup retrieves the ip address of the container, and tries to reach
   257	// before timeout the tcp address at this ip and given port.
   258	func (c ContainerID) lookup(port int, timeout time.Duration) (ip string, err error) {
   259		ip, err = c.IP()
   260		if err != nil {
   261			err = fmt.Errorf("error getting IP: %v", err)
   262			return
   263		}
   264		addr := fmt.Sprintf("%s:%d", ip, port)
   265		err = netutil.AwaitReachable(addr, timeout)
   266		return
   267	}
   268	
   269	// setupContainer sets up a container, using the start function to run the given image.
   270	// It also looks up the IP address of the container, and tests this address with the given
   271	// port and timeout. It returns the container ID and its IP address, or makes the test
   272	// fail on error.
   273	func setupContainer(t *testing.T, image string, port int, timeout time.Duration,
   274		start func() (string, error)) (c ContainerID, ip string) {
   275		runLongTest(t, image)
   276	
   277		containerID, err := start()
   278		if err != nil {
   279			t.Fatalf("docker run: %v", err)
   280		}
   281		c = ContainerID(containerID)
   282		ip, err = c.lookup(port, timeout)
   283		if err != nil {
   284			c.KillRemove(t)
   285			t.Skipf("Skipping test for container %v: %v", c, err)
   286		}
   287		return
   288	}
   289	
   290	const (
   291		mongoImage       = "mpl7/mongo"
   292		mysqlImage       = "mysql:8"
   293		MySQLUsername    = "root"
   294		MySQLPassword    = "root"
   295		postgresImage    = "nornagon/postgres"
   296		PostgresUsername = "docker" // set up by the dockerfile of postgresImage
   297		PostgresPassword = "docker" // set up by the dockerfile of postgresImage
   298		camliHub         = "https://storage.googleapis.com/camlistore-docker/"
   299	)
   300	
   301	// SetupMongoContainer sets up a real MongoDB instance for testing purposes,
   302	// using a Docker container. It returns the container ID and its IP address,
   303	// or makes the test fail on error.
   304	// Currently using https://index.docker.io/u/robinvdvleuten/mongo/
   305	func SetupMongoContainer(t *testing.T) (c ContainerID, ip string) {
   306		return setupContainer(t, mongoImage, 27017, 10*time.Second, func() (string, error) {
   307			return run("-d", mongoImage, "--nojournal")
   308		})
   309	}
   310	
   311	// SetupMySQLContainer sets up a real MySQL instance for testing purposes,
   312	// using a Docker container. It returns the container ID and its IP address,
   313	// or makes the test fail on error.
   314	// Currently using https://hub.docker.com/_/mysql/
   315	func SetupMySQLContainer(t *testing.T, dbname string) (c ContainerID, ip string) {
   316		return setupContainer(t, mysqlImage, 3306, 20*time.Second, func() (string, error) {
   317			return run("-d", "-e", "MYSQL_ROOT_PASSWORD="+MySQLPassword, "-e", "MYSQL_DATABASE="+dbname, mysqlImage)
   318		})
   319	}
   320	
   321	// SetupPostgreSQLContainer sets up a real PostgreSQL instance for testing purposes,
   322	// using a Docker container. It returns the container ID and its IP address,
   323	// or makes the test fail on error.
   324	// Currently using https://index.docker.io/u/nornagon/postgres
   325	func SetupPostgreSQLContainer(t *testing.T, dbname string) (c ContainerID, ip string) {
   326		c, ip = setupContainer(t, postgresImage, 5432, 15*time.Second, func() (string, error) {
   327			return run("-d", postgresImage)
   328		})
   329		cleanupAndDie := func(err error) {
   330			c.KillRemove(t)
   331			t.Fatal(err)
   332		}
   333		rootdb, err := sql.Open("postgres",
   334			fmt.Sprintf("user=%s password=%s host=%s dbname=postgres sslmode=disable", PostgresUsername, PostgresPassword, ip))
   335		if err != nil {
   336			cleanupAndDie(fmt.Errorf("Could not open postgres rootdb: %v", err))
   337		}
   338		if _, err := sqlExecRetry(rootdb,
   339			"CREATE DATABASE "+dbname+" LC_COLLATE = 'C' TEMPLATE = template0",
   340			50); err != nil {
   341			cleanupAndDie(fmt.Errorf("Could not create database %v: %v", dbname, err))
   342		}
   343		return
   344	}
   345	
   346	// sqlExecRetry keeps calling http://golang.org/pkg/database/sql/#DB.Exec on db
   347	// with stmt until it succeeds or until it has been tried maxTry times.
   348	// It sleeps in between tries, twice longer after each new try, starting with
   349	// 100 milliseconds.
   350	func sqlExecRetry(db *sql.DB, stmt string, maxTry int) (sql.Result, error) {
   351		if maxTry <= 0 {
   352			return nil, errors.New("did not try at all")
   353		}
   354		interval := 100 * time.Millisecond
   355		try := 0
   356		var err error
   357		var result sql.Result
   358		for {
   359			result, err = db.Exec(stmt)
   360			if err == nil {
   361				return result, nil
   362			}
   363			try++
   364			if try == maxTry {
   365				break
   366			}
   367			time.Sleep(interval)
   368			interval *= 2
   369		}
   370		return result, fmt.Errorf("failed %v times: %v", try, err)
   371	}
Website layout inspired by memcached.
Content by the authors.