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 }