e1ba7fdabb
## Problem `go test ./...` leaked one Docker volume per test package. `pgtest.Main` tore its throwaway container down with `docker rm -f` — no `-v`. The `postgres:17-alpine` image declares a `VOLUME` for `/var/lib/postgresql/data`, and an explicit `rm` without `-v` orphans that anonymous volume even though the container was created with `--rm`. Found in passing: a 13-hour-old orphaned pgtest container (AutoRemove, `fsync=off`, one mount) still running from a killed test binary — the same leak's other half. Removed on the dev machine. ## Change - `backend/internal/pgtest/pgtest.go`: `-v` on all three `docker rm -f` teardown sites (`Main`'s defer, and both error paths in `start`). - `AGENTS.md`: cleanup expectation under Commands — `docker compose down -v` for a stack, `docker rm -f -v` for a hand-run container, then check `docker volume ls` and `docker system df`. Explicitly out of bounds: removing the user's `postgres-data` volume, or a blanket `docker system prune` of their images and build cache. ## Verification `docker volume ls` snapshot diffed across a real `./internal/store` run: no new volumes, `docker system df` reports 0 local volumes. Reviewed-on: #175 Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com> Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
123 lines
3.6 KiB
Go
123 lines
3.6 KiB
Go
// Package pgtest runs the Postgres the test suite needs: one throwaway
|
|
// container per test binary, one fresh database per test. Docker is therefore
|
|
// a hard prerequisite for `go test ./...`.
|
|
//
|
|
// Rolled by hand rather than pulled in as a dependency — it is one `docker
|
|
// run`, one `docker port` and a ping loop, against a module list that is
|
|
// otherwise stdlib plus what the poller genuinely needs.
|
|
package pgtest
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"os/exec"
|
|
"strconv"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
_ "github.com/jackc/pgx/v5/stdlib"
|
|
)
|
|
|
|
const (
|
|
image = "postgres:17-alpine"
|
|
readyLimit = 60 * time.Second
|
|
)
|
|
|
|
var (
|
|
adminURL string
|
|
dbSeq atomic.Int64
|
|
)
|
|
|
|
// Main starts the container, runs the package's tests and tears the container
|
|
// down. Every test package that touches the store calls it from TestMain:
|
|
//
|
|
// func TestMain(m *testing.M) { os.Exit(pgtest.Main(m)) }
|
|
func Main(m *testing.M) int {
|
|
id, url, err := start()
|
|
if err != nil {
|
|
fmt.Println("pgtest:", err)
|
|
return 1
|
|
}
|
|
// -v: the postgres image declares a VOLUME, so an explicit rm without it
|
|
// leaves the anonymous data volume behind on every test run.
|
|
defer exec.Command("docker", "rm", "-f", "-v", id).Run()
|
|
|
|
adminURL = url
|
|
return m.Run()
|
|
}
|
|
|
|
// URL creates a database of its own for t and returns a connection URL for it.
|
|
// Nothing drops it again: the container goes away wholesale when Main returns.
|
|
func URL(t testing.TB) string {
|
|
t.Helper()
|
|
if adminURL == "" {
|
|
t.Fatal("pgtest: no container; this package needs TestMain to call pgtest.Main")
|
|
}
|
|
// Generated, never derived from the test name, so it needs no quoting and
|
|
// cannot collide when tests run in parallel.
|
|
name := "test_" + strconv.FormatInt(dbSeq.Add(1), 10)
|
|
|
|
admin, err := sql.Open("pgx", adminURL)
|
|
if err != nil {
|
|
t.Fatalf("pgtest: open admin connection: %v", err)
|
|
}
|
|
defer admin.Close()
|
|
if _, err := admin.Exec(`CREATE DATABASE ` + name); err != nil {
|
|
t.Fatalf("pgtest: create database %s: %v", name, err)
|
|
}
|
|
return strings.Replace(adminURL, "/postgres?", "/"+name+"?", 1)
|
|
}
|
|
|
|
// start launches the container and waits for it to accept queries, returning
|
|
// its id and a connection URL for the default database.
|
|
func start() (id, url string, err error) {
|
|
out, err := exec.Command("docker", "run", "-d", "--rm",
|
|
"-e", "POSTGRES_PASSWORD=pgtest",
|
|
"-P", image,
|
|
// Durability buys nothing for a database that dies with the test
|
|
// binary, and turning it off is most of the container's start-up cost.
|
|
"-c", "fsync=off", "-c", "full_page_writes=off",
|
|
).Output()
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("docker run %s: %w", image, err)
|
|
}
|
|
id = strings.TrimSpace(string(out))
|
|
|
|
port, err := exec.Command("docker", "port", id, "5432/tcp").Output()
|
|
if err != nil {
|
|
exec.Command("docker", "rm", "-f", "-v", id).Run()
|
|
return "", "", fmt.Errorf("docker port: %w", err)
|
|
}
|
|
// "0.0.0.0:32768" (and possibly a second, IPv6 line); the port is all we want.
|
|
first, _, _ := strings.Cut(strings.TrimSpace(string(port)), "\n")
|
|
url = fmt.Sprintf("postgres://postgres:pgtest@127.0.0.1:%s/postgres?sslmode=disable",
|
|
first[strings.LastIndex(first, ":")+1:])
|
|
|
|
if err := waitReady(url); err != nil {
|
|
exec.Command("docker", "rm", "-f", "-v", id).Run()
|
|
return "", "", err
|
|
}
|
|
return id, url, nil
|
|
}
|
|
|
|
func waitReady(url string) error {
|
|
db, err := sql.Open("pgx", url)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer db.Close()
|
|
|
|
deadline := time.Now().Add(readyLimit)
|
|
for {
|
|
if err = db.Ping(); err == nil {
|
|
return nil
|
|
}
|
|
if time.Now().After(deadline) {
|
|
return fmt.Errorf("postgres not ready after %s: %w", readyLimit, err)
|
|
}
|
|
time.Sleep(200 * time.Millisecond)
|
|
}
|
|
}
|