Files
golib/README.md
T
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

115 lines
4.5 KiB
Markdown

# 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
```go
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`:
```go
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.