Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 36 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

# embedded-postgres

Run a real Postgres database locally on Linux, OSX or Windows as part of another Go application or test.
Run a real Postgres database locally on Linux, OSX, Windows or FreeBSD (amd64) as part of another Go application or test.

When testing this provides a higher level of confidence than using any in memory alternative. It also requires no other
external dependencies outside of the Go build ecosystem.
Expand Down Expand Up @@ -69,6 +69,10 @@ is done).
If your test need to run multiple different versions of Postgres for different tests, make sure
*BinaryPath* is a subdirectory of *RuntimePath*.

On FreeBSD amd64 the default artifact naming currently uses `freebsd13-amd64`.
If you publish a different FreeBSD line such as `freebsd14-amd64`, select it explicitly with `Platform("freebsd14")`.
If you already have a prebuilt binary tree on disk, prefer `BinariesPath(...)` to skip remote downloads entirely.

A single Postgres instance can be created, started and stopped as follows

```go
Expand All @@ -89,7 +93,9 @@ Username("beer").
Password("wine").
Database("gin").
Version(V12).
Platform("freebsd14").
RuntimePath("/tmp").
BinariesPath("/opt/embedded-postgres").
BinaryRepositoryURL("https://repo.local/central.proxy").
Port(9876).
StartTimeout(45 * time.Second).
Expand All @@ -102,6 +108,35 @@ err := postgres.Start()
err := postgres.Stop()
```

If you want a reusable starting point for local macOS development and FreeBSD 13
deployments, use the `preset` helper package:

```go
import (
"time"

embeddedpostgres "github.com/fergusstrange/embedded-postgres"
"github.com/fergusstrange/embedded-postgres/preset"
)

config := preset.LocalDevelopment("myapp", preset.Options{
Version: embeddedpostgres.V18,
Port: 5433,
StartTimeout: 30 * time.Second,
// Optional on FreeBSD when you pre-install the binary tree yourself.
// BinariesPath: "/opt/embedded-postgres",
})

postgres := embeddedpostgres.NewDatabase(config)
err := postgres.Start()
defer postgres.Stop()
```

On FreeBSD this preset defaults to the `freebsd13` artifact line and uses
`/var/tmp/<app>/embedded-postgres/runtime` plus
`/var/db/<app>/embedded-postgres/data`. Override `Platform("freebsd14")` or the
paths if your host layout differs.

It should be noted that if `postgres.Stop()` is not called then the child Postgres process will not be released and the
caller will block.

Expand All @@ -119,4 +154,3 @@ in [examples](https://github.com/fergusstrange/embedded-postgres/tree/master/exa
## Contributing

View the [contributing guide](CONTRIBUTING.md).

Binary file added bundles/libcrypto.so.111
Binary file not shown.
Binary file added bundles/libssl.so.111
Binary file not shown.
Binary file added bundles/postgres-freebsd13-x86_64.txz
Binary file not shown.
Binary file added bundles/postgres-freebsd14-x86_64.txz
Binary file not shown.
51 changes: 50 additions & 1 deletion config.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,19 @@ package embeddedpostgres
import (
"fmt"
"io"
"net/url"
"os"
"time"
)

// Config maintains the runtime configuration for the Postgres process to be created.
type Config struct {
version PostgresVersion
platform string
port uint32
useUnixSocket bool
unixSocketDirectory string
logDirectory string
database string
username string
password string
Expand Down Expand Up @@ -38,6 +43,8 @@ func DefaultConfig() Config {
return Config{
version: V18,
port: 5432,
useUnixSocket: false,
unixSocketDirectory: "/tmp/",
database: "postgres",
username: "postgres",
password: "postgres",
Expand All @@ -53,12 +60,38 @@ func (c Config) Version(version PostgresVersion) Config {
return c
}

// Platform sets the artifact platform line used to resolve postgres binaries.
// For example: "freebsd13", "freebsd14" or "alpine".
func (c Config) Platform(platform string) Config {
c.platform = platform
return c
}

// Port sets the runtime port that Postgres can be accessed on.
func (c Config) Port(port uint32) Config {
c.port = port
return c
}

// WithoutTcp makes Postgres listen on a UNIX socket instead of opening a TCP port.
func (c Config) WithoutTcp() Config {
c.useUnixSocket = true
return c
}

// WithUnixSocketDirectory sets the directory where Postgres creates its UNIX socket.
func (c Config) WithUnixSocketDirectory(dir string) Config {
c.unixSocketDirectory = dir
return c
}

// LogDirectory sets the directory where the temporary embedded Postgres capture
// file is created before its content is forwarded to the configured logger.
func (c Config) LogDirectory(dir string) Config {
c.logDirectory = dir
return c
}

// Database sets the database name that will be created.
func (c Config) Database(database string) Config {
c.database = database
Expand Down Expand Up @@ -145,7 +178,23 @@ func (c Config) BinaryRepositoryURL(binaryRepositoryURL string) Config {
}

func (c Config) GetConnectionURL() string {
return fmt.Sprintf("postgresql://%s:%s@%s:%d/%s", c.username, c.password, "localhost", c.port, c.database)
u := &url.URL{
Scheme: "postgresql",
User: url.UserPassword(c.username, c.password),
Path: "/" + c.database,
}

if c.useUnixSocket {
u.Host = fmt.Sprintf(":%d", c.port)

q := url.Values{}
q.Set("host", c.unixSocketDirectory)
u.RawQuery = q.Encode()
} else {
u.Host = fmt.Sprintf("localhost:%d", c.port)
}

return u.String()
}

// PostgresVersion represents the semantic version used to fetch and run the Postgres process.
Expand Down
35 changes: 35 additions & 0 deletions config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package embeddedpostgres

import "testing"

func TestGetConnectionURL(t *testing.T) {
config := DefaultConfig().Database("mydb").Username("myuser").Password("mypass")
expect := "postgresql://myuser:mypass@localhost:5432/mydb"

if got := config.GetConnectionURL(); got != expect {
t.Errorf("expected %q got %q", expect, got)
}
}

func TestGetConnectionURLWithUnixSocket(t *testing.T) {
config := DefaultConfig().Database("mydb").Username("myuser").Password("mypass").WithoutTcp()
expect := "postgresql://myuser:mypass@:5432/mydb?host=%2Ftmp%2F"

if got := config.GetConnectionURL(); got != expect {
t.Errorf("expected %q got %q", expect, got)
}
}

func TestGetConnectionURLWithUnixSocketInCustomDir(t *testing.T) {
config := DefaultConfig().
Database("mydb").
Username("myuser").
Password("mypass").
WithoutTcp().
WithUnixSocketDirectory("/path/to/socks")
expect := "postgresql://myuser:mypass@:5432/mydb?host=%2Fpath%2Fto%2Fsocks"

if got := config.GetConnectionURL(); got != expect {
t.Errorf("expected %q got %q", expect, got)
}
}
19 changes: 18 additions & 1 deletion decompression.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import (
"github.com/xi2/xz"
)

type progressLogger func(format string, args ...any)

func defaultTarReader(xzReader *xz.Reader) (func() (*tar.Header, error), func() io.Reader) {
tarReader := tar.NewReader(xzReader)

Expand All @@ -20,7 +22,7 @@ func defaultTarReader(xzReader *xz.Reader) (func() (*tar.Header, error), func()
}
}

func decompressTarXz(tarReader func(*xz.Reader) (func() (*tar.Header, error), func() io.Reader), path, extractPath string) error {
func decompressTarXz(tarReader func(*xz.Reader) (func() (*tar.Header, error), func() io.Reader), path, extractPath string, logf progressLogger) error {
extractDirectory := filepath.Dir(extractPath)

if err := os.MkdirAll(extractDirectory, os.ModePerm); err != nil {
Expand Down Expand Up @@ -54,6 +56,7 @@ func decompressTarXz(tarReader func(*xz.Reader) (func() (*tar.Header, error), fu
}

readNext, reader := tarReader(xzReader)
entryCount := 0

for {
header, err := readNext()
Expand All @@ -79,6 +82,7 @@ func decompressTarXz(tarReader func(*xz.Reader) (func() (*tar.Header, error), fu

switch header.Typeflag {
case tar.TypeReg:
logProgress(logf, "extracting embedded postgres entry archive=%s entry=%s type=file target=%s size=%d", path, header.Name, finalPath, header.Size)
outFile, err := os.OpenFile(targetPath, os.O_CREATE|os.O_RDWR, os.FileMode(header.Mode))
if err != nil {
return errorExtractingPostgres(err)
Expand All @@ -92,6 +96,7 @@ func decompressTarXz(tarReader func(*xz.Reader) (func() (*tar.Header, error), fu
return errorExtractingPostgres(err)
}
case tar.TypeSymlink:
logProgress(logf, "extracting embedded postgres entry archive=%s entry=%s type=symlink target=%s link=%s", path, header.Name, finalPath, header.Linkname)
if err := os.RemoveAll(targetPath); err != nil {
return errorExtractingPostgres(err)
}
Expand All @@ -101,20 +106,32 @@ func decompressTarXz(tarReader func(*xz.Reader) (func() (*tar.Header, error), fu
}

case tar.TypeDir:
logProgress(logf, "extracting embedded postgres entry archive=%s entry=%s type=dir target=%s", path, header.Name, finalPath)
if err := os.MkdirAll(finalPath, os.FileMode(header.Mode)); err != nil {
return errorExtractingPostgres(err)
}
entryCount++
continue
}

if err := renameOrIgnore(targetPath, finalPath); err != nil {
return errorExtractingPostgres(err)
}
entryCount++
}

logProgress(logf, "finished extracting embedded postgres archive archive=%s destination=%s entries=%d", path, extractPath, entryCount)

return nil
}

func logProgress(logf progressLogger, format string, args ...any) {
if logf == nil {
return
}
logf(format, args...)
}

func errorUnableToExtract(cacheLocation, binariesPath string, err error) error {
return fmt.Errorf("unable to extract postgres archive %s to %s, if running parallel tests, configure RuntimePath to isolate testing directories, %w",
cacheLocation,
Expand Down
31 changes: 24 additions & 7 deletions decompression_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package embeddedpostgres

import (
"archive/tar"
"bytes"
"errors"
"fmt"
"io"
Expand All @@ -28,7 +29,7 @@ func Test_decompressTarXz(t *testing.T) {
archive, cleanUp := createTempXzArchive()
defer cleanUp()

err = decompressTarXz(defaultTarReader, archive, tempDir)
err = decompressTarXz(defaultTarReader, archive, tempDir, nil)

assert.NoError(t, err)

Expand All @@ -42,7 +43,7 @@ func Test_decompressTarXz(t *testing.T) {
}

func Test_decompressTarXz_ErrorWhenFileNotExists(t *testing.T) {
err := decompressTarXz(defaultTarReader, "/does-not-exist", "/also-fake")
err := decompressTarXz(defaultTarReader, "/does-not-exist", "/also-fake", nil)

assert.Error(t, err)
assert.Contains(
Expand All @@ -68,7 +69,7 @@ func Test_decompressTarXz_ErrorWhenErrorDuringRead(t *testing.T) {
return func() (*tar.Header, error) {
return nil, errors.New("oh noes")
}, nil
}, archive, tempDir)
}, archive, tempDir, nil)

assert.EqualError(t, err, "unable to extract postgres archive: oh noes")
}
Expand Down Expand Up @@ -108,7 +109,7 @@ func Test_decompressTarXz_ErrorWhenFailedToReadFileToCopy(t *testing.T) {
}
}

err = decompressTarXz(fileBlockingExtractTarReader, archive, tempDir)
err = decompressTarXz(fileBlockingExtractTarReader, archive, tempDir, nil)

assert.Regexp(t, "^unable to extract postgres archive:.+$", err)
}
Expand Down Expand Up @@ -145,7 +146,7 @@ func Test_decompressTarXz_ErrorWhenFileToCopyToNotExists(t *testing.T) {
}
}

err = decompressTarXz(fileBlockingExtractTarReader, archive, tempDir)
err = decompressTarXz(fileBlockingExtractTarReader, archive, tempDir, nil)

assert.Regexp(t, "^unable to extract postgres archive:.+$", err)
}
Expand Down Expand Up @@ -180,7 +181,7 @@ func Test_decompressTarXz_ErrorWhenArchiveCorrupted(t *testing.T) {
panic(err)
}

err = decompressTarXz(defaultTarReader, archive, tempDir)
err = decompressTarXz(defaultTarReader, archive, tempDir, nil)

assert.EqualError(t, err, "unable to extract postgres archive: xz: data is corrupt")
}
Expand All @@ -197,10 +198,26 @@ func Test_decompressTarXz_ErrorWithInvalidDestination(t *testing.T) {

op := fmt.Sprintf(path.Join(tempDir, "%c"), rune(0))

err = decompressTarXz(defaultTarReader, archive, op)
err = decompressTarXz(defaultTarReader, archive, op, nil)
assert.EqualError(
t,
err,
fmt.Sprintf("unable to extract postgres archive: mkdir %s: invalid argument", op),
)
}

func Test_decompressTarXz_LogsExtractedEntries(t *testing.T) {
tempDir := t.TempDir()
archive, cleanUp := createTempXzArchive()
defer cleanUp()

var logs bytes.Buffer
err := decompressTarXz(defaultTarReader, archive, tempDir, func(format string, args ...any) {
_, _ = fmt.Fprintf(&logs, format+"\n", args...)
})

require.NoError(t, err)
assert.Contains(t, logs.String(), "extracting embedded postgres entry")
assert.Contains(t, logs.String(), "entry=dir1/dir2/some_content")
assert.Contains(t, logs.String(), "finished extracting embedded postgres archive")
}
Loading
Loading