PKGBUILD/pkg/testutils/database/database.go

286 lines
8.2 KiB
Go

package database
import (
"context"
"database/sql"
"errors"
"fmt"
"net/url"
"strconv"
"strings"
"testing"
"time"
"git.cryptic.systems/volker.raschek/flucky/pkg/testutils/dockerutils"
"github.com/docker/docker/api/types"
uuid "github.com/satori/go.uuid"
"github.com/stretchr/testify/require"
)
var (
ErrInvalidAttr = errors.New("Invalid DatabaseOption attribute")
)
type PostgresOptions struct {
ContainerEnv map[string]string
ContainerImage string
ContainerLabels map[string]string
ContainerName string
ContainerNetworks map[string][]string
ContainerNetworkLabels map[string]map[string]string
ContainerPort string
Driver string
DSN string
HostPort string
}
// Validate the DatabaseOption struct, if all required atrributes are valid
func (dbOptions *PostgresOptions) Validate() error {
// Required strings
for _, values := range [][]string{
0: {"ContainerImage", dbOptions.ContainerImage},
1: {"ContainerPort", dbOptions.ContainerPort},
2: {"Driver", dbOptions.Driver},
3: {"HostPort", dbOptions.HostPort},
} {
if len(values[1]) <= 0 {
return fmt.Errorf("%w: Attribute %v is empty", ErrInvalidAttr, values[0])
}
}
// Require initialized maps
for key, value := range map[string]interface{}{
"ContainerEnv": dbOptions.ContainerEnv,
"ContainerLabels": dbOptions.ContainerLabels,
"ContainerNetworks": dbOptions.ContainerNetworks,
"ContainerNetworkLabels": dbOptions.ContainerNetworkLabels,
} {
if value == nil {
return fmt.Errorf("%w: Attribut %v is not initialized", ErrInvalidAttr, key)
}
}
// Required postgres environment variables
for _, key := range []string{
"POSTGRES_PASSWORD",
"POSTGRES_USER",
"POSTGRES_DB",
} {
if _, present := dbOptions.ContainerEnv[key]; !present {
return fmt.Errorf("%w: Required env %v not defined", ErrInvalidAttr, key)
}
}
// Supported drivers
found := false
for _, supportedDriver := range []string{
"postgres",
} {
if supportedDriver == dbOptions.Driver {
found = true
break
}
}
if !found {
return fmt.Errorf("%w: Driver %v not supported", ErrInvalidAttr, dbOptions.Driver)
}
// Protect well-known ports
hostPort, err := strconv.ParseInt(dbOptions.HostPort, 10, 64)
if err != nil {
return fmt.Errorf("Failed to parse hostport %v: %w", dbOptions.HostPort, err)
}
if hostPort > 0 && hostPort < 1024 {
return fmt.Errorf("%w: ContainerPort %v: Protect well-known ports between 1-1023", ErrInvalidAttr, dbOptions.HostPort)
}
return nil
}
// NewPostgresDatabase starts a new postgres database based on default values
func NewPostgresDatabase(t *testing.T) (*PostgresOptions, func()) {
return StartPostgres(t, NewPostgresOptions())
}
// NewPostgresOptions returns a new PostgresOptions structs with default values
func NewPostgresOptions() *PostgresOptions {
return &PostgresOptions{
ContainerEnv: map[string]string{
"POSTGRES_PASSWORD": "postgres",
"POSTGRES_USER": "postgres",
"POSTGRES_DB": "postgres",
},
ContainerImage: "docker.io/library/postgres:13-alpine",
ContainerLabels: make(map[string]string, 0),
ContainerName: uuid.NewV4().String(),
ContainerNetworks: make(map[string][]string, 0),
ContainerNetworkLabels: make(map[string]map[string]string, 0),
ContainerPort: "5432",
Driver: "postgres",
DSN: "postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable",
HostPort: "0",
}
}
// StartPostgres starts a postgres container image based on the PostgresOption
// struct
func StartPostgres(t *testing.T, pgOptions *PostgresOptions) (*PostgresOptions, func()) {
var (
ctx = context.Background()
require = require.New(t)
cleanupLabels = map[string]string{
uuid.NewV4().String(): uuid.NewV4().String(),
}
)
dockerClient, err := dockerutils.New()
require.NoError(err)
// Validate the PostgresOptions struct
require.NoError(pgOptions.Validate())
// Pre-Define DSN based on the pgOption attributes
pgOptions.DSN = fmt.Sprintf("postgres://%v:%v@localhost:%v/%v?sslmode=disable",
pgOptions.ContainerEnv["POSTGRES_USER"],
pgOptions.ContainerEnv["POSTGRES_PASSWORD"],
pgOptions.HostPort,
pgOptions.ContainerEnv["POSTGRES_DB"],
)
// Create missing networks
// The dockerutils package check only if the network exist and append the
// container with his aliasses as network endpoint. Additionally every network
// get his own network labels. If no network labels are defined, new labels
// will be generated. This is required to remove the networks by their labels.
networks, err := dockerClient.NetworkList(ctx, types.NetworkListOptions{})
require.NoError(err)
for networkName := range pgOptions.ContainerNetworks {
found := false
for _, network := range networks {
if network.Name == networkName {
found = true
break
}
}
if !found {
// Create network labels if there are no one defined
if _, present := pgOptions.ContainerNetworkLabels[networkName]; !present {
pgOptions.ContainerNetworkLabels[networkName] = make(map[string]string, 0)
}
// Append cleanup labels to the network
for key, value := range cleanupLabels {
pgOptions.ContainerNetworkLabels[networkName][key] = value
}
_, err := dockerClient.NetworkCreate(ctx, networkName, types.NetworkCreate{
Labels: pgOptions.ContainerNetworkLabels[networkName],
})
require.NoError(err)
}
}
// Append cleanup labels to the container
for key, value := range cleanupLabels {
pgOptions.ContainerLabels[key] = value
}
// Build database container
databaseContainerBuilder := dockerClient.NewBuilder(pgOptions.ContainerImage).
Env(pgOptions.ContainerEnv).
Labels(pgOptions.ContainerLabels).
Port(fmt.Sprintf("%s:%s", pgOptions.HostPort, pgOptions.ContainerPort)).
Pull().
WithName(pgOptions.ContainerName)
networkNames := make([]string, 0)
for networkName, aliasses := range pgOptions.ContainerNetworks {
if aliasses == nil {
aliasses = make([]string, 0)
}
if len(aliasses) <= 0 {
aliasses = append(aliasses, pgOptions.ContainerName)
}
databaseContainerBuilder.Network(networkName, aliasses...)
pgOptions.ContainerNetworks[networkName] = aliasses
networkNames = append(networkNames, networkName)
}
dbContainerID, err := databaseContainerBuilder.Start(ctx)
require.NoError(err)
// cleanupFunction to remove the database container with all defined networks.
// Skip network if the network has an additional endpoint
cleanupFunc := func() {
err := dockerClient.ContainerRemove(ctx, dbContainerID, types.ContainerRemoveOptions{Force: true})
require.NoError(err)
for networkName := range pgOptions.ContainerNetworks {
networks, err := dockerClient.NetworkListByLabels(ctx, pgOptions.ContainerNetworkLabels[networkName])
require.NoError(err)
for _, network := range networks {
if len(network.Containers) <= 0 {
err := dockerClient.NetworkRemove(ctx, network.ID)
require.NoError(err)
}
}
}
}
// Search for allocated port if public port is defined as random by 0
if pgOptions.HostPort == "0" {
containers, err := dockerClient.ContainerListByLabels(ctx, true, pgOptions.ContainerLabels)
require.NoError(err)
var pubPort string
for _, port := range containers[0].Ports {
if port.PrivatePort == 5432 {
pubPort = strconv.Itoa(int(port.PublicPort))
break
}
}
require.Greater(len(pubPort), 0, "Failed to detect allocated random port of the postgres container")
connectionURL, err := url.Parse(pgOptions.DSN)
require.NoError(err)
parts := strings.Split(connectionURL.Host, ":")
connectionURL.Host = fmt.Sprintf("%v:%v", parts[0], pubPort)
pgOptions.DSN = connectionURL.String()
pgOptions.HostPort = pubPort
}
var db *sql.DB
for i := 0; i < 10; i++ {
db, err = sql.Open(pgOptions.Driver, pgOptions.DSN)
if err != nil {
t.Log("Database not ready - wait 10 seconds")
time.Sleep(10 * time.Second)
continue
}
err = db.Ping()
if err != nil {
t.Log("Database not ready - wait 10 seconds")
db.Close()
time.Sleep(10 * time.Second)
continue
}
break
}
require.NoError(err)
err = db.Close()
require.NoError(err)
return pgOptions, cleanupFunc
}