Skip to content
Merged
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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 |
|---|---|
Expand Down Expand Up @@ -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). |
Expand Down
2 changes: 1 addition & 1 deletion cmd/pgbot/activity.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/pgbot/advise.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/pgbot/ask.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/pgbot/config_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/pgbot/erd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/pgbot/explain.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
}
Expand Down
13 changes: 13 additions & 0 deletions cmd/pgbot/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion cmd/pgbot/indexes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/pgbot/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 == "" {
Expand Down
2 changes: 1 addition & 1 deletion cmd/pgbot/inspect.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/pgbot/logs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
}
Expand Down
27 changes: 27 additions & 0 deletions cmd/pgbot/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,18 +40,45 @@ 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)
}

// 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)
Expand Down
2 changes: 1 addition & 1 deletion cmd/pgbot/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
46 changes: 46 additions & 0 deletions cmd/pgbot/pgservice_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package main

import (
"os"
"path/filepath"
"testing"

"github.com/jackc/pgx/v5"
)

// The fallback hands pgx a bare "service=<name>" 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")
}
}
2 changes: 1 addition & 1 deletion cmd/pgbot/queries.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/pgbot/report.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/pgbot/tables.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/pgbot/tune.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/pgbot/vacuum.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/pgbot/waits.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/pgbot/why.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down