diff --git a/CHANGELOG.md b/CHANGELOG.md index 57fb391..53fd9f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ separately by `model.SchemaVersion` (currently 1.2.0). ## [Unreleased] +### Added +- **`$PGSERVICE` as a connection fallback** (#25). When no connection string + is passed and neither `$DATABASE_URL` nor `$PGBOT_DATABASE_URL` is set, + pgbot now checks `$PGSERVICE` too, so a + [connection service file](https://www.postgresql.org/docs/current/libpq-pgservice.html) + alone is enough to pick a database. pgx's `ParseConfig` already reads + `PGSERVICEFILE` (or the libpq default path); this just stops pgbot from + erroring out before pgx gets a chance to. + ## [0.8.1] - 2026-09-06 ### Fixed diff --git a/README.md b/README.md index bfc610d..3ed5dbc 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,10 @@ export DATABASE_URL="postgres://pgbot_ro@host:5432/db" pgbot inspect ``` -pgbot reads the argument first, then `$DATABASE_URL`, then `$PGBOT_DATABASE_URL`. +pgbot reads the argument first, then `$DATABASE_URL`, then `$PGBOT_DATABASE_URL`, +then `$PGSERVICE` (if you keep your connections in a +[connection service file](https://www.postgresql.org/docs/current/libpq-pgservice.html), +just `export PGSERVICE=mydb` and drop the argument too). (Shell note: `export DATABASE_URL="…"` — no `$` on the left, no spaces around `=`.) Everything pgbot takes from the environment fits in one block — the connection, @@ -218,7 +221,7 @@ carried into the advice, not lost. ## Commands and flags Every command takes the connection the same way — an argument, `$DATABASE_URL`, -or `$PGBOT_DATABASE_URL`. +`$PGBOT_DATABASE_URL`, or `$PGSERVICE`. | Command | What it does | |---|---| @@ -426,7 +429,7 @@ one SSH connection serves the whole run. Raise `--timeout` if the link is slow. | Variable | Purpose | |---|---| -| `DATABASE_URL` / `PGBOT_DATABASE_URL` | Connection used when no connection string is passed (checked in that order, after the argument). | +| `DATABASE_URL` / `PGBOT_DATABASE_URL` / `PGSERVICE` | Connection used when no connection string is passed (checked in that order, after the argument). `PGSERVICE` picks a `[section]` from your [connection service file](https://www.postgresql.org/docs/current/libpq-pgservice.html) (`PGSERVICEFILE`, or the libpq default path). | | `NO_COLOR` | Disables ANSI output (as does a non-TTY, or `--no-color`). | | `XDG_STATE_HOME` | Where the baseline store lives; defaults to `~/.local/state`. | | `PGBOT_SSH_TUNNEL` | SSH jump host used when `--ssh-tunnel` isn't passed (`[user@]host[:port]`, or a `~/.ssh/config` alias). | diff --git a/cmd/pgbot/activity.go b/cmd/pgbot/activity.go index e290017..678da0b 100644 --- a/cmd/pgbot/activity.go +++ b/cmd/pgbot/activity.go @@ -61,7 +61,7 @@ wait on, and the (scrubbed) SQL. Plain idle sessions are summarized, not listed (--all lists them too). pgbot's own connections are excluded by PID.`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - connString := firstNonEmpty(argAt(args, 0), os.Getenv("DATABASE_URL"), os.Getenv("PGBOT_DATABASE_URL")) + connString := firstNonEmpty(argAt(args, 0), os.Getenv("DATABASE_URL"), os.Getenv("PGBOT_DATABASE_URL"), pgServiceFallback()) if connString == "" { return fmt.Errorf("no connection string (pass one or set $DATABASE_URL)") } diff --git a/cmd/pgbot/advise.go b/cmd/pgbot/advise.go index d1ce364..8c9ff21 100644 --- a/cmd/pgbot/advise.go +++ b/cmd/pgbot/advise.go @@ -98,7 +98,7 @@ func adviseRun(ctx context.Context, connString string, top int, minImpr float64) } func runAdvise(cmd *cobra.Command, args []string, f adviseFlags) error { - connString := firstNonEmpty(argAt(args, 0), os.Getenv("DATABASE_URL"), os.Getenv("PGBOT_DATABASE_URL")) + connString := firstNonEmpty(argAt(args, 0), os.Getenv("DATABASE_URL"), os.Getenv("PGBOT_DATABASE_URL"), pgServiceFallback()) if connString == "" { return fmt.Errorf("no connection string (pass one or set $DATABASE_URL)") } diff --git a/cmd/pgbot/ask.go b/cmd/pgbot/ask.go index 8a014c0..e092e13 100644 --- a/cmd/pgbot/ask.go +++ b/cmd/pgbot/ask.go @@ -51,7 +51,7 @@ func runAsk(cmd *cobra.Command, question, url string, f inspectFlags, yes bool) return fmt.Errorf("aborted") } - connString := firstNonEmpty(url, os.Getenv("DATABASE_URL"), os.Getenv("PGBOT_DATABASE_URL")) + connString := firstNonEmpty(url, os.Getenv("DATABASE_URL"), os.Getenv("PGBOT_DATABASE_URL"), pgServiceFallback()) if connString == "" { return fmt.Errorf("no connection string (pass --url or set $DATABASE_URL)") } diff --git a/cmd/pgbot/config_cmd.go b/cmd/pgbot/config_cmd.go index 548887d..3256a6f 100644 --- a/cmd/pgbot/config_cmd.go +++ b/cmd/pgbot/config_cmd.go @@ -151,7 +151,7 @@ func newConfigInitCmd() *cobra.Command { } func runConfigInit(cmd *cobra.Command, args []string, f inspectFlags, out string) error { - connString := firstNonEmpty(argAt(args, 0), os.Getenv("DATABASE_URL"), os.Getenv("PGBOT_DATABASE_URL")) + connString := firstNonEmpty(argAt(args, 0), os.Getenv("DATABASE_URL"), os.Getenv("PGBOT_DATABASE_URL"), pgServiceFallback()) if connString == "" { return fmt.Errorf("no connection string (pass one or set $DATABASE_URL)") } diff --git a/cmd/pgbot/erd.go b/cmd/pgbot/erd.go index 6655417..6b0c360 100644 --- a/cmd/pgbot/erd.go +++ b/cmd/pgbot/erd.go @@ -25,7 +25,7 @@ func newERDCmd() *cobra.Command { Short: "Draw the schema as an ER diagram in the terminal (--mermaid for GitHub/mermaid.live)", Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - connString := firstNonEmpty(argAt(args, 0), os.Getenv("DATABASE_URL"), os.Getenv("PGBOT_DATABASE_URL")) + connString := firstNonEmpty(argAt(args, 0), os.Getenv("DATABASE_URL"), os.Getenv("PGBOT_DATABASE_URL"), pgServiceFallback()) if connString == "" { return fmt.Errorf("no connection string (pass one or set $DATABASE_URL)") } diff --git a/cmd/pgbot/explain.go b/cmd/pgbot/explain.go index b4f3891..b44ce56 100644 --- a/cmd/pgbot/explain.go +++ b/cmd/pgbot/explain.go @@ -67,7 +67,7 @@ func runExplain(cmd *cobra.Command, args []string, f inspectFlags, yes bool) err return fmt.Errorf("aborted") } - connString := firstNonEmpty(argAt(args, 0), os.Getenv("DATABASE_URL"), os.Getenv("PGBOT_DATABASE_URL")) + connString := firstNonEmpty(argAt(args, 0), os.Getenv("DATABASE_URL"), os.Getenv("PGBOT_DATABASE_URL"), pgServiceFallback()) if connString == "" { return fmt.Errorf("no connection string (pass one or set $DATABASE_URL)") } diff --git a/cmd/pgbot/helpers.go b/cmd/pgbot/helpers.go index fb5884e..bfea1de 100644 --- a/cmd/pgbot/helpers.go +++ b/cmd/pgbot/helpers.go @@ -24,6 +24,19 @@ func firstNonEmpty(vals ...string) string { return "" } +// pgServiceFallback lets a bare $PGSERVICE select a connection when neither an +// argument nor $DATABASE_URL/$PGBOT_DATABASE_URL is set. pgx's ParseConfig +// already reads a connection service file (PGSERVICEFILE, or the libpq +// default path) once it gets a "service=..." string — this just builds that +// string so users who manage connections through a service file don't have +// to also pass one explicitly. +func pgServiceFallback() string { + if svc := os.Getenv("PGSERVICE"); svc != "" { + return "service=" + svc + } + return "" +} + // isInteractive reports whether stdin is a terminal — used to decide whether to // prompt for confirmation (skip the prompt when piped/scripted). func isInteractive() bool { diff --git a/cmd/pgbot/indexes.go b/cmd/pgbot/indexes.go index c46c533..5c55492 100644 --- a/cmd/pgbot/indexes.go +++ b/cmd/pgbot/indexes.go @@ -38,7 +38,7 @@ func newIndexesCmd() *cobra.Command { } func runIndexes(cmd *cobra.Command, args []string, f inspectFlags, doCorrelate bool) error { - connString := firstNonEmpty(argAt(args, 0), os.Getenv("DATABASE_URL"), os.Getenv("PGBOT_DATABASE_URL")) + connString := firstNonEmpty(argAt(args, 0), os.Getenv("DATABASE_URL"), os.Getenv("PGBOT_DATABASE_URL"), pgServiceFallback()) if connString == "" { return fmt.Errorf("no connection string (pass one or set $DATABASE_URL)") } diff --git a/cmd/pgbot/init.go b/cmd/pgbot/init.go index 7fa9678..75633fc 100644 --- a/cmd/pgbot/init.go +++ b/cmd/pgbot/init.go @@ -36,7 +36,7 @@ func newInitCmd() *cobra.Command { "(pg_monitor, pg_stat_statements, primary vs standby).", Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - connString := firstNonEmpty(argAt(args, 0), os.Getenv("DATABASE_URL"), os.Getenv("PGBOT_DATABASE_URL")) + connString := firstNonEmpty(argAt(args, 0), os.Getenv("DATABASE_URL"), os.Getenv("PGBOT_DATABASE_URL"), pgServiceFallback()) if verify { if connString == "" { diff --git a/cmd/pgbot/inspect.go b/cmd/pgbot/inspect.go index 71a22e5..ddb1cc4 100644 --- a/cmd/pgbot/inspect.go +++ b/cmd/pgbot/inspect.go @@ -101,7 +101,7 @@ func runInspect(cmd *cobra.Command, args []string, f inspectFlags) error { if f.profile != "full" && f.profile != "schema" { return usageErrf("--profile must be full|schema, got %q", f.profile) } - connString := firstNonEmpty(argAt(args, 0), os.Getenv("DATABASE_URL"), os.Getenv("PGBOT_DATABASE_URL")) + connString := firstNonEmpty(argAt(args, 0), os.Getenv("DATABASE_URL"), os.Getenv("PGBOT_DATABASE_URL"), pgServiceFallback()) if connString == "" { return fmt.Errorf("no connection string (pass one or set $DATABASE_URL)") } diff --git a/cmd/pgbot/logs.go b/cmd/pgbot/logs.go index 28c1735..4d04912 100644 --- a/cmd/pgbot/logs.go +++ b/cmd/pgbot/logs.go @@ -63,7 +63,7 @@ output shows log lines verbatim; --json scrubs literals (the machine contract).` } func runLogs(cmd *cobra.Command, args []string, f logsFlags) error { - connString := firstNonEmpty(argAt(args, 0), os.Getenv("DATABASE_URL"), os.Getenv("PGBOT_DATABASE_URL")) + connString := firstNonEmpty(argAt(args, 0), os.Getenv("DATABASE_URL"), os.Getenv("PGBOT_DATABASE_URL"), pgServiceFallback()) if connString == "" { return fmt.Errorf("no connection string (pass one or set $DATABASE_URL)") } diff --git a/cmd/pgbot/main_test.go b/cmd/pgbot/main_test.go index 98b8df5..1064e49 100644 --- a/cmd/pgbot/main_test.go +++ b/cmd/pgbot/main_test.go @@ -40,6 +40,7 @@ func TestDsnFromArgs(t *testing.T) { // Falls back to $DATABASE_URL when no argument is given. t.Setenv("DATABASE_URL", "postgres://env") t.Setenv("PGBOT_DATABASE_URL", "") + t.Setenv("PGSERVICE", "") if dsn, err := dsnFromArgs(json.RawMessage(`{}`)); err != nil || dsn != "postgres://env" { t.Errorf("env fallback not honored: %q, %v", dsn, err) } @@ -47,11 +48,37 @@ func TestDsnFromArgs(t *testing.T) { // No argument and no env is a clear error, not an empty string. t.Setenv("DATABASE_URL", "") t.Setenv("PGBOT_DATABASE_URL", "") + t.Setenv("PGSERVICE", "") if _, err := dsnFromArgs(json.RawMessage(`{}`)); err == nil { t.Error("missing DSN everywhere should be an error") } } +func TestPgServiceFallback(t *testing.T) { + t.Setenv("PGSERVICE", "") + if got := pgServiceFallback(); got != "" { + t.Errorf("no $PGSERVICE should fall back to empty, got %q", got) + } + + t.Setenv("PGSERVICE", "mydb") + if got := pgServiceFallback(); got != "service=mydb" { + t.Errorf("pgServiceFallback = %q, want %q", got, "service=mydb") + } + + // A bare $PGSERVICE resolves a connection when nothing else is set — + // pgx's ParseConfig reads PGSERVICE(FILE) itself once it sees "service=...". + t.Setenv("DATABASE_URL", "") + t.Setenv("PGBOT_DATABASE_URL", "") + if dsn, err := dsnFromArgs(json.RawMessage(`{}`)); err != nil || dsn != "service=mydb" { + t.Errorf("PGSERVICE fallback not honored: %q, %v", dsn, err) + } + + // An explicit argument still wins over $PGSERVICE. + if dsn, err := dsnFromArgs(json.RawMessage(`{"connection_string":"postgres://arg"}`)); err != nil || dsn != "postgres://arg" { + t.Errorf("arg should outrank $PGSERVICE: %q, %v", dsn, err) + } +} + func TestFirstNonEmpty(t *testing.T) { if got := firstNonEmpty("", "", "third"); got != "third" { t.Errorf("firstNonEmpty picked %q, want third", got) diff --git a/cmd/pgbot/mcp.go b/cmd/pgbot/mcp.go index 5bafd34..6ac67c8 100644 --- a/cmd/pgbot/mcp.go +++ b/cmd/pgbot/mcp.go @@ -279,7 +279,7 @@ func dsnFromArgs(args json.RawMessage) (string, error) { ConnectionString string `json:"connection_string"` } _ = json.Unmarshal(args, &a) - dsn := firstNonEmpty(a.ConnectionString, os.Getenv("DATABASE_URL"), os.Getenv("PGBOT_DATABASE_URL")) + dsn := firstNonEmpty(a.ConnectionString, os.Getenv("DATABASE_URL"), os.Getenv("PGBOT_DATABASE_URL"), pgServiceFallback()) if dsn == "" { return "", fmt.Errorf("no connection string: pass connection_string or set $DATABASE_URL for the server") } diff --git a/cmd/pgbot/pgservice_test.go b/cmd/pgbot/pgservice_test.go new file mode 100644 index 0000000..e20f5d2 --- /dev/null +++ b/cmd/pgbot/pgservice_test.go @@ -0,0 +1,46 @@ +package main + +import ( + "os" + "path/filepath" + "testing" + + "github.com/jackc/pgx/v5" +) + +// The fallback hands pgx a bare "service=" and relies on pgx to read the +// connection service file. Pin that end to end: a service file named through +// PGSERVICEFILE must supply host, port, user, and database, and a service name +// missing from the file must be an error rather than a silent localhost. +func TestPgServiceFallback_resolvesThroughServiceFile(t *testing.T) { + dir := t.TempDir() + file := filepath.Join(dir, "pg_service.conf") + if err := os.WriteFile(file, []byte("[prod-ro]\nhost=db.internal\nport=6432\nuser=pgbot_ro\ndbname=appdb\nsslmode=require\n"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("PGSERVICEFILE", file) + t.Setenv("PGSERVICE", "prod-ro") + t.Setenv("DATABASE_URL", "") + t.Setenv("PGBOT_DATABASE_URL", "") + // The service file must be the only source of these. + for _, v := range []string{"PGHOST", "PGPORT", "PGUSER", "PGDATABASE", "PGSSLMODE"} { + t.Setenv(v, "") + } + + dsn := firstNonEmpty("", os.Getenv("DATABASE_URL"), os.Getenv("PGBOT_DATABASE_URL"), pgServiceFallback()) + cfg, err := pgx.ParseConfig(dsn) + if err != nil { + t.Fatalf("pgx.ParseConfig(%q): %v", dsn, err) + } + if cfg.Host != "db.internal" || cfg.Port != 6432 || cfg.User != "pgbot_ro" || cfg.Database != "appdb" { + t.Fatalf("service file not applied: host=%q port=%d user=%q db=%q", cfg.Host, cfg.Port, cfg.User, cfg.Database) + } + if cfg.TLSConfig == nil { + t.Fatal("sslmode=require from the service file was not applied") + } + + t.Setenv("PGSERVICE", "does-not-exist") + if _, err := pgx.ParseConfig(pgServiceFallback()); err == nil { + t.Fatal("an unknown service name should fail to parse, not fall through to defaults") + } +} diff --git a/cmd/pgbot/queries.go b/cmd/pgbot/queries.go index 66da27e..acdc1b5 100644 --- a/cmd/pgbot/queries.go +++ b/cmd/pgbot/queries.go @@ -36,7 +36,7 @@ func newQueriesCmd() *cobra.Command { } func runQueries(cmd *cobra.Command, args []string, f inspectFlags, byCalls bool) error { - connString := firstNonEmpty(argAt(args, 0), os.Getenv("DATABASE_URL"), os.Getenv("PGBOT_DATABASE_URL")) + connString := firstNonEmpty(argAt(args, 0), os.Getenv("DATABASE_URL"), os.Getenv("PGBOT_DATABASE_URL"), pgServiceFallback()) if connString == "" { return fmt.Errorf("no connection string (pass one or set $DATABASE_URL)") } diff --git a/cmd/pgbot/report.go b/cmd/pgbot/report.go index b13ec50..5011222 100644 --- a/cmd/pgbot/report.go +++ b/cmd/pgbot/report.go @@ -23,7 +23,7 @@ func newReportCmd() *cobra.Command { Short: "Full inspection as one self-contained HTML page: pgbot report > report.html", Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - connString := firstNonEmpty(argAt(args, 0), os.Getenv("DATABASE_URL"), os.Getenv("PGBOT_DATABASE_URL")) + connString := firstNonEmpty(argAt(args, 0), os.Getenv("DATABASE_URL"), os.Getenv("PGBOT_DATABASE_URL"), pgServiceFallback()) if connString == "" { return fmt.Errorf("no connection string (pass one or set $DATABASE_URL)") } diff --git a/cmd/pgbot/tables.go b/cmd/pgbot/tables.go index adee595..9fee614 100644 --- a/cmd/pgbot/tables.go +++ b/cmd/pgbot/tables.go @@ -32,7 +32,7 @@ func newTablesCmd() *cobra.Command { } func runTables(cmd *cobra.Command, args []string, f inspectFlags) error { - connString := firstNonEmpty(argAt(args, 0), os.Getenv("DATABASE_URL"), os.Getenv("PGBOT_DATABASE_URL")) + connString := firstNonEmpty(argAt(args, 0), os.Getenv("DATABASE_URL"), os.Getenv("PGBOT_DATABASE_URL"), pgServiceFallback()) if connString == "" { return fmt.Errorf("no connection string (pass one or set $DATABASE_URL)") } diff --git a/cmd/pgbot/tune.go b/cmd/pgbot/tune.go index 10f40e0..6859065 100644 --- a/cmd/pgbot/tune.go +++ b/cmd/pgbot/tune.go @@ -34,7 +34,7 @@ func newTuneCmd() *cobra.Command { } func runTune(cmd *cobra.Command, args []string, f inspectFlags) error { - connString := firstNonEmpty(argAt(args, 0), os.Getenv("DATABASE_URL"), os.Getenv("PGBOT_DATABASE_URL")) + connString := firstNonEmpty(argAt(args, 0), os.Getenv("DATABASE_URL"), os.Getenv("PGBOT_DATABASE_URL"), pgServiceFallback()) if connString == "" { return fmt.Errorf("no connection string (pass one or set $DATABASE_URL)") } diff --git a/cmd/pgbot/vacuum.go b/cmd/pgbot/vacuum.go index 1e24af3..ca8a764 100644 --- a/cmd/pgbot/vacuum.go +++ b/cmd/pgbot/vacuum.go @@ -42,7 +42,7 @@ func newVacuumCmd() *cobra.Command { } func runVacuum(cmd *cobra.Command, args []string, f inspectFlags) error { - connString := firstNonEmpty(argAt(args, 0), os.Getenv("DATABASE_URL"), os.Getenv("PGBOT_DATABASE_URL")) + connString := firstNonEmpty(argAt(args, 0), os.Getenv("DATABASE_URL"), os.Getenv("PGBOT_DATABASE_URL"), pgServiceFallback()) if connString == "" { return fmt.Errorf("no connection string (pass one or set $DATABASE_URL)") } diff --git a/cmd/pgbot/waits.go b/cmd/pgbot/waits.go index 2c61817..35ee545 100644 --- a/cmd/pgbot/waits.go +++ b/cmd/pgbot/waits.go @@ -88,7 +88,7 @@ func parseWaitsGroup(s string) (waitsGroup, error) { } func runWaits(cmd *cobra.Command, args []string, f waitsFlags) error { - connString := firstNonEmpty(argAt(args, 0), os.Getenv("DATABASE_URL"), os.Getenv("PGBOT_DATABASE_URL")) + connString := firstNonEmpty(argAt(args, 0), os.Getenv("DATABASE_URL"), os.Getenv("PGBOT_DATABASE_URL"), pgServiceFallback()) if connString == "" { return fmt.Errorf("no connection string (pass one or set $DATABASE_URL)") } diff --git a/cmd/pgbot/why.go b/cmd/pgbot/why.go index 58a2ebe..aa36b29 100644 --- a/cmd/pgbot/why.go +++ b/cmd/pgbot/why.go @@ -200,7 +200,7 @@ func whyArgIsDSN(s string) bool { // store, and merges with the offline history analysis when snapshots exist. // The offline path — `pgbot why` without --duration — is untouched. func runWhyLive(ctx context.Context, w io.Writer, f whyFlags, dsnArg string) error { - connString := firstNonEmpty(dsnArg, os.Getenv("DATABASE_URL"), os.Getenv("PGBOT_DATABASE_URL")) + connString := firstNonEmpty(dsnArg, os.Getenv("DATABASE_URL"), os.Getenv("PGBOT_DATABASE_URL"), pgServiceFallback()) if connString == "" { return fmt.Errorf("--duration samples the live database: pass a connection string or set $DATABASE_URL") }