unkin-agent d59dcbe74e
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
Add the golib scaffold and the pg module
Stand up the shared library with its first module: the Postgres plumbing
every service currently copy-pastes — the DSN builder, the pool
constructor, and the migration runner arrproxy proved out.

- Add pg.DSNFromEnv, generalising the identical Sprintf builders in
  encapi, artifactapi and forgebot into one prefixed lookup with
  DATABASE_URL passthrough and libpq fallbacks.
- Add pg.New and pg.NewMigrated, which ping before returning so an
  unreachable server fails at startup rather than on the first query.
- Add pg.Migrate, lifting arrproxy's runner verbatim in semantics and
  generalising the hardcoded advisory-lock key to FNV-1a/64 of a
  caller-supplied name and the embedded set to an fs.FS.
- Add pg/pgtest, unifying the encapi and artifactapi testcontainers
  helpers, with SkipIfShort so container-backed tests self-skip on the
  Docker-less Kubernetes runners.
- Add the Makefile, README and pre-commit config, plus test, pre-commit
  and build pipelines on golib-ci.
2026-08-31 22:19:40 +10:00

golib

Shared Go library for the unkin estate: the plumbing that was being copy-pasted between services, kept in one place with one set of tests.

golib is a library only. It ships no binaries and no container images, holds no service configuration, and takes on dependencies grudgingly — every service that imports it inherits them.

Modules

Import What it does
git.unkin.net/unkin/golib/pg Postgres: DSN from the environment, pgxpool construction, and the estate's migration runner.
git.unkin.net/unkin/golib/pg/pgtest A throwaway Postgres container for a consumer's own _test.go files. Test-only.

pg

dsn, err := pg.DSNFromEnv("ENCAPI_")
if err != nil {
    return err
}
pool, err := pg.NewMigrated(ctx, dsn, migrations.FS, pg.MigrateOptions{
    LockName: "encapi-migrations",
    Logger:   log,
})

pg.DSNFromEnv(prefix) resolves a connection string from the environment. Precedence, highest first:

  1. <PREFIX>DATABASE_URL — used verbatim.
  2. DATABASE_URL — likewise.
  3. <PREFIX>DBHOST, <PREFIX>DBPORT, <PREFIX>DBUSER, <PREFIX>DBPASS, <PREFIX>DBNAME, <PREFIX>DBSSL.
  4. libpq's PGHOST, PGPORT, PGUSER, PGPASSWORD, PGDATABASE, PGSSLMODE.
  5. Defaults: localhost, 5432, sslmode=disable.

Levels 3 to 5 resolve per field, so a deployment can set the password from a secret and leave the rest to PG*. User and database name have no default — an unset one is an error naming the variables that were checked. An empty prefix reads the bare DBHOST/DBPORT/… names the estate's services already use, so the rendered DSN is unchanged from the fmt.Sprintf builders this replaces.

pg.New opens a pgxpool and pings it, so an unreachable server fails at startup rather than on the first query. pg.NewMigrated does that and then migrates.

pg.Migrate(ctx, pool, fsys, opts) applies the .sql files at the root of fsys in lexical filename order. Every replica calls it at startup:

  • The run holds a cluster-wide pg_advisory_lock keyed on FNV-1a/64 of opts.LockName, on one dedicated pooled connection, because the lock is session-scoped. Replicas that queue behind the winner find the set already recorded and do nothing.
  • Each file is applied together with its schema_migrations row in a single transaction, so a failure leaves neither a half-tracked migration nor a tracking row that would skip it next time.
  • A file missing from schema_migrations is re-applied even if the live database already has it, which is how a schema applied out of band is adopted. Write migrations IF NOT EXISTS-guarded so that re-run is a no-op.
  • If the unlock does not land, the connection is discarded rather than returned to the pool, so a session that may still hold the lock cannot be reused.

pg.LockKey(name) exposes the derivation, so a service migrating off a hardcoded key can assert the two agree before switching over.

pgtest

pgtest starts postgres:17-alpine via testcontainers. Import it only from _test.go files. The estate's CI runs on Kubernetes with no Docker socket, so container-backed tests must skip themselves under -short:

func TestSomething(t *testing.T) {
    ctx := context.Background()
    dsn := pgtest.MustStartPostgres(ctx, t) // skips under -short, cleans up after
    ...
}

Consuming

golib is versioned with semver tags and consumed like any Go module. Pin a tag:

go get git.unkin.net/unkin/golib@v0.1.0

Nothing is released until a v* tag exists; make patch, make minor and make major cut and push the next one.

Because everything shares one module path, a consumer that imports only pg still resolves golib's full dependency set in its module graph. That is the reason to keep the dependency list short, and the reason pgtest's testcontainers dependency is the exception rather than the pattern.

Development

make build     # compile every package
make test      # unit tests (-short: no container needed)
make test-all  # everything, including the container-backed integration tests
make cover     # unit tests with the coverage gate
make lint      # golangci-lint

New code needs meaningful tests. make cover fails below 90% statement coverage, measured over the shipped packages from the unit tests alone — the integration tests do not count towards it, so the bar has to be cleared without a database. pg/pgtest is excluded: it is test scaffolding for other repos, and is covered by the integration tests the gate does not run.

CI runs test, pre-commit and build on every pull request.

S
Description
Shared Go library for estate services: postgres, http service kit, vault and gitea clients
Readme 103 KiB
Languages
Go 97%
Makefile 3%