diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..70dd6db --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,51 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Tests on Postgres ${{ matrix.postgres }} + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + postgres: ["14", "15", "16", "17", "18"] + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-dotnet@v6 + with: + dotnet-version: 10.0.x + - name: Build and test + run: dotnet test --project tests/Pgcheckup.Tests + env: + PGCHECKUP_TEST_POSTGRES: ${{ matrix.postgres }} + + native-aot: + name: NativeAOT (linux-x64) + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-dotnet@v6 + with: + dotnet-version: 10.0.x + - name: Install the native toolchain + run: sudo apt-get update && sudo apt-get install -y clang zlib1g-dev + # Trim and AOT warnings are errors, so this fails on any IL2xxx or IL3xxx warning. + - name: Publish + run: dotnet publish src/Pgcheckup -c Release -r linux-x64 -o out + - name: Scan an inactive slot with the published binary + run: dotnet test --project tests/Pgcheckup.Tests -- --filter-class Pgcheckup.Tests.Cli.NativeBinaryTests + env: + PGCHECKUP_BINARY: ${{ github.workspace }}/out/pgcheckup diff --git a/AGENTS.md b/AGENTS.md index 0a430f0..b8ef622 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,19 +6,23 @@ These are the working rules for agents in this repo. pgcheckup is a read-only CL - `README.md`: the pitch and the "safe to run on production" promises. - `ROADMAP.md`: decisions already made, the milestones, and what is out of scope. Check it before proposing features. Respect those decisions unless the owner reopens them. Record new or changed decisions there, with the date. -- The repo is still in planning. Don't build past the current milestone without asking. +- Work follows the milestones in `ROADMAP.md`. Don't build past the current milestone without asking. ## Commands -There is no code yet. Add the build, test and publish commands here when M0 lands. Keep them cross-platform (`dotnet`, `docker`), because the owner develops on Windows. Avoid bash-only scripts. +Keep commands cross-platform (`dotnet`, `docker`), because the owner develops on Windows. Avoid bash-only scripts. +- Build: `dotnet build pgcheckup.slnx`. A broken check folder fails the build with its file and line. +- Test: `dotnet test --project tests/Pgcheckup.Tests`. It needs Docker, and uses Postgres 18 unless `PGCHECKUP_TEST_POSTGRES` names another major (14 to 17). CI runs all five. +- Publish: `dotnet publish src/Pgcheckup -c Release -r win-x64 -o out` (`linux-x64` on Linux). Trim and AOT warnings fail it. +- Test the published binary: set `PGCHECKUP_BINARY` to it, then run `dotnet test --project tests/Pgcheckup.Tests -- --filter-class Pgcheckup.Tests.Cli.NativeBinaryTests`. - NativeAOT publish on this Windows machine fails with `'vswhere.exe' is not recognized` unless the VS Installer folder is on PATH. Run it as `$env:PATH = "C:\Program Files (x86)\Microsoft Visual Studio\Installer;$env:PATH"; dotnet publish …`. That is an environment problem, not an AOT warning. ## Safe to run on production (hard rules) The product is only as good as these rules. Never break them, not even in debug modes or dev tooling. -- **Read-only, always.** A check is one `SELECT` against catalogs and statistics views. No DDL or DML, and no functions with side effects: `pg_terminate_backend`, `pg_cancel_backend`, `pg_reload_conf`, `pg_stat_reset*`, `pg_switch_wal`, `pg_create_*`, `pg_drop_*`, `nextval`, `setval`. Checks run under the session guards (`default_transaction_read_only`, `statement_timeout`, `lock_timeout`) inside a `READ ONLY` transaction. Never weaken or bypass them. +- **Read-only, always.** A check is one `SELECT` against catalogs and statistics views. No DDL or DML, and no functions with side effects: `pg_terminate_backend`, `pg_cancel_backend`, `pg_reload_conf`, `pg_stat_reset*`, `pg_switch_wal`, `pg_create_*`, `pg_drop_*`, `pg_advisory_*`, `nextval`, `setval`, `set_config`, `txid_current`. Every statement pgcheckup sends runs inside `BEGIN READ ONLY` with `SET LOCAL statement_timeout`, `lock_timeout` and `search_path = pg_catalog, pg_temp`, then rolls back. Never set anything for the whole session, because behind a transaction pooler it reaches the app's connections. Never weaken or bypass these guards. - **Fixes are text.** pgcheckup prints fix SQL and never executes it. - **Least privilege.** No check needs more than `pg_monitor`. Never require superuser or `rds_superuser`. If the role lacks a privilege, the check is skipped with the reason. It is never an error. - **No network beyond the Postgres connection.** No telemetry, update checks, crash reporting or remote lookups. Data such as end-of-life dates ships inside the release. @@ -29,10 +33,10 @@ The product is only as good as these rules. Never break them, not even in debug ## Checks - One folder per check: `checks//check.sql`, `check.md`, `fixtures/fires.sql` and `fixtures/healthy.sql`. The shape is in the `ROADMAP.md` decisions. -- `check.sql` is one read-only query that returns the fixed shape. Thresholds come in as parameters and are never hard-coded. +- `check.sql` is one read-only query that returns values, never prose. The wording lives in the `message` and `fix` templates in `check.md`. Thresholds come in as `@name` parameters and are never hard-coded. The search path is `pg_catalog` only, so qualify anything in another schema. - Compute ages and durations in SQL from the server's `now()`, not the client's clock. - `check.md` has **What breaks**, **Fix** and **Seen in** sections. Every check has at least one **Seen in** link to a public incident or the Postgres docs. Never cite anything a reader can't open. -- Both fixtures are required. `fires.sql` is the positive control, so a check without one isn't done. A fixture may lower a threshold when the real condition can't be reproduced at scale. +- Both fixtures are required. `fires.sql` is the positive control, so a check without one isn't done. A fixture may lower a threshold (`-- threshold name = value`) when the real condition can't be reproduced at scale. - Check ids are kebab-case and stable, because baselines and ignore lists depend on them. Renaming one is a breaking change that needs a decision in `ROADMAP.md`. - Severity: `critical` can take the database down or lose data soon. `warning` is heading there, or removes a safety net. `info` is housekeeping. Don't inflate severity. - A check declares its minimum Postgres version and the providers where it is skipped. Every check is tested on every supported version. @@ -69,4 +73,5 @@ The product is only as good as these rules. Never break them, not even in debug - **Checks:** automate acceptance checks instead of handing manual steps to the owner. Give every check that tests for an absence a positive control, meaning a case that proves the check can fail. - **Validation:** evidence comes from dogfooding (the log) and public async signals (issues, PRs, downloads, image pulls). Don't plan interviews, recruiting or outreach. - **Docs:** short and concise. Prefer editing `ROADMAP.md` over creating new planning documents. Repo files never reference the owner's private notes. -- **Code comments:** explain why, not what. Only comment on what the code can't say for itself: a non-obvious constraint, a workaround and its cause, or a line that keeps a hard rule. Don't restate names or types, don't add boilerplate XML docs, and don't leave commented-out code. A check's `check.md` is its documentation. +- **XML docs:** every public type and member in `src/` has an XML doc comment (`///`), including new ones. The build enforces it: `src/Directory.Build.props` turns on the doc file, so a missing comment is error CS1591. Say what the member does and its contract: parameters, what it returns, what it throws and edge cases, with `` to related types. Don't just restate the name. Document internal and private members too when their purpose isn't obvious from the name. Tests don't need XML docs, because their names say what they check. +- **Code comments:** inside code, explain why, not what. Only comment on what the code can't say for itself: a non-obvious constraint, a workaround and its cause, or a line that keeps a hard rule. Don't leave commented-out code. A check's `check.md` is its documentation. diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..cee89fc --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,17 @@ + + + net10.0 + latest + enable + enable + true + true + 0.0.0 + false + + + + + + diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..a03466f --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,14 @@ + + + true + + + + + + + + + + + diff --git a/README.md b/README.md index 95abfbe..9c5969c 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,9 @@ In February 2019, one of the Postgres shards behind Mailchimp's Mandrill [ran ou Most of these failures show up in the system catalogs weeks ahead: a table's transaction ID age, a replication slot nobody reads, WAL archiving that failed last night. Teams without a DBA rarely look. pgcheckup looks for them and tells you what to do. -> **Status:** planning. There is nothing to install yet. See [ROADMAP.md](ROADMAP.md). +> **Status:** early development. The first check, `replication-slot-inactive`, runs end to end. There is no release to install yet. See [ROADMAP.md](ROADMAP.md). + +![pgcheckup scanning a database whose inactive replication slot is holding 1.07 GB of WAL](docs/scan.gif) ## How it works diff --git a/ROADMAP.md b/ROADMAP.md index a7a25fc..4f70603 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -6,25 +6,27 @@ pgcheckup is a read-only CLI (.NET 10, NativeAOT) that checks a PostgreSQL datab - **Name.** The idea started as "Postgres Doctor". `pgdoctor` is already an existing Go project, so this one is pgcheckup. PostgresAI's `postgres-checkup` is unrelated, and the README says so. - **Position.** Production readiness for teams without a DBA. It has fewer checks than pg-healthcheck or pgdoctor, and each one maps to a failure that causes outages and is explained in plain words with its fix. It is not a DBA toolkit. -- **Read-only by construction.** A scan opens one connection with `default_transaction_read_only = on`, `application_name = pgcheckup`, `statement_timeout` (5 s) and `lock_timeout` (1 s). Every check runs inside its own `READ ONLY` transaction. The lock timeout stops a scan from queueing behind a migration's lock and then blocking the traffic behind it. pgcheckup prints fixes and never runs them. -- **Least privilege.** No check needs more than `pg_monitor`. Each check declares what it needs, and if the role doesn't have it, the check is skipped with the reason. `pgcheckup grant` prints the SQL for a checkup role. +- **Read-only by construction.** Every statement pgcheckup sends runs in its own transaction: `BEGIN READ ONLY`, `SET LOCAL statement_timeout = '5s'`, `SET LOCAL lock_timeout = '1s'`, `SET LOCAL search_path = pg_catalog, pg_temp`, the query, then `ROLLBACK`. The pinned search path stops a function planted in another schema from shadowing a built-in and running as the scanning role. Only `application_name = pgcheckup` is set for the whole session. Behind a transaction-mode pooler such as PgBouncer, a session-level `SET` would reach the app's next transaction, and the `options` connection parameter is rejected or dropped. The lock timeout stops a scan from queueing behind a migration's lock and then blocking the traffic behind it. pgcheckup prints fixes and never runs them. +- **Least privilege.** No check needs more than `pg_monitor`. Each check declares what it needs, and if the role doesn't have it, the check is skipped with the reason. `pgcheckup grant` prints the SQL for a checkup role, including `ALTER ROLE … SET default_transaction_read_only = on`, so the role is read-only outside pgcheckup too. - **SQL only.** pgcheckup talks only to Postgres. Provider settings that SQL can see (such as `rds.force_ssl`) are in scope. Checks that need a cloud API (RDS backups, deletion protection, encryption at rest) are not. - **Managed providers.** pgcheckup detects RDS/Aurora, Cloud SQL, Azure Database for PostgreSQL, Supabase and Neon from SQL. A check can list providers where it doesn't apply or can't run, and it shows as skipped there, with the reason. - **Checks are SQL plus Markdown**, one folder per check under `checks//`: - - `check.sql`: one read-only query that returns findings in a fixed shape (`subject`, `severity`, `detail`, plus named values for the message); - - `check.md`: frontmatter (id, title, category, default severity, thresholds, minimum Postgres version, required privileges, providers to skip) and a body with **What breaks**, **Fix** and **Seen in** (links to public incidents or the Postgres docs); + - `check.sql`: one read-only statement, starting with `SELECT` or `WITH`, that returns a row per finding: `subject`, an optional `severity` that overrides the default, and the named values the templates use; + - `check.md`: frontmatter (id, title, category, default severity, minimum Postgres version, required privileges, providers to skip, thresholds, and the `message` and `fix` templates) and a body with **What breaks**, **Fix** and **Seen in** (links to public incidents or the Postgres docs); - `fixtures/fires.sql` and `fixtures/healthy.sql`: setup scripts. The check must report on the first and stay quiet on the second. - Checks are embedded in the binary at build time, and the build fails on invalid frontmatter. -- **Thresholds** live in each check's frontmatter, with defaults. `check.sql` reads them as parameters, so a fixture can lower a threshold when the real condition can't be reproduced at full scale (wraparound, for example). Overrides from a config file come in v0.2. + A source generator compiles the checks into the binary, so nothing is parsed at runtime. The build fails on invalid frontmatter or templates, a missing section or fixture, an unused or unknown threshold, and SQL that calls a function with side effects that a `READ ONLY` transaction still allows (`pg_terminate_backend`, `pg_cancel_backend`, advisory locks, `txid_current` and others), or that runs SQL passed in as a string (`query_to_xml`, `ts_stat`). Npgsql's SQL rewriting is off, so the server rejects a second statement. +- **Messages are templates**, so numbers read the same in every check. `{name}` inserts a value, and intervals print as "3 days". `{name:count}` and `{name:bytes}` print as "1.61 billion" and "48 GB". A `[…]` section is left out when a value in it is NULL, so one template covers older Postgres versions. `check.sql` quotes names for fix SQL with `quote_ident` and `quote_literal`. JSON output gets the raw values. +- **Thresholds** live in each check's frontmatter, with defaults in Postgres units (`1GB`, `30min`, `1h`). `check.sql` reads them as `@name` parameters, which become `$1`, `$2`… at build time. A fixture can lower one with a `-- threshold name = value` line when the real condition can't be reproduced at full scale (wraparound, for example). Overrides from a config file come in v0.2. - **Ages and durations** are computed in SQL from the server's clock, so a skewed client clock can't change a finding. - **Postgres versions:** every community-supported major (14 to 18 today), each tested in CI with Testcontainers. When a major reaches end of life, it moves to best effort, and scanning it reports the end of life as a finding. +- **Fixture tests** give each fixture a fresh Testcontainers Postgres, because slots, prepared transactions and roles belong to the whole server. The fixture's connection stays open until the check has run, so a fixture can hold a transaction open. The check runs through the same code as `scan`, as a role with only `pg_monitor`. - **Output:** terminal (default), `--format json` and `--format markdown` in v0.1. The JSON has a `schema` version. The HTML report comes in v0.2. - **Exit codes:** 0 when no finding reaches `--fail-on` (default `critical`), 1 when one does, and 2 when the scan couldn't run. Skipped checks never fail a scan. - **Severity:** `critical` can take the database down or lose data soon. `warning` is heading there, or removes a safety net. `info` is housekeeping. - **Connection:** a `postgres://` URL, a key-value connection string, or the standard `PG*` environment variables and `.pgpass`. Docs keep passwords off the command line. - **What output may contain.** Findings name database objects (tables, slots, roles), settings, process IDs and durations. They never include query text, row data, passwords or client addresses. - **No network** beyond the Postgres connection. No telemetry and no update check. End-of-life dates ship inside each release. -- **Runtime:** .NET 10 (LTS) with NativeAOT, and Npgsql through `NpgsqlSlimDataSourceBuilder`. Trim and AOT warnings are errors. +- **Runtime:** .NET 10 (LTS) with NativeAOT, and Npgsql through `NpgsqlSlimDataSourceBuilder`. Trim and AOT warnings are errors. The CLI uses System.CommandLine, and tests use xUnit v3 and Testcontainers. - **Distribution:** NativeAOT binaries for linux-x64, linux-arm64, osx-arm64 and win-x64 on GitHub Releases, with SHA-256 checksums, plus a container image on GHCR for CI. NativeAOT can't cross-compile between operating systems, so releases build on a runner matrix. A `dotnet tool` package comes in v0.2. - **The paid path comes later.** The CLI stays free and complete. A one-time audit report, hosted monitoring and a team dashboard are listed under Later and get built only if public signals show demand. They would be separate code. - **Brand** is option 1A, "Scan": stacked layers read by a single probe line. The wordmark is Bricolage Grotesque SemiBold (optical size 34), converted to vector paths, with "pg" in Postgres blue (`#336791`, or `#5B9BD5` on dark). The assets are in `docs/brand/`, with `-dark` files for dark backgrounds. @@ -33,20 +35,20 @@ pgcheckup is a read-only CLI (.NET 10, NativeAOT) that checks a PostgreSQL datab ## M0: Placeholder (as soon as possible) - [x] Add `LICENSE` (Apache-2.0). -- [ ] Scaffold the .NET 10 solution: a `pgcheckup` console app with NativeAOT on, and a test project. `pgcheckup --version` and `pgcheckup list` work. -- [ ] Check contract: the `checks//` layout, frontmatter validation at build time, and embedding in the binary. One real check end to end: `replication-slot-inactive`. -- [ ] Fixture runner on Testcontainers: for every check, `fires.sql` must produce a finding and `healthy.sql` must not. -- [ ] Read-only guard: the fixture runner runs each check as a role that has only `pg_monitor`, inside a `READ ONLY` transaction. Positive control: a test check that writes must fail. -- [ ] `pgcheckup scan` with terminal output and exit codes 0, 1 and 2. -- [ ] CI on every PR: build, fixture tests on Postgres 14 to 18, and a NativeAOT publish that fails on any IL2xxx or IL3xxx warning. +- [x] Scaffold the .NET 10 solution: a `pgcheckup` console app with NativeAOT on, and a test project. `pgcheckup --version` and `pgcheckup list` work. +- [x] Check contract: the `checks//` layout, frontmatter validation at build time, and embedding in the binary. One real check end to end: `replication-slot-inactive`. +- [x] Fixture runner on Testcontainers: for every check, `fires.sql` must produce a finding and `healthy.sql` must not. +- [x] Read-only guard: the fixture runner runs each check as a role that has only `pg_monitor`, inside a `READ ONLY` transaction. Positive control: a test check that writes must fail. +- [x] `pgcheckup scan` with terminal output and exit codes 0, 1 and 2. +- [x] CI on every PR: build, fixture tests on Postgres 14 to 18, and a NativeAOT publish that fails on any IL2xxx or IL3xxx warning. - [x] Brand: the logo in `docs/brand/`, with `-dark` variants. Then replace the README heading with a `` lockup. -- [ ] Add a recording or screenshot of a scan to the README. +- [x] Add a recording or screenshot of a scan to the README. **Done when:** CI is green, the NativeAOT binary reports an inactive replication slot on a Testcontainers Postgres and exits 1 with `--fail-on warning`, and a test PR that adds a writing check fails the read-only guard. ## M1: Engine and check catalog -- [ ] Engine: session guards, server version and provider detection, and a privilege probe, followed by every applicable check in its own read-only transaction. A check that errors or times out is reported as errored, and the others still run. +- [ ] Engine: server version and provider detection, and a privilege probe, followed by every applicable check. A check that errors or times out is reported as errored, and the others still run. - [ ] Provider detection, tested by simulating each provider's roles and settings in fixtures. - [ ] Desk research for the catalog: go through public Postgres postmortems (danluu/post-mortems, engineering blogs) and DBA Stack Exchange, list the failures that recur, and adjust the table below to match. Every check gets at least one **Seen in** link. - [ ] The v0.1 checks: @@ -69,7 +71,7 @@ pgcheckup is a read-only CLI (.NET 10, NativeAOT) that checks a PostgreSQL datab | `postgres-eol` | Major versions past their end-of-life date | - [ ] `pgcheckup explain ` prints the check's note. `pgcheckup list` shows every check with its category and minimum version. -- [ ] `pgcheckup grant` prints SQL for a least-privilege checkup role (`pg_monitor` plus `CONNECT`). +- [ ] `pgcheckup grant` prints SQL for a least-privilege checkup role: `pg_monitor`, `CONNECT`, and `default_transaction_read_only = on` for the role. - [ ] `--format json` and `--format markdown`. The JSON shape is documented, with `"schema": 1`. **Done when:** every check's fixtures pass on Postgres 14 to 18, and a scan as a role with only `pg_monitor` either runs or skips (with a reason) every check, with no errors. diff --git a/checks/replication-slot-inactive/check.md b/checks/replication-slot-inactive/check.md new file mode 100644 index 0000000..2c013b6 --- /dev/null +++ b/checks/replication-slot-inactive/check.md @@ -0,0 +1,35 @@ +--- +id: replication-slot-inactive +title: Inactive replication slot +category: wal +severity: warning +min_version: 14 +privileges: [] +thresholds: + min_retained_wal: 1GB +message: Slot {subject} has been inactive[ for {inactive_for}] and is holding {retained_wal:bytes} of WAL. +fix: | + restart its consumer, or drop the slot: + SELECT pg_drop_replication_slot({slot_literal}); +--- + +## What breaks + +A replication slot makes Postgres keep every WAL segment that its consumer hasn't confirmed. When the consumer stops, the slot keeps WAL for as long as it exists. Typical consumers are a replica that was removed, a paused CDC connector such as Debezium, or a subscription dropped without its slot. `pg_wal` grows until the disk is full, and then Postgres stops accepting writes. + +A logical slot also holds back `catalog_xmin`, so vacuum can't clean up the system catalogs while the slot waits. + +## Fix + +If the consumer should still exist, restart it and let it catch up. If it shouldn't, drop the slot: + +```sql +SELECT pg_drop_replication_slot('slot_name'); +``` + +Then cap the WAL any slot can keep with `max_slot_wal_keep_size`. A slot that passes the cap is invalidated instead of filling the disk. On Postgres 18, `idle_replication_slot_timeout` also invalidates slots that stay inactive for too long. + +## Seen in + +- [The Insatiable Postgres Replication Slot](https://www.morling.dev/blog/insatiable-postgres-replication-slot/), Gunnar Morling: an inactive slot on an idle Amazon RDS database kept growing its WAL. +- [Replication slots](https://www.postgresql.org/docs/current/warm-standby.html#STREAMING-REPLICATION-SLOTS), PostgreSQL documentation: "replication slots can cause the server to retain so many WAL segments that they fill up the space allocated for pg_wal." diff --git a/checks/replication-slot-inactive/check.sql b/checks/replication-slot-inactive/check.sql new file mode 100644 index 0000000..070362f --- /dev/null +++ b/checks/replication-slot-inactive/check.sql @@ -0,0 +1,21 @@ +SELECT s.slot_name AS subject, + quote_literal(s.slot_name) AS slot_literal, + -- inactive_since arrived in Postgres 17. Reading it through to_jsonb keeps one query + -- for every supported version, and gives NULL before 17. + now() - (to_jsonb(s) ->> 'inactive_since')::timestamptz AS inactive_for, + w.retained_wal +FROM pg_replication_slots AS s +CROSS JOIN LATERAL ( + -- On a standby, pg_current_wal_lsn() raises an error; the replay position is its equivalent. + SELECT pg_wal_lsn_diff( + CASE WHEN pg_is_in_recovery() THEN pg_last_wal_replay_lsn() ELSE pg_current_wal_lsn() END, + s.restart_lsn)::bigint AS retained_wal +) AS w +WHERE NOT s.active + -- A lost slot has already been invalidated and holds no WAL. + AND s.wal_status IS DISTINCT FROM 'lost' + -- On a standby, a slot synced from the primary (Postgres 17 and later) always looks inactive, + -- and can't be dropped there. Its consumer is on the primary, where this check covers it. + AND NOT coalesce((to_jsonb(s) ->> 'synced')::boolean, false) + AND w.retained_wal >= @min_retained_wal +ORDER BY w.retained_wal DESC diff --git a/checks/replication-slot-inactive/fixtures/fires.sql b/checks/replication-slot-inactive/fixtures/fires.sql new file mode 100644 index 0000000..0bb7edc --- /dev/null +++ b/checks/replication-slot-inactive/fixtures/fires.sql @@ -0,0 +1,4 @@ +-- threshold min_retained_wal = 0B +-- A physical slot that reserves WAL from the start and never gets a consumer. +SELECT pg_create_physical_replication_slot('fixture_slot', true); +CREATE TABLE fixture_wal AS SELECT g FROM generate_series(1, 1000) AS g; diff --git a/checks/replication-slot-inactive/fixtures/healthy.sql b/checks/replication-slot-inactive/fixtures/healthy.sql new file mode 100644 index 0000000..74b0433 --- /dev/null +++ b/checks/replication-slot-inactive/fixtures/healthy.sql @@ -0,0 +1,4 @@ +-- threshold min_retained_wal = 0B +-- A slot that has never reserved WAL holds none back, so even a zero threshold stays quiet. +SELECT pg_create_physical_replication_slot('fixture_slot'); +CREATE TABLE fixture_wal AS SELECT g FROM generate_series(1, 1000) AS g; diff --git a/docs/scan.gif b/docs/scan.gif new file mode 100644 index 0000000..78a4d02 Binary files /dev/null and b/docs/scan.gif differ diff --git a/global.json b/global.json new file mode 100644 index 0000000..1d364c6 --- /dev/null +++ b/global.json @@ -0,0 +1,9 @@ +{ + "sdk": { + "version": "10.0.100", + "rollForward": "latestFeature" + }, + "test": { + "runner": "Microsoft.Testing.Platform" + } +} diff --git a/pgcheckup.slnx b/pgcheckup.slnx new file mode 100644 index 0000000..e3e7bd8 --- /dev/null +++ b/pgcheckup.slnx @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/Directory.Build.props b/src/Directory.Build.props new file mode 100644 index 0000000..1d13767 --- /dev/null +++ b/src/Directory.Build.props @@ -0,0 +1,9 @@ + + + + + true + + false + + diff --git a/src/Pgcheckup.Checks.Generator/CheckCompiler.cs b/src/Pgcheckup.Checks.Generator/CheckCompiler.cs new file mode 100644 index 0000000..0d96d88 --- /dev/null +++ b/src/Pgcheckup.Checks.Generator/CheckCompiler.cs @@ -0,0 +1,370 @@ +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text.RegularExpressions; + +namespace Pgcheckup.Checks.Generator; + +/// The files of one checks/<id>/ folder, as the compiler needs them. +/// The folder name, which must match the id in check.md. +/// The contents of check.md, or when the folder has none. +/// The contents of check.sql, or when the folder has none. +/// Every file in the folder, relative to it, with forward slashes. +public sealed class CheckFiles(string id, string? checkMd, string? checkSql, IReadOnlyCollection otherFiles) +{ + /// The folder name, which must match the id in check.md. + public string Id { get; } = id; + + /// The contents of check.md, or when the folder has none. + public string? CheckMd { get; } = checkMd; + + /// The contents of check.sql, or when the folder has none. + public string? CheckSql { get; } = checkSql; + + /// Every file in the folder, relative to it, with forward slashes, such as fixtures/fires.sql. + public IReadOnlyCollection OtherFiles { get; } = otherFiles; +} + +/// A problem with a check, located in one of its files. The generator reports it as build error PGC001. +/// The file, relative to the check's folder, such as check.md. +/// The 1-based line in that file. +/// What is wrong, in a sentence a check author can act on. +public sealed class CheckError(string file, int line, string message) +{ + /// The file, relative to the check's folder, such as check.md. + public string File { get; } = file; + + /// The 1-based line in that file. + public int Line { get; } = line; + + /// What is wrong, in a sentence a check author can act on. + public string Message { get; } = message; + + /// + public override string ToString() => $"{File}({Line}): {Message}"; +} + +/// A threshold that check.sql reads. +/// The name that check.sql reads as @name. +/// Its default value. +public sealed class CompiledThreshold(string name, ThresholdValue value) +{ + /// The name that check.sql reads as @name. + public string Name { get; } = name; + + /// Its default value. + public ThresholdValue Value { get; } = value; +} + +/// A check that passed every build-time rule, ready to be emitted into the binary. +public sealed class CompiledCheck +{ + /// The check's stable kebab-case id, equal to its folder name. + public string Id { get; set; } = ""; + + /// A short title for pgcheckup list. + public string Title { get; set; } = ""; + + /// One of . + public string Category { get; set; } = ""; + + /// The default severity of a finding, one of . + public string Severity { get; set; } = ""; + + /// The oldest Postgres major version the check runs on. + public int MinVersion { get; set; } + + /// The predefined roles the check needs, from . May be empty. + public IReadOnlyList Privileges { get; set; } = []; + + /// The providers where the check is skipped, from . + public IReadOnlyList SkipOn { get; set; } = []; + + /// The thresholds in the order of their $n parameters, so index 0 binds to $1. + public IReadOnlyList Thresholds { get; set; } = []; + + /// check.sql with thresholds rewritten to $n parameters. + public string Sql { get; set; } = ""; + + /// The parsed message template. + public IReadOnlyList Message { get; set; } = []; + + /// The parsed fix template. + public IReadOnlyList Fix { get; set; } = []; + + /// The Markdown body of check.md, which pgcheckup explain prints. + public string Note { get; set; } = ""; +} + +/// The outcome of . +/// The compiled check, or when there are errors. +/// Every problem found. Empty when the check compiled. +public sealed class CheckCompilation(CompiledCheck? check, IReadOnlyList errors) +{ + /// The compiled check, or when there are errors. + public CompiledCheck? Check { get; } = check; + + /// Every problem found. Empty when the check compiled. + public IReadOnlyList Errors { get; } = errors; +} + +/// Applies every build-time rule to one check folder. +public static class CheckCompiler +{ + /// The note and frontmatter file. + public const string CheckMd = "check.md"; + + /// The query file. + public const string CheckSqlFile = "check.sql"; + + /// The fixture the check must report on. + public const string FiresFixture = "fixtures/fires.sql"; + + /// The fixture the check must stay quiet on. + public const string HealthyFixture = "fixtures/healthy.sql"; + + /// The allowed values of category. + public static readonly string[] Categories = ["ids", "cleanup", "wal", "capacity"]; + + /// The allowed values of severity, from most to least severe. + public static readonly string[] Severities = ["critical", "warning", "info"]; + + /// The predefined roles a check may list under privileges. All are part of pg_monitor. + public static readonly string[] Privileges = ["pg_monitor", "pg_read_all_settings", "pg_read_all_stats", "pg_stat_scan_tables"]; + + /// The managed providers a check may list under skip_on. + public static readonly string[] Providers = ["rds", "aurora", "cloudsql", "azure", "supabase", "neon"]; + + /// The ## sections every check.md body must have. + public static readonly string[] Sections = ["What breaks", "Fix", "Seen in"]; + + private static readonly string[] RequiredKeys = ["id", "title", "category", "severity", "min_version", "privileges", "message", "fix"]; + private static readonly string[] OptionalKeys = ["skip_on", "thresholds"]; + + private static readonly Regex KebabCase = new("^[a-z0-9]+(-[a-z0-9]+)*$", RegexOptions.CultureInvariant); + private static readonly Regex SnakeCase = new("^[a-z][a-z0-9_]*$", RegexOptions.CultureInvariant); + + /// + /// Compiles one check folder: its frontmatter, templates, body sections, SQL and fixtures. + /// + /// The folder's files. + /// The compiled check, or every error found. Errors don't stop at the first one. + public static CheckCompilation Compile(CheckFiles files) + { + var errors = new List(); + if (files.CheckMd == null) + { + errors.Add(new CheckError(CheckMd, 1, $"The check folder {files.Id} has no check.md.")); + return new CheckCompilation(null, errors); + } + + var document = Frontmatter.Parse(files.CheckMd); + errors.AddRange(document.Errors.Select(e => new CheckError(CheckMd, e.Line, e.Message))); + if (document.Entries.Count == 0) + { + return new CheckCompilation(null, errors); + } + + var check = new CompiledCheck { Note = document.Body }; + var entries = document.Entries.GroupBy(e => e.Key).ToDictionary(g => g.Key, g => g.First()); + + foreach (var entry in document.Entries.Where(e => !RequiredKeys.Contains(e.Key) && !OptionalKeys.Contains(e.Key))) + { + errors.Add(new CheckError(CheckMd, entry.Line, $"Unknown key {entry.Key}. Use {string.Join(", ", RequiredKeys.Concat(OptionalKeys))}.")); + } + + foreach (var missing in RequiredKeys.Where(k => !entries.ContainsKey(k))) + { + errors.Add(new CheckError(CheckMd, 1, $"The frontmatter has no {missing}.")); + } + + string? Scalar(string key) + { + if (!entries.TryGetValue(key, out var entry)) + { + return null; + } + + if (entry.Kind != EntryKind.Scalar || entry.Scalar.Length == 0) + { + errors.Add(new CheckError(CheckMd, entry.Line, $"{key} must be a single value.")); + return null; + } + + return entry.Scalar; + } + + void OneOf(string key, string? value, string[] allowed, System.Action set) + { + if (value == null) + { + return; + } + + if (allowed.Contains(value)) + { + set(value); + } + else + { + errors.Add(new CheckError(CheckMd, entries[key].Line, $"{key} is {value}. Use {string.Join(", ", allowed)}.")); + } + } + + IReadOnlyList ListOf(string key, string[] allowed) + { + if (!entries.TryGetValue(key, out var entry)) + { + return []; + } + + if (entry.Kind != EntryKind.List) + { + errors.Add(new CheckError(CheckMd, entry.Line, $"{key} must be a list, such as [{allowed[0]}], or [].")); + return []; + } + + foreach (var unknown in entry.Items.Where(i => !allowed.Contains(i))) + { + errors.Add(new CheckError(CheckMd, entry.Line, $"{key} includes {unknown}. Use {string.Join(", ", allowed)}.")); + } + + return entry.Items; + } + + IReadOnlyList Template(string key) + { + var text = Scalar(key); + if (text == null) + { + return []; + } + + var parts = TemplateParser.Parse(text, out var templateErrors); + errors.AddRange(templateErrors.Select(e => new CheckError(CheckMd, entries[key].ValueLine, $"{key}: {e}"))); + return parts; + } + + var id = Scalar("id"); + if (id != null && !KebabCase.IsMatch(id)) + { + errors.Add(new CheckError(CheckMd, entries["id"].Line, $"The id {id} must be kebab-case, such as replication-slot-inactive.")); + } + else if (id != null && id != files.Id) + { + errors.Add(new CheckError(CheckMd, entries["id"].Line, $"The id {id} must match the folder name, {files.Id}.")); + } + else if (id != null) + { + check.Id = id; + } + + check.Title = Scalar("title") ?? ""; + OneOf("category", Scalar("category"), Categories, v => check.Category = v); + OneOf("severity", Scalar("severity"), Severities, v => check.Severity = v); + + var minVersion = Scalar("min_version"); + if (minVersion != null) + { + if (int.TryParse(minVersion, NumberStyles.None, CultureInfo.InvariantCulture, out var major) && major >= 10) + { + check.MinVersion = major; + } + else + { + errors.Add(new CheckError(CheckMd, entries["min_version"].Line, $"min_version is {minVersion}. Use a Postgres major version, 10 or later, such as 14.")); + } + } + + check.Privileges = ListOf("privileges", Privileges); + check.SkipOn = ListOf("skip_on", Providers); + check.Message = Template("message"); + check.Fix = Template("fix"); + + var thresholds = new List(); + var thresholdNames = new List(); + if (entries.TryGetValue("thresholds", out var thresholdEntry)) + { + if (thresholdEntry.Kind != EntryKind.Map) + { + errors.Add(new CheckError(CheckMd, thresholdEntry.Line, "thresholds must be a list of `name: value` lines, indented under it.")); + } + + foreach (var (name, text, line) in thresholdEntry.Map) + { + if (thresholdNames.Contains(name)) + { + errors.Add(new CheckError(CheckMd, line, $"The threshold {name} appears more than once.")); + continue; + } + + thresholdNames.Add(name); + if (!SnakeCase.IsMatch(name)) + { + errors.Add(new CheckError(CheckMd, line, $"The threshold name {name} must be snake_case, such as min_retained_wal.")); + } + else if (!ThresholdValue.TryParse(text, out var value, out var error)) + { + errors.Add(new CheckError(CheckMd, line, $"{name}: {error}")); + } + else + { + thresholds.Add(new CompiledThreshold(name, value)); + } + } + } + + CheckBody(document.Body, BodyStartLine(files.CheckMd), errors); + + if (files.CheckSql == null) + { + errors.Add(new CheckError(CheckSqlFile, 1, $"The check folder {files.Id} has no check.sql.")); + } + else + { + // Invalid thresholds were reported above; naming them all here avoids a second error. + var sql = CheckSql.Compile(files.CheckSql, thresholdNames); + errors.AddRange(sql.Errors.Select(e => new CheckError(CheckSqlFile, e.Line, e.Message))); + check.Sql = sql.Sql; + check.Thresholds = sql.Parameters.SelectMany(p => thresholds.Where(t => t.Name == p)).ToList(); + } + + foreach (var fixture in new[] { FiresFixture, HealthyFixture }.Where(f => !files.OtherFiles.Contains(f))) + { + errors.Add(new CheckError(fixture, 1, $"The check folder {files.Id} has no {fixture}. Every check needs one that fires and one that stays quiet.")); + } + + return new CheckCompilation(errors.Count == 0 ? check : null, errors); + } + + private static void CheckBody(string body, int bodyStartLine, List errors) + { + var lines = body.Split('\n'); + foreach (var section in Sections) + { + var heading = System.Array.FindIndex(lines, l => l.TrimEnd() == "## " + section); + if (heading < 0) + { + errors.Add(new CheckError(CheckMd, bodyStartLine, $"check.md has no ## {section} section.")); + continue; + } + + if (section == "Seen in") + { + var content = lines.Skip(heading + 1).TakeWhile(l => !l.StartsWith("## ", System.StringComparison.Ordinal)); + if (!content.Any(l => l.Contains("https://"))) + { + errors.Add(new CheckError(CheckMd, bodyStartLine + heading, "## Seen in needs at least one https:// link to a public incident or the Postgres docs.")); + } + } + } + } + + private static int BodyStartLine(string markdown) + { + var lines = markdown.Replace("\r\n", "\n").Split('\n'); + var close = System.Array.FindIndex(lines, 1, l => l.TrimEnd() == "---"); + var first = System.Array.FindIndex(lines, close + 1, l => l.Trim().Length > 0); + return first < 0 ? close + 1 : first + 1; + } +} diff --git a/src/Pgcheckup.Checks.Generator/CheckGenerator.cs b/src/Pgcheckup.Checks.Generator/CheckGenerator.cs new file mode 100644 index 0000000..2233637 --- /dev/null +++ b/src/Pgcheckup.Checks.Generator/CheckGenerator.cs @@ -0,0 +1,142 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Globalization; +using System.Linq; +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Text; + +namespace Pgcheckup.Checks.Generator; + +/// +/// Compiles every folder under checks/ into the CheckCatalog class, so the binary +/// carries its checks and parses nothing at runtime. +/// +/// +/// The checks folder comes from the PgcheckupChecksDir MSBuild property, and its files +/// from AdditionalFiles. Every becomes build error PGC001 on the +/// file and line it names. +/// +[Generator(LanguageNames.CSharp)] +public sealed class CheckGenerator : IIncrementalGenerator +{ + private const string ChecksDirProperty = "build_property.PgcheckupChecksDir"; + + private static readonly DiagnosticDescriptor InvalidCheck = new( + "PGC001", "Invalid check", "{0}", "pgcheckup", DiagnosticSeverity.Error, isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor NoChecksDir = new( + "PGC002", "Checks folder not set", "{0}", "pgcheckup", DiagnosticSeverity.Error, isEnabledByDefault: true); + + /// + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var root = context.AnalyzerConfigOptionsProvider.Select((options, _) => + options.GlobalOptions.TryGetValue(ChecksDirProperty, out var dir) && !string.IsNullOrWhiteSpace(dir) + ? Normalize(dir).TrimEnd('/') + "/" + : null); + + var files = context.AdditionalTextsProvider + .Combine(root) + .Where(pair => pair.Right != null && Normalize(pair.Left.Path).StartsWith(pair.Right, StringComparison.OrdinalIgnoreCase)) + .Select((pair, cancellationToken) => + { + var relative = Normalize(pair.Left.Path).Substring(pair.Right!.Length); + var needsText = relative.EndsWith("/" + CheckCompiler.CheckMd, StringComparison.Ordinal) + || relative.EndsWith("/" + CheckCompiler.CheckSqlFile, StringComparison.Ordinal); + return new CheckFile(pair.Left.Path, relative, needsText ? pair.Left.GetText(cancellationToken)?.ToString() : null); + }) + .Collect(); + + context.RegisterSourceOutput(files.Combine(root), static (spc, input) => Emit(spc, input.Left, input.Right)); + } + + private static void Emit(SourceProductionContext context, ImmutableArray files, string? root) + { + if (root == null) + { + context.ReportDiagnostic(Diagnostic.Create(NoChecksDir, Location.None, "Set the PgcheckupChecksDir MSBuild property to the checks folder.")); + return; + } + + var compiled = new List(); + foreach (var folder in files.Where(f => f.Relative.Contains("/")).GroupBy(f => f.Relative.Substring(0, f.Relative.IndexOf('/'))).OrderBy(g => g.Key, StringComparer.Ordinal)) + { + string? Text(string name) => folder.FirstOrDefault(f => f.Relative == folder.Key + "/" + name)?.Text; + + var others = folder.Select(f => f.Relative.Substring(folder.Key.Length + 1)).ToList(); + var compilation = CheckCompiler.Compile(new CheckFiles(folder.Key, Text(CheckCompiler.CheckMd), Text(CheckCompiler.CheckSqlFile), others)); + foreach (var error in compilation.Errors) + { + var path = root + folder.Key + "/" + error.File; + var position = new LinePosition(Math.Max(0, error.Line - 1), 0); + var location = Location.Create(path, default, new LinePositionSpan(position, position)); + context.ReportDiagnostic(Diagnostic.Create(InvalidCheck, location, $"{folder.Key}: {error.Message}")); + } + + if (compilation.Check != null) + { + compiled.Add(compilation.Check); + } + } + + context.AddSource("CheckCatalog.g.cs", Source(compiled)); + } + + private static string Source(IReadOnlyList checks) + { + const string ns = "global::Pgcheckup.Checks."; + var code = new StringBuilder(); + code.AppendLine("// "); + code.AppendLine("#nullable enable"); + code.AppendLine("namespace Pgcheckup.Checks;"); + code.AppendLine(); + code.AppendLine("/// The checks compiled from the checks folder at build time."); + code.AppendLine("internal static partial class CheckCatalog"); + code.AppendLine("{"); + code.AppendLine(" /// Every check, ordered by id."); + code.AppendLine($" public static global::System.Collections.Generic.IReadOnlyList<{ns}CheckDefinition> All {{ get; }} = new {ns}CheckDefinition[]"); + code.AppendLine(" {"); + foreach (var check in checks) + { + code.AppendLine($" new {ns}CheckDefinition("); + code.AppendLine($" Id: {Literal(check.Id)},"); + code.AppendLine($" Title: {Literal(check.Title)},"); + code.AppendLine($" Category: {Literal(check.Category)},"); + code.AppendLine($" Severity: {ns}Severity.{Pascal(check.Severity)},"); + code.AppendLine($" MinVersion: {check.MinVersion.ToString(CultureInfo.InvariantCulture)},"); + code.AppendLine($" Privileges: new string[] {{ {string.Join(", ", check.Privileges.Select(Literal))} }},"); + code.AppendLine($" SkipOn: new string[] {{ {string.Join(", ", check.SkipOn.Select(Literal))} }},"); + code.AppendLine($" Thresholds: new {ns}Threshold[] {{ {string.Join(", ", check.Thresholds.Select(t => $"new {ns}Threshold({Literal(t.Name)}, {ns}ThresholdKind.{t.Value.Kind}, {t.Value.Value.ToString(CultureInfo.InvariantCulture)}m, {Literal(t.Value.Text)})"))} }},"); + code.AppendLine($" Sql: {Literal(check.Sql)},"); + code.AppendLine($" Message: new {ns}Template({Parts(check.Message)}),"); + code.AppendLine($" Fix: new {ns}Template({Parts(check.Fix)}),"); + code.AppendLine($" Note: {Literal(check.Note)}),"); + } + + code.AppendLine(" };"); + code.AppendLine("}"); + return code.ToString(); + + static string Parts(IEnumerable parts) => + $"new {ns}TemplatePart[] {{ {string.Join(", ", parts.Select(Part))} }}"; + + static string Part(TemplatePart part) => part switch + { + TextPart t => $"new {ns}TextPart({Literal(t.Text)})", + ValuePart v => $"new {ns}ValuePart({Literal(v.Name)}, {ns}ValueFormat.{(v.Format == null ? "Default" : Pascal(v.Format))})", + SectionPart s => $"new {ns}SectionPart({Parts(s.Parts)})", + _ => throw new InvalidOperationException(), + }; + } + + private static string Literal(string value) => SymbolDisplay.FormatLiteral(value, quote: true); + + private static string Pascal(string value) => char.ToUpperInvariant(value[0]) + value.Substring(1); + + private static string Normalize(string path) => path.Replace('\\', '/'); + + private sealed record CheckFile(string Path, string Relative, string? Text); +} diff --git a/src/Pgcheckup.Checks.Generator/CheckSql.cs b/src/Pgcheckup.Checks.Generator/CheckSql.cs new file mode 100644 index 0000000..602122b --- /dev/null +++ b/src/Pgcheckup.Checks.Generator/CheckSql.cs @@ -0,0 +1,145 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace Pgcheckup.Checks.Generator; + +/// The outcome of compiling a check.sql with . +public sealed class SqlResult +{ + /// Creates a result. + /// The SQL with @name rewritten to $n and a trailing semicolon removed. + /// The threshold names, where the name at index 0 binds to $1. + /// Everything wrong with the SQL. Empty when it compiled. + public SqlResult(string sql, IReadOnlyList parameters, IReadOnlyList errors) + { + Sql = sql; + Parameters = parameters; + Errors = errors; + } + + /// The SQL with @name rewritten to $n and a trailing semicolon removed. + public string Sql { get; } + + /// The threshold names in order of first use. The name at index 0 binds to $1. + public IReadOnlyList Parameters { get; } + + /// Everything wrong with the SQL. Empty when it compiled. + public IReadOnlyList Errors { get; } +} + +/// Checks a check.sql against the read-only rules and prepares it to run. +public static class CheckSql +{ + // A READ ONLY transaction blocks DDL, DML, nextval and row locks, but not these. They signal + // or reconfigure the server, take locks, write WAL, consume transaction IDs, read server + // files or large objects, or run SQL passed in as a string, which this check can't see. + private static readonly HashSet DeniedFunctions = new(StringComparer.Ordinal) + { + "ts_stat", "ts_rewrite", "loread", "lowrite", "pg_logical_emit_message", "pg_stat_statements_reset", + "nextval", "setval", "set_config", "txid_current", "pg_current_xact_id", + "pg_terminate_backend", "pg_cancel_backend", "pg_reload_conf", "pg_rotate_logfile", + "pg_switch_wal", "pg_promote", "pg_notify", "pg_export_snapshot", + "pg_logical_slot_get_changes", "pg_logical_slot_get_binary_changes", "pg_replication_slot_advance", + "pg_sync_replication_slots", "pg_log_standby_snapshot", "pg_log_backend_memory_contexts", + "pg_import_system_collations", "pg_create_restore_point", + "pg_backup_start", "pg_backup_stop", "pg_start_backup", "pg_stop_backup", + "pg_read_file", "pg_read_binary_file", + }; + + private static readonly string[] DeniedPrefixes = + [ + "pg_stat_reset", "pg_create_", "pg_drop_", "pg_copy_", "pg_advisory_", "pg_try_advisory_", + "pg_file_", "pg_wal_replay_", "pg_replication_origin_", "dblink", "lo_", + "query_to_xml", "table_to_xml", "cursor_to_xml", "schema_to_xml", "database_to_xml", + ]; + + /// + /// Compiles a check.sql. It must be one statement that starts with SELECT or WITH, + /// read every declared threshold and nothing else, and call no function on the deny list. + /// + /// The contents of check.sql. + /// The threshold names declared in check.md. + /// The rewritten SQL, its parameter order and every error found. + public static SqlResult Compile(string sql, IReadOnlyCollection thresholds) + { + var errors = new List(); + var tokens = SqlTokenizer.Tokenize(sql, errors); + + var first = tokens.FirstOrDefault(); + if (first.Kind != TokenKind.Word || !(IsKeyword(first, "select") || IsKeyword(first, "with"))) + { + errors.Add(new SourceError(tokens.Count == 0 ? 1 : SqlTokenizer.LineOf(sql, first.Start), "check.sql must be one statement that starts with SELECT or WITH.")); + } + + var parameters = new List(); + var output = new StringBuilder(sql.Length); + var copied = 0; + for (var i = 0; i < tokens.Count; i++) + { + var token = tokens[i]; + switch (token.Kind) + { + case TokenKind.Semicolon when i != tokens.Count - 1: + errors.Add(new SourceError(SqlTokenizer.LineOf(sql, token.Start), "check.sql must be one statement.")); + break; + + case TokenKind.Semicolon: + output.Append(sql, copied, token.Start - copied); + copied = token.Start + 1; + break; + + case TokenKind.Word or TokenKind.QuotedIdentifier when i + 1 < tokens.Count && tokens[i + 1].Text == "(": + var name = token.Text.ToLowerInvariant(); + if (DeniedFunctions.Contains(name) || DeniedPrefixes.Any(p => name.StartsWith(p, StringComparison.Ordinal))) + { + errors.Add(new SourceError(SqlTokenizer.LineOf(sql, token.Start), $"check.sql calls {name}(), which can have side effects or run SQL of its own that a READ ONLY transaction doesn't stop.")); + } + + break; + + // U&"..." spells an identifier with escapes, which would hide a denied name. + case TokenKind.Other when token.Text == "&" && i > 0 && i + 1 < tokens.Count + && tokens[i - 1] is { Kind: TokenKind.Word, Text: "U" or "u" } unicode + && unicode.Start + 1 == token.Start + && tokens[i + 1].Kind == TokenKind.QuotedIdentifier: + errors.Add(new SourceError(SqlTokenizer.LineOf(sql, token.Start), "check.sql uses a U&\"...\" identifier. Write the name plainly.")); + break; + + case TokenKind.PositionalParameter: + errors.Add(new SourceError(SqlTokenizer.LineOf(sql, token.Start), $"check.sql uses {token.Text}. Read thresholds as @name parameters.")); + break; + + case TokenKind.Parameter: + if (!thresholds.Contains(token.Text)) + { + errors.Add(new SourceError(SqlTokenizer.LineOf(sql, token.Start), $"check.sql reads @{token.Text}, which isn't a threshold in check.md.")); + break; + } + + if (!parameters.Contains(token.Text)) + { + parameters.Add(token.Text); + } + + output.Append(sql, copied, token.Start - copied); + output.Append('$').Append(parameters.IndexOf(token.Text) + 1); + copied = token.Start + token.Length; + break; + } + } + + output.Append(sql, copied, sql.Length - copied); + + foreach (var unused in thresholds.Where(t => !parameters.Contains(t))) + { + errors.Add(new SourceError(1, $"Threshold {unused} is never read by check.sql.")); + } + + return new SqlResult(output.ToString(), parameters, errors); + } + + private static bool IsKeyword(Token token, string keyword) => + string.Equals(token.Text, keyword, StringComparison.OrdinalIgnoreCase); +} diff --git a/src/Pgcheckup.Checks.Generator/Frontmatter.cs b/src/Pgcheckup.Checks.Generator/Frontmatter.cs new file mode 100644 index 0000000..1f14165 --- /dev/null +++ b/src/Pgcheckup.Checks.Generator/Frontmatter.cs @@ -0,0 +1,240 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; + +namespace Pgcheckup.Checks.Generator; + +/// The shape of a frontmatter value. +public enum EntryKind +{ + /// One value: plain, quoted, or a | or > block. + Scalar, + + /// A [a, b] list on one line. + List, + + /// Indented name: value lines under an empty key. + Map, +} + +/// One top-level key of a check's frontmatter and its value. +/// The key. +/// The 1-based line the key is on. +/// The shape of its value. +public sealed class FrontmatterEntry(string key, int line, EntryKind kind) +{ + /// The key. + public string Key { get; } = key; + + /// The 1-based line the key is on. + public int Line { get; } = line; + + /// The line where the value starts: the key's line, or the next one for a block scalar. + public int ValueLine { get; set; } = line; + + /// The shape of the value, which says which of , and is set. + public EntryKind Kind { get; } = kind; + + /// The value of a entry, unquoted, with a block scalar joined and trimmed. + public string Scalar { get; set; } = ""; + + /// The items of a entry, unquoted. + public List Items { get; } = []; + + /// The entries of a entry, in order and with their lines. Duplicates are kept. + public List<(string Key, string Value, int Line)> Map { get; } = []; +} + +/// A check.md split into its frontmatter entries and its Markdown body. +public sealed class FrontmatterDocument +{ + /// The top-level entries in order. A duplicate key is kept and reported in . + public List Entries { get; } = []; + + /// The Markdown after the closing --- line, trimmed. + public string Body { get; set; } = ""; + + /// Everything that isn't valid frontmatter. Empty when the frontmatter parsed. + public List Errors { get; } = []; +} + +/// +/// Reads check.md frontmatter: a strict subset of YAML with top-level key: value, +/// [a, b] lists, one level of nested name: value maps, quoted scalars, and +/// | or > block scalars. +/// +/// +/// Anything outside the subset is an error with a line number, rather than something a full +/// YAML parser would read differently. +/// +public static class Frontmatter +{ + private static readonly Regex TopLevel = new(@"^([A-Za-z_][A-Za-z0-9_]*):(.*)$", RegexOptions.CultureInvariant); + private static readonly Regex Nested = new(@"^\s+([^:\s]+):(.*)$", RegexOptions.CultureInvariant); + + /// Parses a check.md. + /// The file's contents. CRLF line endings are accepted. + /// + /// The entries, body and errors. When the file doesn't start with a frontmatter block, + /// the only error is on line 1 and there are no entries. + /// + public static FrontmatterDocument Parse(string markdown) + { + var document = new FrontmatterDocument(); + var lines = markdown.Replace("\r\n", "\n").Split('\n'); + if (lines[0].TrimEnd() != "---") + { + document.Errors.Add(new SourceError(1, "check.md must start with frontmatter between --- lines.")); + return document; + } + + var close = System.Array.FindIndex(lines, 1, l => l.TrimEnd() == "---"); + if (close < 0) + { + document.Errors.Add(new SourceError(1, "The frontmatter's closing --- line is missing.")); + return document; + } + + document.Body = string.Join("\n", lines.Skip(close + 1)).Trim(); + + var i = 1; + while (i < close) + { + var line = lines[i]; + var lineNumber = i + 1; + i++; + + if (IsBlankOrComment(line)) + { + continue; + } + + var match = TopLevel.Match(line); + if (!match.Success) + { + document.Errors.Add(new SourceError(lineNumber, $"Expected `key: value`, not `{line.Trim()}`.")); + continue; + } + + var key = match.Groups[1].Value; + var rest = match.Groups[2].Value.Trim(); + if (document.Entries.Any(e => e.Key == key)) + { + document.Errors.Add(new SourceError(lineNumber, $"{key} appears more than once.")); + } + + FrontmatterEntry entry; + if (rest.Length == 0) + { + entry = new FrontmatterEntry(key, lineNumber, EntryKind.Map); + while (i < close && (IsBlankOrComment(lines[i]) || char.IsWhiteSpace(lines[i][0]))) + { + if (!IsBlankOrComment(lines[i])) + { + var nested = Nested.Match(lines[i]); + if (nested.Success) + { + entry.Map.Add((nested.Groups[1].Value, Unquote(nested.Groups[2].Value.Trim()), i + 1)); + } + else + { + document.Errors.Add(new SourceError(i + 1, $"Expected `name: value` under {key}.")); + } + } + + i++; + } + } + else if (rest is "|" or "|-" or "|+" or ">" or ">-" or ">+") + { + entry = new FrontmatterEntry(key, lineNumber, EntryKind.Scalar) { ValueLine = lineNumber + 1 }; + var block = new List(); + while (i < close && (lines[i].Trim().Length == 0 || char.IsWhiteSpace(lines[i][0]))) + { + block.Add(lines[i]); + i++; + } + + entry.Scalar = BlockScalar(block, folded: rest[0] == '>'); + } + else if (rest.StartsWith("[")) + { + entry = new FrontmatterEntry(key, lineNumber, EntryKind.List); + if (!rest.EndsWith("]")) + { + document.Errors.Add(new SourceError(lineNumber, $"The list for {key} must end with ] on the same line.")); + } + else + { + var inner = rest.Substring(1, rest.Length - 2); + entry.Items.AddRange(inner.Split(',').Select(s => Unquote(s.Trim())).Where(s => s.Length > 0)); + } + } + else + { + entry = new FrontmatterEntry(key, lineNumber, EntryKind.Scalar) { Scalar = Unquote(rest) }; + } + + document.Entries.Add(entry); + } + + return document; + } + + private static bool IsBlankOrComment(string line) + { + var trimmed = line.Trim(); + return trimmed.Length == 0 || trimmed[0] == '#'; + } + + private static string BlockScalar(List lines, bool folded) + { + var indent = lines.Where(l => l.Trim().Length > 0).Select(l => l.Length - l.TrimStart().Length).DefaultIfEmpty(0).Min(); + var content = lines.Select(l => l.Length >= indent ? l.Substring(indent).TrimEnd() : "").ToList(); + if (!folded) + { + return string.Join("\n", content).Trim('\n'); + } + + var text = new StringBuilder(); + foreach (var line in content) + { + if (line.Length == 0) + { + text.Append('\n'); + } + else + { + if (text.Length > 0 && text[text.Length - 1] != '\n') + { + text.Append(' '); + } + + text.Append(line); + } + } + + return text.ToString().Trim('\n'); + } + + private static string Unquote(string value) + { + if (value.Length > 0 && (value[0] == '"' || value[0] == '\'')) + { + var quote = value[0]; + var end = value.LastIndexOf(quote); + var after = value.Substring(end + 1).Trim(); + if (end > 0 && (after.Length == 0 || after[0] == '#')) + { + var inner = value.Substring(1, end - 1); + return quote == '"' + ? inner.Replace("\\\"", "\"").Replace("\\\\", "\\") + : inner.Replace("''", "'"); + } + } + + var comment = value.IndexOf(" #", System.StringComparison.Ordinal); + return comment >= 0 ? value.Substring(0, comment).TrimEnd() : value; + } +} diff --git a/src/Pgcheckup.Checks.Generator/IsExternalInit.cs b/src/Pgcheckup.Checks.Generator/IsExternalInit.cs new file mode 100644 index 0000000..70b0073 --- /dev/null +++ b/src/Pgcheckup.Checks.Generator/IsExternalInit.cs @@ -0,0 +1,6 @@ +namespace System.Runtime.CompilerServices; + +// netstandard2.0 lacks this type, which records and init accessors need. +internal static class IsExternalInit +{ +} diff --git a/src/Pgcheckup.Checks.Generator/Pgcheckup.Checks.Generator.csproj b/src/Pgcheckup.Checks.Generator/Pgcheckup.Checks.Generator.csproj new file mode 100644 index 0000000..b121d63 --- /dev/null +++ b/src/Pgcheckup.Checks.Generator/Pgcheckup.Checks.Generator.csproj @@ -0,0 +1,14 @@ + + + netstandard2.0 + disable + true + true + + $(NoWarn);RS2008 + + + + + + diff --git a/src/Pgcheckup.Checks.Generator/SqlTokenizer.cs b/src/Pgcheckup.Checks.Generator/SqlTokenizer.cs new file mode 100644 index 0000000..ecd3539 --- /dev/null +++ b/src/Pgcheckup.Checks.Generator/SqlTokenizer.cs @@ -0,0 +1,288 @@ +using System.Collections.Generic; + +namespace Pgcheckup.Checks.Generator; + +/// A problem found in one file of a check, with the line it is on. +public sealed class SourceError +{ + /// Creates an error for a line of the file being read. + /// The 1-based line number. + /// What is wrong, in a sentence a check author can act on. + public SourceError(int line, string message) + { + Line = line; + Message = message; + } + + /// The 1-based line number the error is on. + public int Line { get; } + + /// What is wrong, in a sentence a check author can act on. + public string Message { get; } + + /// + public override string ToString() => $"line {Line}: {Message}"; +} + +/// The kinds of token tells apart. +public enum TokenKind +{ + /// A keyword or unquoted identifier, such as SELECT or pg_stat_activity. + Word, + + /// A double-quoted identifier. holds the name without quotes. + QuotedIdentifier, + + /// A threshold reference such as @min_age. holds the name without @. + Parameter, + + /// A positional parameter such as $1, which check.sql must not use. + PositionalParameter, + + /// A ; outside any literal or comment. + Semicolon, + + /// Any other single character: operators, punctuation and digits. + Other, +} + +/// One token of a SQL text, with its position in that text. +public readonly struct Token +{ + /// Creates a token. + /// What kind of token it is. + /// The 0-based offset of its first character in the SQL text. + /// How many characters of the SQL text it covers. + /// Its text, unquoted for identifiers and without @ for parameters. + public Token(TokenKind kind, int start, int length, string text) + { + Kind = kind; + Start = start; + Length = length; + Text = text; + } + + /// What kind of token it is. + public TokenKind Kind { get; } + + /// The 0-based offset of its first character in the SQL text. + public int Start { get; } + + /// How many characters of the SQL text it covers, including quotes or @. + public int Length { get; } + + /// Its text, unquoted for identifiers and without @ for parameters. + public string Text { get; } +} + +/// +/// Just enough of Postgres's lexer to tell code from comments, string literals and quoted +/// identifiers, so that checks on statements and function names can't be fooled by either. +/// +public static class SqlTokenizer +{ + /// Splits SQL into tokens, skipping whitespace, comments and string literals. + /// The SQL text, such as a check.sql or a fixture. + /// Receives a for each comment, string or identifier that is never closed. + /// + /// The tokens in order. String literals (standard, E'' and dollar-quoted) and comments + /// produce no tokens. + /// + public static List Tokenize(string sql, List errors) + { + var tokens = new List(); + var i = 0; + while (i < sql.Length) + { + var c = sql[i]; + var next = i + 1 < sql.Length ? sql[i + 1] : '\0'; + var start = i; + + if (char.IsWhiteSpace(c)) + { + i++; + } + else if (c == '-' && next == '-') + { + while (i < sql.Length && sql[i] != '\n') + { + i++; + } + } + else if (c == '/' && next == '*') + { + i = SkipBlockComment(sql, i, errors); + } + else if (c == '\'') + { + var escapes = tokens.Count > 0 && IsEscapeStringPrefix(sql, tokens[tokens.Count - 1]); + i = SkipQuoted(sql, i, '\'', escapes, errors, "string literal"); + } + else if (c == '"') + { + i = SkipQuoted(sql, i, '"', false, errors, "quoted identifier"); + var content = sql.Substring(start + 1, System.Math.Max(0, i - start - 2)).Replace("\"\"", "\""); + tokens.Add(new Token(TokenKind.QuotedIdentifier, start, i - start, content)); + } + else if (c == '$' && char.IsDigit(next)) + { + i++; + while (i < sql.Length && char.IsDigit(sql[i])) + { + i++; + } + + tokens.Add(new Token(TokenKind.PositionalParameter, start, i - start, sql.Substring(start, i - start))); + } + else if (c == '$' && TryReadDollarTag(sql, i, out var tag)) + { + var end = sql.IndexOf(tag, i + tag.Length, System.StringComparison.Ordinal); + if (end < 0) + { + errors.Add(new SourceError(LineOf(sql, start), $"The {tag} string is never closed.")); + i = sql.Length; + } + else + { + i = end + tag.Length; + } + } + else if (IsIdentifierStart(c)) + { + while (i < sql.Length && IsIdentifierPart(sql[i])) + { + i++; + } + + tokens.Add(new Token(TokenKind.Word, start, i - start, sql.Substring(start, i - start))); + } + else if (c == '@' && IsIdentifierStart(next)) + { + i++; + while (i < sql.Length && IsIdentifierPart(sql[i]) && sql[i] != '$') + { + i++; + } + + tokens.Add(new Token(TokenKind.Parameter, start, i - start, sql.Substring(start + 1, i - start - 1))); + } + else if (c == ';') + { + i++; + tokens.Add(new Token(TokenKind.Semicolon, start, 1, ";")); + } + else + { + i++; + tokens.Add(new Token(TokenKind.Other, start, 1, c.ToString())); + } + } + + return tokens; + } + + private static bool IsEscapeStringPrefix(string sql, Token previous) => + previous.Kind == TokenKind.Word + && previous.Length == 1 + && (previous.Text == "E" || previous.Text == "e") + && previous.Start + 1 < sql.Length + && sql[previous.Start + 1] == '\''; + + private static int SkipBlockComment(string sql, int i, List errors) + { + var start = i; + var depth = 0; + while (i < sql.Length) + { + if (sql[i] == '/' && i + 1 < sql.Length && sql[i + 1] == '*') + { + depth++; + i += 2; + } + else if (sql[i] == '*' && i + 1 < sql.Length && sql[i + 1] == '/') + { + depth--; + i += 2; + if (depth == 0) + { + return i; + } + } + else + { + i++; + } + } + + errors.Add(new SourceError(LineOf(sql, start), "A /* comment is never closed.")); + return i; + } + + private static int SkipQuoted(string sql, int i, char quote, bool backslashEscapes, List errors, string what) + { + var start = i; + i++; + while (i < sql.Length) + { + if (backslashEscapes && sql[i] == '\\') + { + i += 2; + } + else if (sql[i] == quote && i + 1 < sql.Length && sql[i + 1] == quote) + { + i += 2; + } + else if (sql[i] == quote) + { + return i + 1; + } + else + { + i++; + } + } + + errors.Add(new SourceError(LineOf(sql, start), $"A {what} is never closed.")); + return sql.Length; + } + + private static bool TryReadDollarTag(string sql, int i, out string tag) + { + var j = i + 1; + if (j < sql.Length && IsIdentifierStart(sql[j])) + { + while (j < sql.Length && IsIdentifierPart(sql[j]) && sql[j] != '$') + { + j++; + } + } + + if (j < sql.Length && sql[j] == '$') + { + tag = sql.Substring(i, j - i + 1); + return true; + } + + tag = ""; + return false; + } + + private static bool IsIdentifierStart(char c) => char.IsLetter(c) || c == '_' || c > 127; + + private static bool IsIdentifierPart(char c) => IsIdentifierStart(c) || char.IsDigit(c) || c == '$'; + + /// The 1-based line that a 0-based offset falls on. + internal static int LineOf(string text, int position) + { + var line = 1; + for (var i = 0; i < position && i < text.Length; i++) + { + if (text[i] == '\n') + { + line++; + } + } + + return line; + } +} diff --git a/src/Pgcheckup.Checks.Generator/TemplateParser.cs b/src/Pgcheckup.Checks.Generator/TemplateParser.cs new file mode 100644 index 0000000..13fa5be --- /dev/null +++ b/src/Pgcheckup.Checks.Generator/TemplateParser.cs @@ -0,0 +1,173 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; + +namespace Pgcheckup.Checks.Generator; + +/// One piece of a parsed message or fix template. +public abstract class TemplatePart +{ +} + +/// Literal text, copied as written. +/// The text, with doubled braces and brackets already reduced to one. +public sealed class TextPart(string text) : TemplatePart +{ + /// The text, with doubled braces and brackets already reduced to one. + public string Text { get; } = text; +} + +/// A {name} or {name:format} placeholder for a column of the check's query. +/// The column name. +/// One of , or to format by the value's type. +public sealed class ValuePart(string name, string? format) : TemplatePart +{ + /// The column name. + public string Name { get; } = name; + + /// One of , or to format by the value's type. + public string? Format { get; } = format; +} + +/// A [ … ] section, left out of the message when any value inside it is NULL. +/// Its text and values. Sections don't nest. +public sealed class SectionPart(IReadOnlyList parts) : TemplatePart +{ + /// Its text and values. Sections don't nest. + public IReadOnlyList Parts { get; } = parts; +} + +/// +/// Parses message and fix templates: {name} or {name:format} inserts a value, +/// [ … ] is left out when a value inside it is NULL, and doubled braces or brackets +/// stand for themselves. +/// +public static class TemplateParser +{ + /// The formats a placeholder may name: bytes prints "48 GB" and count prints "1.61 billion". + public static readonly string[] Formats = ["bytes", "count"]; + + private static readonly Regex Placeholder = new(@"^([a-z_][a-z0-9_]*)(?::([a-z]+))?$", RegexOptions.CultureInvariant); + + /// Parses a template from check.md. + /// The template text. + /// Receives every problem found. Empty when the template is valid. + /// The parts in order. Only trust them when is empty. + public static IReadOnlyList Parse(string template, out List errors) + { + errors = []; + if (template.Trim().Length == 0) + { + errors.Add("The template is empty."); + return []; + } + + var top = new List(); + List? section = null; + var text = new StringBuilder(); + + void FlushText() + { + if (text.Length > 0) + { + (section ?? top).Add(new TextPart(text.ToString())); + text.Clear(); + } + } + + var i = 0; + while (i < template.Length) + { + var c = template[i]; + var next = i + 1 < template.Length ? template[i + 1] : '\0'; + + if ((c == '{' || c == '}' || c == '[' || c == ']') && next == c) + { + text.Append(c); + i += 2; + continue; + } + + switch (c) + { + case '{': + var close = template.IndexOf('}', i + 1); + if (close < 0) + { + errors.Add($"A {{ at position {i + 1} is never closed."); + return top; + } + + var inner = template.Substring(i + 1, close - i - 1); + var match = Placeholder.Match(inner); + if (!match.Success) + { + errors.Add($"{{{inner}}} isn't a value. Write {{name}} or {{name:format}} with a lowercase column name."); + } + else if (match.Groups[2].Success && !Formats.Contains(match.Groups[2].Value)) + { + errors.Add($"{{{inner}}} uses the unknown format {match.Groups[2].Value}. Use {string.Join(" or ", Formats)}."); + } + else + { + FlushText(); + (section ?? top).Add(new ValuePart(match.Groups[1].Value, match.Groups[2].Success ? match.Groups[2].Value : null)); + } + + i = close + 1; + break; + + case '}': + errors.Add($"A }} at position {i + 1} has no matching {{. Write }}}} for a literal brace."); + i++; + break; + + case '[': + if (section != null) + { + errors.Add($"A [ at position {i + 1} is inside another [ … ] section."); + return top; + } + + FlushText(); + section = []; + i++; + break; + + case ']': + if (section == null) + { + errors.Add($"A ] at position {i + 1} has no matching [. Write ]] for a literal bracket."); + i++; + break; + } + + FlushText(); + if (!section.OfType().Any()) + { + errors.Add("A [ … ] section has no value in it, so it would never be left out."); + } + + top.Add(new SectionPart(section)); + section = null; + i++; + break; + + default: + text.Append(c); + i++; + break; + } + } + + if (section != null) + { + errors.Add("A [ … ] section is never closed."); + return top; + } + + FlushText(); + return top; + } +} diff --git a/src/Pgcheckup.Checks.Generator/ThresholdValue.cs b/src/Pgcheckup.Checks.Generator/ThresholdValue.cs new file mode 100644 index 0000000..c10a830 --- /dev/null +++ b/src/Pgcheckup.Checks.Generator/ThresholdValue.cs @@ -0,0 +1,135 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text.RegularExpressions; + +namespace Pgcheckup.Checks.Generator; + +/// What a threshold measures, which decides the parameter type it binds as. +public enum ThresholdKind +{ + /// A size in bytes, written with B, kB, MB, GB or TB. Binds as bigint. + Bytes, + + /// A length of time, written with us, ms, s, min, h or d. Binds as interval. + Duration, + + /// A whole number without a unit. Binds as bigint. + Integer, + + /// A number with a decimal point and no unit. Binds as numeric. + Number, +} + +/// +/// A threshold from a check's frontmatter, such as 1GB or 30min, parsed at build time. +/// +/// +/// Bytes and durations use Postgres's own units (as in postgresql.conf), so a threshold reads the +/// same as the setting it is usually compared with. Units are case-sensitive, and 1 kB is 1024 bytes. +/// +public readonly struct ThresholdValue +{ + private static readonly Regex Pattern = new(@"^(\d+(?:\.\d+)?)\s*([A-Za-z]*)$", RegexOptions.CultureInvariant); + + private static readonly Dictionary Units = new(StringComparer.Ordinal) + { + ["B"] = (ThresholdKind.Bytes, 1m), + ["kB"] = (ThresholdKind.Bytes, 1024m), + ["MB"] = (ThresholdKind.Bytes, 1024m * 1024), + ["GB"] = (ThresholdKind.Bytes, 1024m * 1024 * 1024), + ["TB"] = (ThresholdKind.Bytes, 1024m * 1024 * 1024 * 1024), + ["us"] = (ThresholdKind.Duration, 1m), + ["ms"] = (ThresholdKind.Duration, 1_000m), + ["s"] = (ThresholdKind.Duration, 1_000_000m), + ["min"] = (ThresholdKind.Duration, 60_000_000m), + ["h"] = (ThresholdKind.Duration, 3_600_000_000m), + ["d"] = (ThresholdKind.Duration, 86_400_000_000m), + }; + + /// Creates a threshold that has already been parsed. + /// What the threshold measures. + /// The value in bytes, microseconds, or as written for plain numbers. + /// The threshold as written in check.md, for messages. + public ThresholdValue(ThresholdKind kind, decimal value, string text) + { + Kind = kind; + Value = value; + Text = text; + } + + /// What the threshold measures. + public ThresholdKind Kind { get; } + + /// + /// The value in the kind's base unit: bytes for , microseconds + /// for . Always fits in a 64-bit integer. + /// + public decimal Value { get; } + + /// The threshold as written in check.md. + public string Text { get; } + + /// Parses a threshold such as 1GB, 30min, 1500000000 or 0.9. + /// The value from check.md or a fixture's -- threshold line. + /// The parsed threshold, or default when parsing fails. + /// Why the text isn't a threshold, or an empty string on success. + /// + /// for a non-negative number with an optional known unit that fits in + /// 64 bits once converted to the base unit. + /// + public static bool TryParse(string text, out ThresholdValue value, out string error) + { + value = default; + if (string.IsNullOrWhiteSpace(text)) + { + error = "The threshold is empty."; + return false; + } + + var match = Pattern.Match(text.Trim()); + if (!match.Success) + { + error = $"'{text}' isn't a number with an optional unit (B, kB, MB, GB, TB, us, ms, s, min, h, d)."; + return false; + } + + // Checked before any unit is applied, so the multiplication below can't overflow decimal. + if (!decimal.TryParse(match.Groups[1].Value, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out var number) + || number > long.MaxValue) + { + error = $"'{text}' is too large for a threshold."; + return false; + } + + var unit = match.Groups[2].Value; + if (unit.Length == 0) + { + var kind = match.Groups[1].Value.Contains(".") ? ThresholdKind.Number : ThresholdKind.Integer; + return InRange(new ThresholdValue(kind, number, text), out value, out error); + } + + if (!Units.TryGetValue(unit, out var known)) + { + error = $"'{text}' has an unknown unit. Use B, kB, MB, GB, TB, us, ms, s, min, h or d."; + return false; + } + + return InRange(new ThresholdValue(known.Kind, Math.Round(number * known.Factor, MidpointRounding.AwayFromZero), text), out value, out error); + } + + // Thresholds bind as bigint or interval, so they must fit in 64 bits. + private static bool InRange(ThresholdValue candidate, out ThresholdValue value, out string error) + { + if (candidate.Value > long.MaxValue) + { + value = default; + error = $"'{candidate.Text}' is too large for a threshold."; + return false; + } + + value = candidate; + error = ""; + return true; + } +} diff --git a/src/Pgcheckup/Checks/CheckDefinition.cs b/src/Pgcheckup/Checks/CheckDefinition.cs new file mode 100644 index 0000000..33ec3f3 --- /dev/null +++ b/src/Pgcheckup/Checks/CheckDefinition.cs @@ -0,0 +1,64 @@ +namespace Pgcheckup.Checks; + +/// How urgent a finding is. A higher value is more severe, so severities compare directly. +public enum Severity +{ + /// Housekeeping. + Info, + + /// Heading toward an outage, or a safety net is gone. + Warning, + + /// Can take the database down or lose data soon. + Critical, +} + +/// What a threshold measures, which decides the parameter type it binds as. +public enum ThresholdKind +{ + /// A size in bytes. Binds as bigint. + Bytes, + + /// A length of time, held in microseconds. Binds as interval. + Duration, + + /// A whole number. Binds as bigint. + Integer, + + /// A decimal number. Binds as numeric. + Number, +} + +/// A threshold that a check's query reads as a parameter. +/// The name, as check.sql reads it with @name. +/// What it measures. +/// The value in bytes, microseconds, or as written for plain numbers. +/// The value as written in check.md, such as 1GB. +public sealed record Threshold(string Name, ThresholdKind Kind, decimal Value, string Text); + +/// A check, compiled from its checks/<id>/ folder into the binary at build time. +/// The stable kebab-case id. Baselines and ignore lists depend on it. +/// A short title for pgcheckup list. +/// The group the check belongs to: ids, cleanup, wal or capacity. +/// The severity of a finding unless its row says otherwise. +/// The oldest Postgres major version the check runs on. +/// The predefined roles the check needs. Empty when any role can run it. +/// The managed providers where the check is skipped. +/// The thresholds in parameter order, so index 0 binds to $1. +/// One read-only statement that returns a row per finding. +/// What is wrong, rendered from each row. +/// What to do about it, rendered from each row. pgcheckup prints it and never runs it. +/// The Markdown body of check.md. +public sealed record CheckDefinition( + string Id, + string Title, + string Category, + Severity Severity, + int MinVersion, + IReadOnlyList Privileges, + IReadOnlyList SkipOn, + IReadOnlyList Thresholds, + string Sql, + Template Message, + Template Fix, + string Note); diff --git a/src/Pgcheckup/Checks/Template.cs b/src/Pgcheckup/Checks/Template.cs new file mode 100644 index 0000000..163133a --- /dev/null +++ b/src/Pgcheckup/Checks/Template.cs @@ -0,0 +1,108 @@ +using System.Text; + +namespace Pgcheckup.Checks; + +/// How a template prints a value. +public enum ValueFormat +{ + /// By the value's type: intervals as "3 days", timestamps in UTC, everything else plainly. + Default, + + /// A size in bytes, such as "48 GB". Written {name:bytes}. + Bytes, + + /// A count, with words past a million, such as "1.61 billion". Written {name:count}. + Count, +} + +/// One piece of a compiled message or fix template. +public abstract record TemplatePart; + +/// Literal text. +/// The text, printed as it is. +public sealed record TextPart(string Text) : TemplatePart; + +/// A value from a column of the check's query. +/// The column name. +/// How to print the value. +public sealed record ValuePart(string Name, ValueFormat Format) : TemplatePart; + +/// A section that is left out when any value inside it is NULL. +/// Its text and values. Sections don't nest. +public sealed record SectionPart(IReadOnlyList Parts) : TemplatePart; + +/// A template and a query row that don't fit: a missing column, or NULL outside a section. +/// Which value is at fault. +public sealed class TemplateException(string message) : Exception(message); + +/// A message or fix template, compiled from check.md at build time. +/// The template's parts in order. +public sealed class Template(IReadOnlyList parts) +{ + /// The template's parts in order. + public IReadOnlyList Parts { get; } = parts; + + /// Renders the template with one row of the check's query. + /// The row, by column name. SQL NULL is . + /// The text, with each section left out when a value in it is NULL. + /// + /// The row has no column for a value, or a value outside a section is NULL. + /// + public string Render(IReadOnlyDictionary values) + { + var text = new StringBuilder(); + foreach (var part in Parts) + { + switch (part) + { + case TextPart t: + text.Append(t.Text); + break; + + case ValuePart v: + text.Append(Format(v, values) ?? throw new TemplateException($"The query returned NULL for {v.Name}, which the template needs.")); + break; + + case SectionPart s: + var section = new StringBuilder(); + var complete = true; + foreach (var inner in s.Parts) + { + var rendered = inner switch + { + TextPart t => t.Text, + ValuePart v => Format(v, values), + _ => throw new TemplateException("A template section can't contain another section."), + }; + + if (rendered == null) + { + complete = false; + break; + } + + section.Append(rendered); + } + + if (complete) + { + text.Append(section); + } + + break; + } + } + + return text.ToString(); + } + + private static string? Format(ValuePart part, IReadOnlyDictionary values) + { + if (!values.TryGetValue(part.Name, out var value)) + { + throw new TemplateException($"The template uses {part.Name}, but the query returned no column with that name."); + } + + return value == null ? null : ValueText.Format(value, part.Format); + } +} diff --git a/src/Pgcheckup/Checks/ValueText.cs b/src/Pgcheckup/Checks/ValueText.cs new file mode 100644 index 0000000..5d9ae33 --- /dev/null +++ b/src/Pgcheckup/Checks/ValueText.cs @@ -0,0 +1,108 @@ +using System.Globalization; + +namespace Pgcheckup.Checks; + +/// Prints values the same way in every check, so numbers and durations read alike. +public static class ValueText +{ + private static readonly string[] ByteUnits = ["bytes", "kB", "MB", "GB", "TB", "PB"]; + private static readonly string[] CountUnits = ["million", "billion", "trillion"]; + + /// Prints a value from a query row. + /// A non-null value as Npgsql read it. + /// How to print it. + /// The text, without culture-specific formatting. + /// needs a number and isn't one. + public static string Format(object value, ValueFormat format) => format switch + { + ValueFormat.Bytes => Bytes(ToDecimal(value)), + ValueFormat.Count => Count(ToDecimal(value)), + _ => value switch + { + TimeSpan span => Duration(span), + DateTime time => time.ToUniversalTime().ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture) + " UTC", + bool flag => flag ? "on" : "off", + IFormattable number => number.ToString(null, CultureInfo.InvariantCulture), + _ => value.ToString() ?? "", + }, + }; + + /// Prints a size with Postgres's units (1024-based, as in pg_size_pretty) and three significant digits. + /// The size in bytes. + /// Such as "512 bytes", "1.5 GB" or "48 GB". + public static string Bytes(decimal bytes) + { + if (bytes < 1024) + { + return bytes == 1 ? "1 byte" : $"{Significant(bytes)} bytes"; + } + + var unit = 0; + var value = bytes; + while (unit < ByteUnits.Length - 1 && (value >= 1024 || Round(value) >= 1024)) + { + value /= 1024; + unit++; + } + + return $"{Significant(value)} {ByteUnits[unit]}"; + } + + /// Prints a count with thousands separators, or in words from a million on. + /// The count. + /// Such as "48,213", "48 million" or "1.61 billion". + public static string Count(decimal count) + { + if (count < 1_000_000) + { + return Math.Round(count).ToString("#,0", CultureInfo.InvariantCulture); + } + + var unit = 0; + var value = count / 1_000_000; + while (unit < CountUnits.Length - 1 && (value >= 1000 || Round(value) >= 1000)) + { + value /= 1000; + unit++; + } + + return $"{Significant(value)} {CountUnits[unit]}"; + } + + /// Prints a duration in its largest whole unit, rounded down. + /// The duration. A negative one prints as zero. + /// Such as "3 days", "1 hour" or "45 seconds". + public static string Duration(TimeSpan span) + { + if (span < TimeSpan.Zero) + { + span = TimeSpan.Zero; + } + + return span switch + { + { TotalDays: >= 1 } => Plural((long)span.TotalDays, "day"), + { TotalHours: >= 1 } => Plural((long)span.TotalHours, "hour"), + { TotalMinutes: >= 1 } => Plural((long)span.TotalMinutes, "minute"), + _ => Plural((long)span.TotalSeconds, "second"), + }; + } + + private static string Plural(long n, string unit) => n == 1 ? $"1 {unit}" : $"{n} {unit}s"; + + private static decimal Round(decimal value) => + Math.Round(value, value >= 100 ? 0 : value >= 10 ? 1 : 2, MidpointRounding.AwayFromZero); + + private static string Significant(decimal value) => Round(value).ToString("0.##", CultureInfo.InvariantCulture); + + private static decimal ToDecimal(object value) => value switch + { + decimal d => d, + long l => l, + int i => i, + short s => s, + double d => (decimal)d, + float f => (decimal)f, + _ => throw new TemplateException($"The value {value} isn't a number, so it can't be formatted as a size or count."), + }; +} diff --git a/src/Pgcheckup/Cli/ConnectionInput.cs b/src/Pgcheckup/Cli/ConnectionInput.cs new file mode 100644 index 0000000..e39dc15 --- /dev/null +++ b/src/Pgcheckup/Cli/ConnectionInput.cs @@ -0,0 +1,299 @@ +using System.Globalization; +using System.Text; +using Npgsql; + +namespace Pgcheckup.Cli; + +/// A connection string or URL that can't be read. +/// +/// Which part is wrong. It never repeats any of the input, which may hold a password. +/// +public sealed class ConnectionInputException(string message) : Exception(message); + +/// +/// Reads connections the way psql does: a postgres:// URL, a libpq key-value string, or +/// nothing, with PGHOST, PGPORT, PGDATABASE and PGSSLMODE filling in what the input leaves out. +/// +/// Npgsql reads PGUSER, PGPASSWORD, PGPASSFILE and ~/.pgpass itself. +public static class ConnectionInput +{ + private static readonly (string Variable, string Key)[] Environment = + [ + ("PGHOST", "host"), + ("PGPORT", "port"), + ("PGDATABASE", "dbname"), + ("PGSSLMODE", "sslmode"), + ]; + + private static readonly Dictionary SslModes = new(StringComparer.Ordinal) + { + ["disable"] = SslMode.Disable, + ["allow"] = SslMode.Allow, + ["prefer"] = SslMode.Prefer, + ["require"] = SslMode.Require, + ["verify-ca"] = SslMode.VerifyCA, + ["verify-full"] = SslMode.VerifyFull, + }; + + private static readonly Dictionary> Keywords = new(StringComparer.Ordinal) + { + ["host"] = (b, v) => b.Host = v, + ["port"] = (b, v) => b.Port = Number("port", v), + ["dbname"] = (b, v) => b.Database = v, + ["user"] = (b, v) => b.Username = v, + ["password"] = (b, v) => b.Password = v, + ["passfile"] = (b, v) => b.Passfile = v, + ["sslmode"] = (b, v) => b.SslMode = SslModes.TryGetValue(v, out var mode) + ? mode + : throw new ConnectionInputException($"sslmode must be one of {string.Join(", ", SslModes.Keys)}."), + ["sslrootcert"] = (b, v) => b.RootCertificate = v, + ["sslcert"] = (b, v) => b.SslCertificate = v, + ["sslkey"] = (b, v) => b.SslKey = v, + ["sslpassword"] = (b, v) => b.SslPassword = v, + ["connect_timeout"] = (b, v) => b.Timeout = Number("connect_timeout", v), + ["target_session_attrs"] = (b, v) => b.TargetSessionAttributes = v, + ["options"] = (b, v) => b.Options = v, + + // pgcheckup always connects as application_name=pgcheckup. + ["application_name"] = (_, _) => { }, + ["fallback_application_name"] = (_, _) => { }, + }; + + /// Turns what the user passed into Npgsql connection settings. + /// + /// A postgres:// or postgresql:// URL, a libpq key-value string, or + /// or blank to use the environment alone. + /// + /// The process's environment variables. + /// The settings, with the host defaulting to localhost. + /// + /// The input isn't a URL or key-value string, or has a parameter pgcheckup doesn't read, a + /// port or timeout that isn't a number, or an unknown sslmode. + /// + public static NpgsqlConnectionStringBuilder Parse(string? input, IReadOnlyDictionary environment) + { + var values = new Dictionary(StringComparer.Ordinal); + if (!string.IsNullOrWhiteSpace(input)) + { + input = input.Trim(); + if (input.StartsWith("postgres://", StringComparison.Ordinal) || input.StartsWith("postgresql://", StringComparison.Ordinal)) + { + ReadUrl(input, values); + } + else if (input.Contains("://", StringComparison.Ordinal)) + { + throw new ConnectionInputException("A connection URL must start with postgres:// or postgresql://."); + } + else + { + ReadKeyValues(input, values); + } + } + + foreach (var (variable, key) in Environment) + { + if (!values.ContainsKey(key) && environment.TryGetValue(variable, out var value) && !string.IsNullOrEmpty(value)) + { + values[key] = value; + } + } + + // libpq's fallback when there's no Unix socket. Npgsql has no default at all. + values.TryAdd("host", "localhost"); + + // An unknown key may be half of a password with a space in it, so it isn't named. + if (values.Keys.Any(k => !Keywords.ContainsKey(k))) + { + throw new ConnectionInputException( + $"The connection has a parameter pgcheckup doesn't read. It reads {string.Join(", ", Keywords.Keys)}."); + } + + var settings = new NpgsqlConnectionStringBuilder(); + foreach (var (key, value) in values) + { + Keywords[key](settings, value); + } + + return settings; + } + + private static void ReadUrl(string url, Dictionary values) + { + var rest = url[(url.IndexOf("://", StringComparison.Ordinal) + 3)..]; + + // As in libpq, credentials run to the first @ that comes before any /, so a password + // may hold ? or : but a / in it must be percent-encoded. + var credentialsEnd = rest.IndexOfAny(['@', '/']); + if (credentialsEnd >= 0 && rest[credentialsEnd] == '@') + { + var userInfo = rest[..credentialsEnd]; + rest = rest[(credentialsEnd + 1)..]; + var colon = userInfo.IndexOf(':'); + values["user"] = Uri.UnescapeDataString(colon >= 0 ? userInfo[..colon] : userInfo); + if (colon >= 0) + { + values["password"] = Uri.UnescapeDataString(userInfo[(colon + 1)..]); + } + } + + var query = ""; + var questionMark = rest.IndexOf('?'); + if (questionMark >= 0) + { + query = rest[(questionMark + 1)..]; + rest = rest[..questionMark]; + } + + var slash = rest.IndexOf('/'); + var authority = slash >= 0 ? rest[..slash] : rest; + var database = slash >= 0 ? rest[(slash + 1)..] : ""; + if (database.Length > 0) + { + values["dbname"] = Uri.UnescapeDataString(database); + } + + if (authority.Length > 0) + { + var hosts = authority.Split(',').Select(SplitHostPort).ToList(); + if (hosts.Count == 1) + { + values["host"] = hosts[0].Host; + if (hosts[0].Port != null) + { + values["port"] = hosts[0].Port!; + } + } + else + { + values["host"] = string.Join(",", hosts.Select(h => h.Port == null ? h.Host : $"{h.Host}:{h.Port}")); + } + } + + foreach (var pair in query.Split('&', StringSplitOptions.RemoveEmptyEntries)) + { + var equals = pair.IndexOf('='); + var key = Uri.UnescapeDataString(equals >= 0 ? pair[..equals] : pair); + values[key] = Uri.UnescapeDataString(equals >= 0 ? pair[(equals + 1)..] : ""); + } + } + + private static (string Host, string? Port) SplitHostPort(string hostPort) + { + string host; + string? port = null; + if (hostPort.StartsWith('[')) + { + var close = hostPort.IndexOf(']'); + if (close < 0) + { + throw new ConnectionInputException("An IPv6 host in the URL is missing its closing ]."); + } + + host = hostPort[1..close]; + if (hostPort.Length > close + 1 && hostPort[close + 1] == ':') + { + port = hostPort[(close + 2)..]; + } + } + else + { + var colon = hostPort.LastIndexOf(':'); + host = colon >= 0 ? hostPort[..colon] : hostPort; + port = colon >= 0 ? hostPort[(colon + 1)..] : null; + } + + if (port != null) + { + Number("port", port); + } + + return (Uri.UnescapeDataString(host), port); + } + + // libpq's format: key=value pairs separated by spaces, with values in single quotes when + // they hold spaces, and backslash escapes inside. + private static void ReadKeyValues(string input, Dictionary values) + { + var i = 0; + while (i < input.Length) + { + if (char.IsWhiteSpace(input[i])) + { + i++; + continue; + } + + var keyStart = i; + while (i < input.Length && input[i] != '=' && !char.IsWhiteSpace(input[i])) + { + i++; + } + + var key = input[keyStart..i]; + while (i < input.Length && char.IsWhiteSpace(input[i])) + { + i++; + } + + if (i >= input.Length || input[i] != '=') + { + throw new ConnectionInputException("Part of the connection string isn't key=value. Put values that contain spaces in single quotes."); + } + + i++; + while (i < input.Length && char.IsWhiteSpace(input[i])) + { + i++; + } + + var value = new StringBuilder(); + if (i < input.Length && input[i] == '\'') + { + i++; + var closed = false; + while (i < input.Length) + { + if (input[i] == '\\' && i + 1 < input.Length) + { + value.Append(input[i + 1]); + i += 2; + } + else if (input[i] == '\'') + { + closed = true; + i++; + break; + } + else + { + value.Append(input[i++]); + } + } + + if (!closed) + { + throw new ConnectionInputException("A value in the connection string opens a quote that never closes."); + } + } + else + { + while (i < input.Length && !char.IsWhiteSpace(input[i])) + { + if (input[i] == '\\' && i + 1 < input.Length) + { + i++; + } + + value.Append(input[i++]); + } + } + + values[key] = value.ToString(); + } + } + + private static int Number(string key, string value) => + int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var number) + ? number + : throw new ConnectionInputException($"The {key} in the connection isn't a number."); +} diff --git a/src/Pgcheckup/Cli/PgcheckupCli.cs b/src/Pgcheckup/Cli/PgcheckupCli.cs new file mode 100644 index 0000000..07ac8c3 --- /dev/null +++ b/src/Pgcheckup/Cli/PgcheckupCli.cs @@ -0,0 +1,179 @@ +using System.CommandLine; +using Npgsql; +using Pgcheckup.Checks; +using Pgcheckup.Engine; + +namespace Pgcheckup.Cli; + +/// The pgcheckup command line: its commands, options and exit codes. +public static class PgcheckupCli +{ + /// Exit code 0: the scan ran and no finding reached --fail-on. + public const int Passed = 0; + + /// Exit code 1: at least one finding reached --fail-on. + public const int FindingsReachedFailOn = 1; + + /// Exit code 2: the scan couldn't run, whatever the reason. + public const int CouldNotRun = 2; + + /// Runs pgcheckup with every check compiled into the binary. + /// The command-line arguments. + /// Where the report and help go. + /// Where errors go. + /// The process's environment variables, for PG* settings and NO_COLOR. + /// Whether is a file or pipe, which turns color off. + /// Cancels the scan. + /// 0, 1 or 2. See , and . + public static Task RunAsync( + string[] args, + TextWriter output, + TextWriter error, + IReadOnlyDictionary environment, + bool outputRedirected, + CancellationToken cancellationToken) => + RunAsync(args, CheckCatalog.All, output, error, environment, outputRedirected, cancellationToken); + + /// Runs pgcheckup with the given checks. + /// The command-line arguments. + /// The checks that list shows and scan runs. + /// Where the report and help go. + /// Where errors go. + /// The process's environment variables, for PG* settings and NO_COLOR. + /// Whether is a file or pipe, which turns color off. + /// Cancels the scan. + /// + /// 0, 1 or 2. Anything that stops the scan, including bad arguments and unexpected errors, + /// returns 2, never 1. + /// + public static async Task RunAsync( + string[] args, + IReadOnlyList checks, + TextWriter output, + TextWriter error, + IReadOnlyDictionary environment, + bool outputRedirected, + CancellationToken cancellationToken) + { + var root = new RootCommand("Checks a PostgreSQL database for the problems that cause outages. Read-only, and safe to run on production."); + + var list = new Command("list", "List every check."); + list.SetAction(_ => List(checks, output)); + root.Subcommands.Add(list); + + var connection = new Argument("connection") + { + Description = "A postgres:// URL or a libpq key-value string. Without one, the PG* environment variables are used. Keep the password in PGPASSWORD or ~/.pgpass, not here.", + Arity = ArgumentArity.ZeroOrOne, + }; + var failOn = new Option("--fail-on") + { + Description = "Exit with 1 when a finding reaches this severity: critical, warning or info.", + DefaultValueFactory = _ => "critical", + }; + failOn.AcceptOnlyFromAmong("critical", "warning", "info"); + + var scan = new Command("scan", "Scan a database and report what could take it down."); + scan.Arguments.Add(connection); + scan.Options.Add(failOn); + scan.SetAction((result, token) => ScanAsync( + result.GetValue(connection), result.GetValue(failOn)!, checks, output, error, environment, outputRedirected, token)); + root.Subcommands.Add(scan); + + var parsed = root.Parse(args); + + // Exit code 1 means findings, so argument errors get 2 like any other scan that couldn't run. + if (parsed.Errors.Count > 0) + { + foreach (var parseError in parsed.Errors) + { + error.WriteLine($"pgcheckup: {parseError.Message}"); + } + + error.WriteLine("Run pgcheckup --help for usage."); + return CouldNotRun; + } + + // System.CommandLine's own handler would print a stack trace and exit 1, which means findings. + try + { + return await parsed.InvokeAsync( + new InvocationConfiguration { Output = output, Error = error, EnableDefaultExceptionHandler = false }, + cancellationToken); + } + catch (Exception problem) + { + error.WriteLine($"pgcheckup: the scan stopped: {problem.Message}"); + return CouldNotRun; + } + } + + private static int List(IReadOnlyList checks, TextWriter output) + { + var width = checks.Max(c => c.Id.Length) + 2; + foreach (var check in checks) + { + output.WriteLine($"{check.Id.PadRight(width)}{check.Severity.ToString().ToLowerInvariant(),-10}{check.Title}"); + } + + return Passed; + } + + private static async Task ScanAsync( + string? input, + string failOn, + IReadOnlyList checks, + TextWriter output, + TextWriter error, + IReadOnlyDictionary environment, + bool outputRedirected, + CancellationToken cancellationToken) + { + NpgsqlConnectionStringBuilder settings; + try + { + settings = ConnectionInput.Parse(input, environment); + } + catch (ConnectionInputException problem) + { + error.WriteLine($"pgcheckup: {problem.Message}"); + return CouldNotRun; + } + + var host = settings.Host ?? "localhost"; + ReadOnlySession session; + try + { + session = await ReadOnlySession.OpenAsync(settings, cancellationToken); + } + catch (Exception problem) when (problem is NpgsqlException or TimeoutException or ArgumentException or InvalidOperationException) + { + error.WriteLine($"pgcheckup: couldn't connect to {host}: {problem.Message}"); + return CouldNotRun; + } + + await using (session) + { + ScanReport report; + try + { + report = await Scanner.ScanAsync(session, host, checks, cancellationToken); + } + catch (Exception problem) when (problem is CheckFailedException or NpgsqlException) + { + error.WriteLine($"pgcheckup: {problem.Message}"); + return CouldNotRun; + } + + TerminalReport.Write(output, report, TerminalReport.UseColor(outputRedirected, environment)); + + var threshold = failOn switch + { + "info" => Severity.Info, + "warning" => Severity.Warning, + _ => Severity.Critical, + }; + return report.Results.Any(r => r.Worst >= threshold) ? FindingsReachedFailOn : Passed; + } + } +} diff --git a/src/Pgcheckup/Cli/TerminalReport.cs b/src/Pgcheckup/Cli/TerminalReport.cs new file mode 100644 index 0000000..fb6ad01 --- /dev/null +++ b/src/Pgcheckup/Cli/TerminalReport.cs @@ -0,0 +1,106 @@ +using Pgcheckup.Checks; +using Pgcheckup.Engine; + +namespace Pgcheckup.Cli; + +/// The report pgcheckup scan prints to a terminal. +public static class TerminalReport +{ + private const int Indent = 10; + private const string FixLabel = "Fix: "; + + /// Whether to color the severity words. + /// Whether the report goes to a file or pipe. + /// The process's environment variables. + /// + /// when output is redirected, NO_COLOR is set to anything but an + /// empty string, or TERM is dumb. + /// + public static bool UseColor(bool outputRedirected, IReadOnlyDictionary environment) => + !outputRedirected + && !(environment.TryGetValue("NO_COLOR", out var noColor) && !string.IsNullOrEmpty(noColor)) + && !(environment.TryGetValue("TERM", out var term) && term == "dumb"); + + /// + /// Writes the header, then each finding with its fix, then a summary that counts each check + /// once at its worst severity. + /// + /// Where to write. + /// What the scan found. + /// Whether to color the severity words. The words are always there. + /// Findings are ordered by severity, most severe first, then by check id. + public static void Write(TextWriter output, ScanReport report, bool color) + { + var server = report.Server; + output.WriteLine($"pgcheckup · {server.Database} on {server.Host} · PostgreSQL {server.Version}"); + output.WriteLine(); + + var findings = report.Results + .SelectMany(r => r.Findings) + .Select((finding, order) => (finding, order)) + .OrderByDescending(f => f.finding.Severity) + .ThenBy(f => f.finding.CheckId, StringComparer.Ordinal) + .ThenBy(f => f.order) + .Select(f => f.finding); + + foreach (var finding in findings) + { + var label = finding.Severity.ToString().ToUpperInvariant(); + output.WriteLine($"{Paint(label, finding.Severity, color)}{new string(' ', Indent - label.Length)}{finding.CheckId}"); + WriteIndented(output, finding.Message, new string(' ', Indent), new string(' ', Indent)); + WriteIndented(output, finding.Fix, new string(' ', Indent) + FixLabel, new string(' ', Indent + FixLabel.Length)); + output.WriteLine(); + } + + output.WriteLine(Summary(report)); + } + + private static void WriteIndented(TextWriter output, string text, string first, string rest) + { + var lines = text.Split('\n'); + for (var i = 0; i < lines.Length; i++) + { + output.WriteLine((i == 0 ? first : rest) + lines[i]); + } + } + + private static string Summary(ScanReport report) + { + var parts = new List { $"{report.Results.Count(r => r.Worst == null)} passed" }; + var critical = report.Results.Count(r => r.Worst == Severity.Critical); + var warning = report.Results.Count(r => r.Worst == Severity.Warning); + var info = report.Results.Count(r => r.Worst == Severity.Info); + if (critical > 0) + { + parts.Add($"{critical} critical"); + } + + if (warning > 0) + { + parts.Add(warning == 1 ? "1 warning" : $"{warning} warnings"); + } + + if (info > 0) + { + parts.Add($"{info} info"); + } + + return string.Join(" · ", parts); + } + + private static string Paint(string label, Severity severity, bool color) + { + if (!color) + { + return label; + } + + var code = severity switch + { + Severity.Critical => "1;31", + Severity.Warning => "1;33", + _ => "1;34", + }; + return $"\u001b[{code}m{label}\u001b[0m"; + } +} diff --git a/src/Pgcheckup/Engine/CheckRunner.cs b/src/Pgcheckup/Engine/CheckRunner.cs new file mode 100644 index 0000000..13f9b1a --- /dev/null +++ b/src/Pgcheckup/Engine/CheckRunner.cs @@ -0,0 +1,77 @@ +using Pgcheckup.Checks; + +namespace Pgcheckup.Engine; + +/// One problem a check found, ready to print. +/// The id of the check that found it. +/// The object it is about, such as a slot or table name. +/// How urgent it is. +/// What is wrong and why it matters. +/// What to do about it. pgcheckup prints it and never runs it. +/// The row the check returned, by column name, for machine-readable output. +public sealed record Finding( + string CheckId, + string Subject, + Severity Severity, + string Message, + string Fix, + IReadOnlyDictionary Values); + +/// A check whose query or template doesn't hold up its side of the contract. +/// What the check did wrong, naming the check. +/// The error that exposed it, if any. +public sealed class CheckException(string message, Exception? inner = null) : Exception(message, inner); + +/// Runs one check and turns its rows into findings. +public static class CheckRunner +{ + /// Runs a check's query with its thresholds and renders a finding per row. + /// The guarded session to query through. + /// The check to run. + /// Cancels the query. + /// A finding per row, in the query's order. Empty when the check passes. + /// A row has no subject, an unknown severity, or doesn't fit a template. + /// The query failed, timed out or couldn't get a lock. + public static async Task> RunAsync(ReadOnlySession session, CheckDefinition check, CancellationToken cancellationToken) + { + var parameters = check.Thresholds.Select(ParameterValue).ToList(); + var rows = await session.QueryAsync(check.Sql, parameters, cancellationToken); + return rows.Select(row => ToFinding(check, row)).ToList(); + } + + private static object ParameterValue(Threshold threshold) => threshold.Kind switch + { + ThresholdKind.Bytes or ThresholdKind.Integer => (long)threshold.Value, + ThresholdKind.Duration => TimeSpan.FromMicroseconds((long)threshold.Value), + _ => threshold.Value, + }; + + private static Finding ToFinding(CheckDefinition check, IReadOnlyDictionary row) + { + if (!row.TryGetValue("subject", out var subject) || subject is not string subjectText) + { + throw new CheckException($"{check.Id} returned a row without a subject."); + } + + var severity = check.Severity; + if (row.TryGetValue("severity", out var value) && value != null) + { + severity = value switch + { + "critical" => Severity.Critical, + "warning" => Severity.Warning, + "info" => Severity.Info, + _ => throw new CheckException($"{check.Id} returned the severity {value}. Use critical, warning or info."), + }; + } + + try + { + return new Finding(check.Id, subjectText, severity, check.Message.Render(row), check.Fix.Render(row), row); + } + catch (TemplateException error) + { + throw new CheckException($"{check.Id}: {error.Message}", error); + } + } +} diff --git a/src/Pgcheckup/Engine/ReadOnlySession.cs b/src/Pgcheckup/Engine/ReadOnlySession.cs new file mode 100644 index 0000000..5c64bc0 --- /dev/null +++ b/src/Pgcheckup/Engine/ReadOnlySession.cs @@ -0,0 +1,123 @@ +using Npgsql; + +namespace Pgcheckup.Engine; + +/// +/// The only way pgcheckup talks to Postgres. Every query runs in its own READ ONLY +/// transaction with transaction-local timeouts and search path, then rolls back. +/// +/// +/// Nothing is set for the session: behind a transaction pooler, a session setting would reach +/// the application's next transaction. +/// +public sealed class ReadOnlySession : IAsyncDisposable +{ + private static readonly string[] Guards = + [ + "BEGIN TRANSACTION READ ONLY", + "SET LOCAL statement_timeout = '5s'", + + // Stops a scan from queueing behind a migration's lock and blocking the traffic behind it. + "SET LOCAL lock_timeout = '1s'", + + // A function or operator planted in another schema can't shadow a built-in and run as + // the scanning role (CVE-2018-1058). pg_temp goes last so temporary tables can't either. + "SET LOCAL search_path = pg_catalog, pg_temp", + ]; + + private readonly NpgsqlDataSource dataSource; + private readonly NpgsqlConnection connection; + + private ReadOnlySession(NpgsqlDataSource dataSource, NpgsqlConnection connection) + { + this.dataSource = dataSource; + this.connection = connection; + } + + /// Connects as application_name = pgcheckup, without loading types or pooling. + /// Where and how to connect. The caller's builder isn't changed. + /// Cancels connecting. + /// An open session. Dispose it to close the connection. + /// The server can't be reached, or refuses the login. + public static async Task OpenAsync(NpgsqlConnectionStringBuilder settings, CancellationToken cancellationToken) + { + var builder = new NpgsqlSlimDataSourceBuilder(settings.ConnectionString); + builder.ConnectionStringBuilder.ApplicationName = "pgcheckup"; + builder.ConnectionStringBuilder.Pooling = false; + builder.EnableTransportSecurity(); + + // Loading types would run a query outside the guarded transaction; the built-in types suffice. + builder.ConfigureTypeLoading(options => options.EnableTypeLoading(false)); + + var dataSource = builder.Build(); + try + { + var connection = await dataSource.OpenConnectionAsync(cancellationToken); + return new ReadOnlySession(dataSource, connection); + } + catch + { + await dataSource.DisposeAsync(); + throw; + } + } + + /// Runs one statement in its own guarded transaction and returns every row. + /// + /// A single statement. With SQL rewriting off, Postgres rejects a second one. Parameters are + /// $1, $2 and so on. + /// + /// The parameter values, bound by position and typed by their .NET type. + /// Cancels the query. The rollback still runs. + /// The rows, by column name. SQL NULL is . + /// + /// The statement failed: it tried to write (25006), ran past 5 seconds (57014), waited over + /// 1 second for a lock (55P03), or raised any other error. + /// + public async Task>> QueryAsync( + string sql, IReadOnlyList parameters, CancellationToken cancellationToken) + { + foreach (var guard in Guards) + { + await using var command = new NpgsqlCommand(guard, connection); + await command.ExecuteNonQueryAsync(cancellationToken); + } + + try + { + await using var query = new NpgsqlCommand(sql, connection); + foreach (var parameter in parameters) + { + query.Parameters.Add(new NpgsqlParameter { Value = parameter }); + } + + var rows = new List>(); + await using var reader = await query.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + var row = new Dictionary(reader.FieldCount, StringComparer.Ordinal); + for (var i = 0; i < reader.FieldCount; i++) + { + row[reader.GetName(i)] = await reader.IsDBNullAsync(i, cancellationToken) ? null : reader.GetValue(i); + } + + rows.Add(row); + } + + return rows; + } + finally + { + await using var rollback = new NpgsqlCommand("ROLLBACK", connection); + await rollback.ExecuteNonQueryAsync(CancellationToken.None); + } + } + + /// Closes the connection. + /// A task that completes when the connection is closed. + public async ValueTask DisposeAsync() + { + await connection.DisposeAsync(); + await dataSource.DisposeAsync(); + } +} diff --git a/src/Pgcheckup/Engine/Scanner.cs b/src/Pgcheckup/Engine/Scanner.cs new file mode 100644 index 0000000..9df65b0 --- /dev/null +++ b/src/Pgcheckup/Engine/Scanner.cs @@ -0,0 +1,77 @@ +using System.Globalization; +using Pgcheckup.Checks; + +namespace Pgcheckup.Engine; + +/// What the report's header says about the server. +/// The database scanned. +/// The host as given, never the full connection string. +/// The Postgres version, such as "17.6". +public sealed record ServerInfo(string Database, string Host, string Version); + +/// One check's findings. +/// The check that ran. +/// What it found. Empty when it passed. +public sealed record CheckResult(CheckDefinition Check, IReadOnlyList Findings) +{ + /// The most severe finding's severity, or when the check passed. + public Severity? Worst => Findings.Count == 0 ? null : Findings.Max(f => f.Severity); +} + +/// Everything a scan found. +/// The server scanned. +/// Each check's findings, in the order the checks ran. +public sealed record ScanReport(ServerInfo Server, IReadOnlyList Results); + +/// A check that couldn't run. In M0 this ends the scan; M1 reports it as errored and goes on. +/// The check that failed. +/// What went wrong. +public sealed class CheckFailedException(string checkId, Exception inner) + : Exception($"{checkId} couldn't run: {inner.Message}", inner) +{ + /// The check that failed. + public string CheckId { get; } = checkId; +} + +/// Runs checks against one database. +public static class Scanner +{ + /// Reads the server's version and database, then runs each check in turn. + /// The guarded session to query through. + /// The host to name in the report. + /// The checks to run, in order. + /// Cancels the scan. + /// The server and each check's findings. + /// A check failed for any reason. The scan stops there. + /// The server's version or database couldn't be read. + public static async Task ScanAsync( + ReadOnlySession session, string host, IReadOnlyList checks, CancellationToken cancellationToken) + { + var row = (await session.QueryAsync( + "SELECT current_database() AS database, current_setting('server_version_num')::int AS version", + [], + cancellationToken)).Single(); + + // server_version_num is major * 10000 + minor from Postgres 10 on. + var version = (int)row["version"]!; + var server = new ServerInfo( + (string)row["database"]!, + host, + string.Create(CultureInfo.InvariantCulture, $"{version / 10000}.{version % 10000}")); + + var results = new List(); + foreach (var check in checks) + { + try + { + results.Add(new CheckResult(check, await CheckRunner.RunAsync(session, check, cancellationToken))); + } + catch (Exception error) when (error is not OperationCanceledException) + { + throw new CheckFailedException(check.Id, error); + } + } + + return new ScanReport(server, results); + } +} diff --git a/src/Pgcheckup/Pgcheckup.csproj b/src/Pgcheckup/Pgcheckup.csproj new file mode 100644 index 0000000..da6ad63 --- /dev/null +++ b/src/Pgcheckup/Pgcheckup.csproj @@ -0,0 +1,22 @@ + + + Exe + pgcheckup + Pgcheckup + true + false + $([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)../../checks/')) + + + + + + + + + + + + + + diff --git a/src/Pgcheckup/Program.cs b/src/Pgcheckup/Program.cs new file mode 100644 index 0000000..629d969 --- /dev/null +++ b/src/Pgcheckup/Program.cs @@ -0,0 +1,14 @@ +using System.Collections; +using System.Text; +using Pgcheckup.Cli; + +// The report's separators and any non-ASCII object names need UTF-8, whatever the console's code page. +Console.OutputEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + +var environment = new Dictionary(OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal); +foreach (DictionaryEntry variable in Environment.GetEnvironmentVariables()) +{ + environment[(string)variable.Key] = (string?)variable.Value; +} + +return await PgcheckupCli.RunAsync(args, Console.Out, Console.Error, environment, Console.IsOutputRedirected, CancellationToken.None); diff --git a/tests/Pgcheckup.Tests/Checks/CheckCompilerTests.cs b/tests/Pgcheckup.Tests/Checks/CheckCompilerTests.cs new file mode 100644 index 0000000..c61d1f8 --- /dev/null +++ b/tests/Pgcheckup.Tests/Checks/CheckCompilerTests.cs @@ -0,0 +1,242 @@ +using Pgcheckup.Checks.Generator; + +namespace Pgcheckup.Tests.Checks; + +public class CheckCompilerTests +{ + private const string ValidFrontmatter = """ + id: sample-check + title: Sample check + category: wal + severity: warning + min_version: 14 + privileges: [pg_monitor] + thresholds: + min_size: 1GB + min_age: 1h + message: >- + Thing {subject} is [{age} ]old + and big. + fix: | + Do this: + SELECT 1; + """; + + private const string ValidBody = """ + ## What breaks + + Things. + + ## Fix + + Fix it. + + ## Seen in + + - [Replication slots](https://www.postgresql.org/docs/current/warm-standby.html) + """; + + private const string ValidSql = "SELECT 'x' AS subject WHERE 1 >= @min_age AND 2 >= @min_size"; + + private static readonly string[] BothFixtures = ["fixtures/fires.sql", "fixtures/healthy.sql"]; + + private static string Markdown(string frontmatter = ValidFrontmatter, string body = ValidBody) => + $"---\n{frontmatter}\n---\n\n{body}\n"; + + private static CheckCompilation Compile( + string? markdown = null, string? sql = ValidSql, string[]? otherFiles = null, string id = "sample-check") => + CheckCompiler.Compile(new CheckFiles(id, markdown ?? Markdown(), sql, otherFiles ?? BothFixtures)); + + private static CheckError SingleError(CheckCompilation compilation) + { + Assert.Null(compilation.Check); + return Assert.Single(compilation.Errors); + } + + [Fact] + public void Compiles_a_valid_check() + { + var compilation = Compile(); + + Assert.Empty(compilation.Errors); + var check = Assert.IsType(compilation.Check); + Assert.Equal("sample-check", check.Id); + Assert.Equal("Sample check", check.Title); + Assert.Equal("wal", check.Category); + Assert.Equal("warning", check.Severity); + Assert.Equal(14, check.MinVersion); + Assert.Equal(["pg_monitor"], check.Privileges); + Assert.Empty(check.SkipOn); + Assert.Equal("SELECT 'x' AS subject WHERE 1 >= $1 AND 2 >= $2", check.Sql); + Assert.Equal("'Thing '' is '[' ']'old and big.'", TemplateText.Describe(check.Message)); + Assert.Equal("'Do this:\nSELECT 1;'", TemplateText.Describe(check.Fix)); + Assert.StartsWith("## What breaks", check.Note); + } + + [Fact] + public void Orders_thresholds_by_their_parameter_position() + { + var check = Compile().Check!; + + Assert.Collection( + check.Thresholds, + t => + { + Assert.Equal("min_age", t.Name); + Assert.Equal(ThresholdKind.Duration, t.Value.Kind); + Assert.Equal(3_600_000_000m, t.Value.Value); + }, + t => + { + Assert.Equal("min_size", t.Name); + Assert.Equal(ThresholdKind.Bytes, t.Value.Kind); + Assert.Equal(1_073_741_824m, t.Value.Value); + }); + } + + [Fact] + public void Reads_quoted_scalars_and_ignores_comments() + { + var frontmatter = ValidFrontmatter + .Replace("title: Sample check", "# A comment line\ntitle: \"Sample: \\\"check\\\"\"") + .Replace("category: wal", "category: 'wal' # trailing comment"); + + var check = Compile(Markdown(frontmatter)).Check!; + + Assert.Equal("Sample: \"check\"", check.Title); + Assert.Equal("wal", check.Category); + } + + [Fact] + public void Requires_frontmatter() + { + var error = SingleError(Compile(ValidBody)); + + Assert.Equal("check.md", error.File); + Assert.Equal(1, error.Line); + Assert.Contains("---", error.Message); + } + + [Fact] + public void Reports_an_unknown_key_on_its_line() + { + var error = SingleError(Compile(Markdown(ValidFrontmatter + "\ncolour: blue"))); + + Assert.Equal(17, error.Line); + Assert.Contains("colour", error.Message); + } + + [Theory] + [InlineData("id")] + [InlineData("title")] + [InlineData("category")] + [InlineData("severity")] + [InlineData("min_version")] + [InlineData("privileges")] + public void Reports_a_missing_required_key(string key) + { + var frontmatter = string.Join("\n", ValidFrontmatter.Split('\n').Where(l => !l.StartsWith(key + ":", StringComparison.Ordinal))); + + Assert.Contains(key, SingleError(Compile(Markdown(frontmatter))).Message); + } + + [Fact] + public void Reports_a_duplicate_key() + { + Assert.Contains("title", SingleError(Compile(Markdown(ValidFrontmatter + "\ntitle: Again"))).Message); + } + + [Fact] + public void Requires_the_id_to_match_the_folder() + { + Assert.Contains("other-check", SingleError(Compile(id: "other-check")).Message); + } + + [Theory] + [InlineData("id: sample-check", "id: Sample_Check", "Sample_Check")] + [InlineData("category: wal", "category: disks", "disks")] + [InlineData("severity: warning", "severity: high", "high")] + [InlineData("min_version: 14", "min_version: 9.6", "9.6")] + [InlineData("min_version: 14", "min_version: 9", "9")] + [InlineData("privileges: [pg_monitor]", "privileges: [superuser]", "superuser")] + [InlineData("privileges: [pg_monitor]", "privileges: [pg_monitor]\nskip_on: [heroku]", "heroku")] + [InlineData("min_size: 1GB", "min_size: 1 gigabyte", "1 gigabyte")] + [InlineData("min_size: 1GB", "Min_Size: 1GB", "Min_Size")] + [InlineData("title: Sample check", "title: [a, b]", "title")] + public void Rejects_invalid_values(string valid, string invalid, string named) + { + var markdown = Markdown(ValidFrontmatter.Replace(valid, invalid)); + + Assert.Contains(Compile(markdown).Errors, e => e.File == "check.md" && e.Message.Contains(named)); + } + + [Fact] + public void Reports_a_bad_threshold_on_its_line() + { + var error = Assert.Single(Compile(Markdown(ValidFrontmatter.Replace("min_age: 1h", "min_age: 1 hour"))).Errors); + + Assert.Equal(10, error.Line); + } + + [Fact] + public void Reports_a_threshold_named_twice() + { + var error = SingleError(Compile(Markdown(ValidFrontmatter.Replace(" min_age: 1h", " min_age: 1h\n min_age: 2h")))); + + Assert.Equal(11, error.Line); + Assert.Contains("min_age", error.Message); + } + + [Fact] + public void Reports_template_errors_on_the_template_line() + { + var error = SingleError(Compile(Markdown(ValidFrontmatter.Replace("{subject}", "{subject:gb}")))); + + Assert.Equal(12, error.Line); + Assert.Contains("gb", error.Message); + } + + [Theory] + [InlineData("## What breaks", "What breaks")] + [InlineData("## Fix", "Fix")] + [InlineData("## Seen in", "Seen in")] + public void Requires_every_section(string heading, string named) + { + var body = ValidBody.Replace(heading, "## Something else"); + + Assert.Contains(named, SingleError(Compile(Markdown(body: body))).Message); + } + + [Fact] + public void Requires_a_link_under_seen_in() + { + var body = ValidBody.Replace("- [Replication slots](https://www.postgresql.org/docs/current/warm-standby.html)", "- A talk I remember"); + + Assert.Contains("link", SingleError(Compile(Markdown(body: body))).Message); + } + + [Theory] + [InlineData("fixtures/fires.sql")] + [InlineData("fixtures/healthy.sql")] + public void Requires_both_fixtures(string fixture) + { + var error = SingleError(Compile(otherFiles: BothFixtures.Where(f => f != fixture).ToArray())); + + Assert.Equal(fixture, error.File); + } + + [Fact] + public void Requires_check_sql() + { + Assert.Equal("check.sql", SingleError(Compile(sql: null)).File); + } + + [Fact] + public void Reports_sql_errors_against_check_sql() + { + var error = SingleError(Compile(sql: ValidSql + "\n AND pg_terminate_backend(1) IS NULL")); + + Assert.Equal("check.sql", error.File); + Assert.Equal(2, error.Line); + } +} diff --git a/tests/Pgcheckup.Tests/Checks/CheckGeneratorTests.cs b/tests/Pgcheckup.Tests/Checks/CheckGeneratorTests.cs new file mode 100644 index 0000000..2aa5490 --- /dev/null +++ b/tests/Pgcheckup.Tests/Checks/CheckGeneratorTests.cs @@ -0,0 +1,140 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Text; +using Pgcheckup.Checks.Generator; + +namespace Pgcheckup.Tests.Checks; + +public class CheckGeneratorTests +{ + private const string Root = "/repo/checks/"; + + private const string ValidCheckMd = """ + --- + id: good-check + title: Good check + category: wal + severity: warning + min_version: 14 + privileges: [pg_monitor] + thresholds: + min_size: 1GB + message: Slot {subject}[ for {age}] holds {size:bytes} and "quotes". + fix: | + SELECT pg_drop_replication_slot({slot_literal}); + SELECT ARRAY[[1]]; + --- + + ## What breaks + + Disks fill. + + ## Fix + + Drop it. + + ## Seen in + + - [Docs](https://www.postgresql.org/docs/current/warm-standby.html) + """; + + private static GeneratorDriverRunResult Run(params (string Path, string Text)[] files) + { + var compilation = CSharpCompilation.Create( + "Generated", + references: TrustedPlatformReferences(), + options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + var driver = CSharpGeneratorDriver.Create( + [new CheckGenerator().AsSourceGenerator()], + files.Select(f => (AdditionalText)new InMemoryText(f.Path, f.Text)), + optionsProvider: new BuildProperties(new() { ["build_property.PgcheckupChecksDir"] = Root })); + + driver = (CSharpGeneratorDriver)driver.RunGeneratorsAndUpdateCompilation(compilation, out var output, out _); + + // The generated catalog must compile against the real runtime types. + Assert.Empty(output.GetDiagnostics().Where(d => d.Severity == DiagnosticSeverity.Error)); + return driver.GetRunResult(); + } + + private static IEnumerable TrustedPlatformReferences() => + ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!) + .Split(Path.PathSeparator) + .Append(typeof(Pgcheckup.Checks.CheckDefinition).Assembly.Location) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Select(p => MetadataReference.CreateFromFile(p)); + + private static (string, string)[] GoodCheck(string folder = "good-check") => + [ + ($"{Root}{folder}/check.md", ValidCheckMd), + ($"{Root}{folder}/check.sql", "SELECT 'x' AS subject WHERE 1 > @min_size"), + ($"{Root}{folder}/fixtures/fires.sql", ""), + ($"{Root}{folder}/fixtures/healthy.sql", ""), + ]; + + [Fact] + public void Emits_a_catalog_that_compiles_for_valid_checks() + { + var result = Run(GoodCheck()); + + Assert.Empty(result.Diagnostics); + var source = Assert.Single(result.GeneratedTrees).ToString(); + Assert.Contains("\"good-check\"", source); + } + + [Fact] + public void Reports_an_invalid_check_as_an_error_on_its_file_and_line() + { + var files = GoodCheck(); + files[0].Item2 = ValidCheckMd.Replace("title: Good check", "title: Good check\ncolour: blue"); + + var diagnostic = Assert.Single(Run(files).Diagnostics); + + Assert.Equal(DiagnosticSeverity.Error, diagnostic.Severity); + Assert.Equal("PGC001", diagnostic.Id); + Assert.Contains("colour", diagnostic.GetMessage()); + var span = diagnostic.Location.GetLineSpan(); + Assert.Equal($"{Root}good-check/check.md", span.Path); + Assert.Equal(4, span.StartLinePosition.Line + 1); + } + + [Fact] + public void Reports_a_check_folder_without_check_md() + { + var diagnostic = Assert.Single(Run(($"{Root}lost-check/check.sql", "SELECT 1")).Diagnostics); + + Assert.Equal(DiagnosticSeverity.Error, diagnostic.Severity); + Assert.Contains("lost-check", diagnostic.GetMessage()); + } + + [Fact] + public void Ignores_files_outside_check_folders() + { + var result = Run(("/repo/docs/check.md", "not a check"), ($"{Root}README.md", "About checks")); + + Assert.Empty(result.Diagnostics); + } + + private sealed class InMemoryText(string path, string text) : AdditionalText + { + public override string Path { get; } = path; + + public override SourceText GetText(CancellationToken cancellationToken = default) => SourceText.From(text); + } + + private sealed class BuildProperties(Dictionary global) : AnalyzerConfigOptionsProvider + { + public override AnalyzerConfigOptions GlobalOptions { get; } = new Options(global); + + public override AnalyzerConfigOptions GetOptions(SyntaxTree tree) => new Options([]); + + public override AnalyzerConfigOptions GetOptions(AdditionalText textFile) => new Options([]); + + private sealed class Options(Dictionary values) : AnalyzerConfigOptions + { + public override bool TryGetValue(string key, out string value) => values.TryGetValue(key, out value!); + } + } +} diff --git a/tests/Pgcheckup.Tests/Checks/CheckSqlTests.cs b/tests/Pgcheckup.Tests/Checks/CheckSqlTests.cs new file mode 100644 index 0000000..92b59f0 --- /dev/null +++ b/tests/Pgcheckup.Tests/Checks/CheckSqlTests.cs @@ -0,0 +1,142 @@ +using Pgcheckup.Checks.Generator; + +namespace Pgcheckup.Tests.Checks; + +public class CheckSqlTests +{ + private static SqlResult Compile(string sql, params string[] thresholds) => CheckSql.Compile(sql, thresholds); + + [Fact] + public void Rewrites_named_thresholds_to_positional_parameters_in_order_of_first_use() + { + var result = Compile("SELECT 1 WHERE a >= @min_x AND b < @max_y OR c = @min_x", "max_y", "min_x"); + + Assert.Empty(result.Errors); + Assert.Equal("SELECT 1 WHERE a >= $1 AND b < $2 OR c = $1", result.Sql); + Assert.Equal(["min_x", "max_y"], result.Parameters); + } + + [Fact] + public void Leaves_at_signs_alone_inside_literals_comments_and_operators() + { + const string sql = """ + SELECT '@x', E'\'@x', $q$ @x $q$, "@x", a @> b -- @x + /* @x /* nested @x */ @x */ + WHERE n >= @x + """; + + var result = Compile(sql, "x"); + + Assert.Empty(result.Errors); + Assert.Equal(sql.Replace("n >= @x", "n >= $1"), result.Sql); + } + + [Fact] + public void Reports_an_unknown_threshold_with_its_line() + { + var result = Compile("SELECT 1\nWHERE a > @nope", "x"); + + Assert.Contains(result.Errors, e => e.Line == 2 && e.Message.Contains("@nope")); + } + + [Fact] + public void Reports_a_threshold_the_sql_never_reads() + { + var result = Compile("SELECT 1", "unused_limit"); + + Assert.Contains(result.Errors, e => e.Message.Contains("unused_limit")); + } + + [Theory] + [InlineData("SELECT 1")] + [InlineData("select 1")] + [InlineData("-- leading comment\nWITH x AS (SELECT 1) SELECT * FROM x")] + [InlineData("/* c */ SELECT 1;")] + public void Accepts_one_select_or_with_statement(string sql) + { + Assert.Empty(Compile(sql).Errors); + } + + [Fact] + public void Drops_a_trailing_semicolon() + { + Assert.Equal("SELECT 1\n", Compile("SELECT 1;\n").Sql); + } + + [Theory] + [InlineData("INSERT INTO t VALUES (1)")] + [InlineData("SET statement_timeout = 0")] + [InlineData("(SELECT 1)")] + [InlineData("")] + [InlineData("-- only a comment")] + public void Rejects_anything_but_a_select_or_with_statement(string sql) + { + Assert.Contains(Compile(sql).Errors, e => e.Message.Contains("SELECT or WITH")); + } + + [Theory] + [InlineData("SELECT 1; SELECT 2")] + [InlineData("SELECT 1;;")] + [InlineData("SELECT 1; -- trailing\nDELETE FROM t")] + public void Rejects_more_than_one_statement(string sql) + { + Assert.Contains(Compile(sql).Errors, e => e.Message.Contains("one statement")); + } + + [Theory] + [InlineData("SELECT pg_terminate_backend(pid) FROM pg_stat_activity")] + [InlineData("SELECT PG_CANCEL_BACKEND(1)")] + [InlineData("SELECT pg_catalog.pg_reload_conf()")] + [InlineData("SELECT \"pg_terminate_backend\"(1)")] + [InlineData("SELECT nextval('s')")] + [InlineData("SELECT set_config('x', 'y', true)")] + [InlineData("SELECT txid_current()")] + [InlineData("SELECT pg_current_xact_id()")] + [InlineData("SELECT pg_advisory_lock(1)")] + [InlineData("SELECT pg_try_advisory_xact_lock(1)")] + [InlineData("SELECT pg_stat_reset_shared('wal')")] + [InlineData("SELECT pg_create_physical_replication_slot('s')")] + [InlineData("SELECT pg_drop_replication_slot('s')")] + [InlineData("SELECT pg_logical_slot_get_changes('s', NULL, NULL)")] + [InlineData("SELECT dblink('x', 'y')")] + [InlineData("SELECT lo_import('/etc/passwd')")] + [InlineData("SELECT pg_read_file('postgresql.conf')")] + [InlineData("SELECT pg_switch_wal()")] + [InlineData("SELECT pg_notify('c', 'x')")] + [InlineData("SELECT pg_logical_emit_message(false, 'x', 'y')")] + [InlineData("SELECT public.pg_stat_statements_reset()")] + [InlineData("SELECT loread(0, 1)")] + [InlineData("SELECT query_to_xml('SELECT pg_terminate_backend(1)', false, false, '')")] + [InlineData("SELECT query_to_xmlschema('SELECT 1', false, false, '')")] + [InlineData("SELECT table_to_xml('orders', false, false, '')")] + [InlineData("SELECT cursor_to_xml('c', 1, false, false, '')")] + [InlineData("SELECT schema_to_xml('public', false, false, '')")] + [InlineData("SELECT database_to_xml(false, false, '')")] + [InlineData("SELECT ts_stat('SELECT 1')")] + [InlineData("SELECT ts_rewrite('a'::tsquery, 'SELECT 1')")] + public void Rejects_functions_with_side_effects(string sql) + { + Assert.Contains(Compile(sql).Errors, e => e.Line == 1 && e.Message.Contains("side effects")); + } + + [Theory] + [InlineData("SELECT U&\"\0070g_terminate_backend\"(1)")] + [InlineData("SELECT u&\"x\" FROM t")] + public void Rejects_unicode_escaped_identifiers(string sql) + { + Assert.Contains(Compile(sql).Errors, e => e.Message.Contains("U&")); + } + + [Theory] + [InlineData("SELECT 'pg_terminate_backend'")] + [InlineData("SELECT 1 -- pg_terminate_backend(pid)")] + [InlineData("SELECT pg_logical_slot_peek_changes('s', NULL, NULL)")] + [InlineData("SELECT txid_current_if_assigned()")] + [InlineData("SELECT pg_current_xact_id_if_assigned()")] + [InlineData("SELECT pg_last_wal_replay_lsn()")] + [InlineData("SELECT pg_ls_waldir()")] + public void Allows_read_only_functions_and_mentions_in_literals(string sql) + { + Assert.Empty(Compile(sql).Errors); + } +} diff --git a/tests/Pgcheckup.Tests/Checks/FixtureTests.cs b/tests/Pgcheckup.Tests/Checks/FixtureTests.cs new file mode 100644 index 0000000..afe4217 --- /dev/null +++ b/tests/Pgcheckup.Tests/Checks/FixtureTests.cs @@ -0,0 +1,59 @@ +using Npgsql; +using Pgcheckup.Checks; +using Pgcheckup.Engine; +using Pgcheckup.Tests.Postgres; + +namespace Pgcheckup.Tests.Checks; + +// Every check runs against its own fixtures on a fresh Postgres, as the checkup role, through +// the same session and runner as a scan. `fires` must produce a finding and `healthy` must not. +public class FixtureTests +{ + private static CancellationToken Cancel => TestContext.Current.CancellationToken; + + public static TheoryData Fixtures() + { + var data = new TheoryData(); + foreach (var check in CheckCatalog.All) + { + data.Add(check.Id, "fires"); + data.Add(check.Id, "healthy"); + } + + return data; + } + + [Theory] + [MemberData(nameof(Fixtures))] + public async Task Fires_on_its_fires_fixture_and_stays_quiet_on_healthy(string checkId, string fixture) + { + var check = CheckCatalog.All.Single(c => c.Id == checkId); + if (int.Parse(PostgresServer.Version) < check.MinVersion) + { + Assert.Skip($"{checkId} needs Postgres {check.MinVersion} or later."); + } + + var script = FixtureScript.Load(checkId, fixture); + await using var server = await PostgresServer.StartAsync(Cancel); + + // Stays open until the check has run, so a fixture can hold a transaction or lock open. + await using var setup = await server.OpenSuperuserAsync(Cancel); + foreach (var statement in script.Statements) + { + await using var command = new NpgsqlCommand(statement, setup); + await command.ExecuteNonQueryAsync(Cancel); + } + + await using var session = await ReadOnlySession.OpenAsync(server.Checkup, Cancel); + var findings = await CheckRunner.RunAsync(session, script.Apply(check), Cancel); + + if (fixture == "fires") + { + Assert.NotEmpty(findings); + } + else + { + Assert.Empty(findings); + } + } +} diff --git a/tests/Pgcheckup.Tests/Checks/TemplateParserTests.cs b/tests/Pgcheckup.Tests/Checks/TemplateParserTests.cs new file mode 100644 index 0000000..44fdc89 --- /dev/null +++ b/tests/Pgcheckup.Tests/Checks/TemplateParserTests.cs @@ -0,0 +1,55 @@ +using Pgcheckup.Checks.Generator; + +namespace Pgcheckup.Tests.Checks; + +public class TemplateParserTests +{ + private static string Parse(string template) + { + var parts = TemplateParser.Parse(template, out var errors); + Assert.Empty(errors); + return TemplateText.Describe(parts); + } + + private static IReadOnlyList Errors(string template) + { + TemplateParser.Parse(template, out var errors); + return errors; + } + + [Fact] + public void Splits_text_values_and_optional_sections() + { + Assert.Equal( + "'Slot '' has been inactive'[' for ']' and holds ''.'", + Parse("Slot {subject} has been inactive[ for {inactive_for}] and holds {retained_wal:bytes}.")); + } + + [Fact] + public void Doubled_braces_and_brackets_are_literal() + { + Assert.Equal("'SELECT ARRAY[1] {x}'", Parse("SELECT ARRAY[[1]] {{x}}")); + } + + [Fact] + public void Accepts_the_count_format() + { + Assert.Equal("' IDs'", Parse("{age:count} IDs")); + } + + [Theory] + [InlineData("Slot {subject", "never closed")] + [InlineData("Slot {Subject}", "Subject")] + [InlineData("Slot {}", "{}")] + [InlineData("{size:megabytes}", "megabytes")] + [InlineData("a } b", "}")] + [InlineData("[ for {x}", "never closed")] + [InlineData("a ] b", "]")] + [InlineData("[a [b {x}]]", "inside")] + [InlineData("a[ no values ]", "no value")] + [InlineData("", "empty")] + public void Rejects_malformed_templates(string template, string expected) + { + Assert.Contains(Errors(template), e => e.Contains(expected)); + } +} diff --git a/tests/Pgcheckup.Tests/Checks/TemplateRenderTests.cs b/tests/Pgcheckup.Tests/Checks/TemplateRenderTests.cs new file mode 100644 index 0000000..64369b7 --- /dev/null +++ b/tests/Pgcheckup.Tests/Checks/TemplateRenderTests.cs @@ -0,0 +1,125 @@ +using Pgcheckup.Checks; + +namespace Pgcheckup.Tests.Checks; + +public class TemplateRenderTests +{ + private static readonly Template SlotMessage = new( + [ + new TextPart("Slot "), + new ValuePart("subject", ValueFormat.Default), + new TextPart(" has been inactive"), + new SectionPart([new TextPart(" for "), new ValuePart("inactive_for", ValueFormat.Default)]), + new TextPart(" and is holding "), + new ValuePart("retained_wal", ValueFormat.Bytes), + new TextPart(" of WAL."), + ]); + + private static string Render(Template template, params (string Name, object? Value)[] values) => + template.Render(values.ToDictionary(v => v.Name, v => v.Value)); + + private static string RenderOne(object? value, ValueFormat format = ValueFormat.Default) => + Render(new Template([new ValuePart("v", format)]), ("v", value)); + + [Fact] + public void Renders_values_into_the_text() + { + Assert.Equal( + "Slot debezium has been inactive for 3 days and is holding 48 GB of WAL.", + Render(SlotMessage, ("subject", "debezium"), ("inactive_for", new TimeSpan(3, 4, 12, 33)), ("retained_wal", 51_539_607_552L))); + } + + [Fact] + public void Leaves_out_a_section_whose_value_is_null() + { + Assert.Equal( + "Slot debezium has been inactive and is holding 48 GB of WAL.", + Render(SlotMessage, ("subject", "debezium"), ("inactive_for", null), ("retained_wal", 51_539_607_552L))); + } + + [Fact] + public void Refuses_a_null_value_outside_a_section() + { + var error = Assert.Throws(() => + Render(SlotMessage, ("subject", null), ("inactive_for", null), ("retained_wal", 1L))); + + Assert.Contains("subject", error.Message); + } + + [Fact] + public void Refuses_a_value_the_query_did_not_return() + { + var error = Assert.Throws(() => Render(SlotMessage, ("subject", "debezium"))); + + Assert.Contains("inactive_for", error.Message); + } + + [Theory] + [InlineData(3, 4, 0, 0, "3 days")] + [InlineData(1, 23, 59, 0, "1 day")] + [InlineData(0, 4, 30, 0, "4 hours")] + [InlineData(0, 1, 0, 0, "1 hour")] + [InlineData(0, 0, 12, 59, "12 minutes")] + [InlineData(0, 0, 1, 0, "1 minute")] + [InlineData(0, 0, 0, 45, "45 seconds")] + [InlineData(0, 0, 0, 1, "1 second")] + [InlineData(0, 0, 0, 0, "0 seconds")] + public void Prints_intervals_in_their_largest_whole_unit(int days, int hours, int minutes, int seconds, string expected) + { + Assert.Equal(expected, RenderOne(new TimeSpan(days, hours, minutes, seconds))); + } + + [Theory] + [InlineData(4127L, "4127")] + [InlineData("orders", "orders")] + [InlineData(true, "on")] + [InlineData(false, "off")] + public void Prints_other_values_plainly(object value, string expected) + { + Assert.Equal(expected, RenderOne(value)); + } + + [Fact] + public void Prints_decimals_and_timestamps_without_culture() + { + Assert.Equal("0.95", RenderOne(0.95m)); + Assert.Equal("2026-09-25 14:03 UTC", RenderOne(new DateTime(2026, 9, 25, 14, 3, 59, DateTimeKind.Utc))); + } + + [Theory] + [InlineData(0L, "0 bytes")] + [InlineData(1L, "1 byte")] + [InlineData(512L, "512 bytes")] + [InlineData(1536L, "1.5 kB")] + [InlineData(1_610_612_736L, "1.5 GB")] + [InlineData(51_539_607_552L, "48 GB")] + [InlineData(1_073_741_823L, "1 GB")] + [InlineData(1_825_361_101L, "1.7 GB")] + [InlineData(13_421_772_800L, "12.5 GB")] + [InlineData(1_125_899_906_842_624L, "1 PB")] + public void Prints_bytes_like_postgres_sizes(long bytes, string expected) + { + Assert.Equal(expected, RenderOne(bytes, ValueFormat.Bytes)); + } + + [Fact] + public void Prints_numeric_bytes() + { + Assert.Equal("48 GB", RenderOne(51_539_607_552m, ValueFormat.Bytes)); + } + + [Theory] + [InlineData(48_213L, "48,213")] + [InlineData(999_999L, "999,999")] + [InlineData(1_000_000L, "1 million")] + [InlineData(48_000_000L, "48 million")] + [InlineData(123_456_789L, "123 million")] + [InlineData(1_610_000_000L, "1.61 billion")] + [InlineData(2_147_483_647L, "2.15 billion")] + [InlineData(999_999_999L, "1 billion")] + [InlineData(4_000_000_000_000L, "4 trillion")] + public void Prints_large_counts_in_words(long count, string expected) + { + Assert.Equal(expected, RenderOne(count, ValueFormat.Count)); + } +} diff --git a/tests/Pgcheckup.Tests/Checks/TemplateText.cs b/tests/Pgcheckup.Tests/Checks/TemplateText.cs new file mode 100644 index 0000000..4bf449c --- /dev/null +++ b/tests/Pgcheckup.Tests/Checks/TemplateText.cs @@ -0,0 +1,16 @@ +using Pgcheckup.Checks.Generator; + +namespace Pgcheckup.Tests.Checks; + +// Writes template parts back in a compact form so expectations can be written by hand. +internal static class TemplateText +{ + public static string Describe(IEnumerable parts) => string.Concat(parts.Select(p => p switch + { + TextPart t => $"'{t.Text}'", + ValuePart { Format: null } v => $"<{v.Name}>", + ValuePart v => $"<{v.Name}:{v.Format}>", + SectionPart s => $"[{Describe(s.Parts)}]", + _ => throw new InvalidOperationException(), + })); +} diff --git a/tests/Pgcheckup.Tests/Checks/ThresholdValueTests.cs b/tests/Pgcheckup.Tests/Checks/ThresholdValueTests.cs new file mode 100644 index 0000000..dfe2a9f --- /dev/null +++ b/tests/Pgcheckup.Tests/Checks/ThresholdValueTests.cs @@ -0,0 +1,70 @@ +using Pgcheckup.Checks.Generator; + +namespace Pgcheckup.Tests.Checks; + +public class ThresholdValueTests +{ + [Theory] + [InlineData("0B", 0)] + [InlineData("1GB", 1073741824)] + [InlineData("512 MB", 536870912)] + [InlineData("1.5kB", 1536)] + [InlineData("2TB", 2199023255552)] + public void Parses_bytes_with_postgres_units(string text, long bytes) + { + Assert.True(ThresholdValue.TryParse(text, out var value, out _)); + Assert.Equal(ThresholdKind.Bytes, value.Kind); + Assert.Equal(bytes, value.Value); + } + + [Theory] + [InlineData("0s", 0)] + [InlineData("250ms", 250_000)] + [InlineData("5s", 5_000_000)] + [InlineData("30min", 1_800_000_000)] + [InlineData("1h", 3_600_000_000)] + [InlineData("2 d", 172_800_000_000)] + [InlineData("100us", 100)] + public void Parses_durations_to_microseconds(string text, long microseconds) + { + Assert.True(ThresholdValue.TryParse(text, out var value, out _)); + Assert.Equal(ThresholdKind.Duration, value.Kind); + Assert.Equal(microseconds, value.Value); + } + + [Fact] + public void Parses_a_plain_integer() + { + Assert.True(ThresholdValue.TryParse("1500000000", out var value, out _)); + Assert.Equal(ThresholdKind.Integer, value.Kind); + Assert.Equal(1_500_000_000m, value.Value); + } + + [Fact] + public void Parses_a_decimal_number() + { + Assert.True(ThresholdValue.TryParse("0.9", out var value, out _)); + Assert.Equal(ThresholdKind.Number, value.Kind); + Assert.Equal(0.9m, value.Value); + } + + [Theory] + [InlineData("")] + [InlineData("1gb")] + [InlineData("1 GiB")] + [InlineData("-1h")] + [InlineData("1e9")] + [InlineData("90%")] + [InlineData("h")] + [InlineData("1.5.1s")] + [InlineData("99999999TB")] + [InlineData("99999999999999999999")] + [InlineData("999999999999d")] + [InlineData("99999999999999999999999999999999")] + [InlineData("99999999999999999999TB")] + public void Rejects_values_postgres_would_not_accept(string text) + { + Assert.False(ThresholdValue.TryParse(text, out _, out var error)); + Assert.Contains(text.Length == 0 ? "empty" : text, error); + } +} diff --git a/tests/Pgcheckup.Tests/Cli/CommandLineTests.cs b/tests/Pgcheckup.Tests/Cli/CommandLineTests.cs new file mode 100644 index 0000000..293b285 --- /dev/null +++ b/tests/Pgcheckup.Tests/Cli/CommandLineTests.cs @@ -0,0 +1,105 @@ +using Pgcheckup.Checks; +using Pgcheckup.Cli; +using Pgcheckup.Tests.Postgres; + +namespace Pgcheckup.Tests.Cli; + +public class CommandLineTests +{ + private static readonly Dictionary NoEnvironment = []; + + internal static async Task<(int ExitCode, string Output, string Error)> RunAsync(params string[] args) + { + var output = new StringWriter { NewLine = "\n" }; + var error = new StringWriter { NewLine = "\n" }; + var exitCode = await PgcheckupCli.RunAsync(args, output, error, NoEnvironment, outputRedirected: true, TestContext.Current.CancellationToken); + return (exitCode, output.ToString(), error.ToString()); + } + + [Fact] + public async Task Lists_every_check() + { + var (exitCode, output, _) = await RunAsync("list"); + + Assert.Equal(0, exitCode); + Assert.Contains("replication-slot-inactive", output); + Assert.Contains("Inactive replication slot", output); + } + + [Theory] + [InlineData("scan", "--nope")] + [InlineData("scan", "--fail-on", "sometimes")] + [InlineData("frobnicate")] + public async Task Exits_2_when_the_arguments_are_wrong(params string[] args) + { + var (exitCode, _, error) = await RunAsync(args); + + Assert.Equal(2, exitCode); + Assert.NotEmpty(error); + } + + [Fact] + public async Task Exits_2_when_the_connection_input_is_wrong() + { + var (exitCode, _, error) = await RunAsync("scan", "mysql://db.example.com/app"); + + Assert.Equal(2, exitCode); + Assert.Contains("postgres://", error); + } + + [Fact] + public async Task Exits_2_when_it_cannot_connect() + { + var (exitCode, _, error) = await RunAsync("scan", "postgres://checkup:hunter2@127.0.0.1:1/app?connect_timeout=2"); + + Assert.Equal(2, exitCode); + Assert.Contains("couldn't connect to 127.0.0.1", error); + Assert.DoesNotContain("hunter2", error); + } +} + +public class ScanCommandTests(InactiveSlotFixture postgres) : IClassFixture +{ + [Fact] + public async Task Reports_a_warning_and_exits_0_below_the_default_fail_on() + { + var server = await postgres.ServerAsync(); + var (exitCode, output, error) = await CommandLineTests.RunAsync("scan", await postgres.CheckupUrlAsync()); + + Assert.Equal("", error); + Assert.Equal(0, exitCode); + Assert.StartsWith($"pgcheckup · app on {server.Checkup.Host} · PostgreSQL {PostgresServer.Version}.", output); + Assert.Contains("WARNING replication-slot-inactive", output); + Assert.Contains("Slot debezium has been inactive", output); + Assert.Contains("SELECT pg_drop_replication_slot('debezium');", output); + Assert.EndsWith("0 passed · 1 warning\n", output); + } + + [Fact] + public async Task Exits_2_when_a_check_fails_in_a_way_nobody_planned_for() + { + // Npgsql can't read NaN into a decimal, so the runner throws something no catch expects. + var check = new CheckDefinition( + "nan-check", "NaN check", "wal", Severity.Critical, 14, [], [], [], + "SELECT 'a' AS subject, 'NaN'::numeric AS size", + new Template([new ValuePart("size", ValueFormat.Bytes)]), + new Template([new TextPart("nothing")]), + ""); + var output = new StringWriter(); + var error = new StringWriter(); + + var exitCode = await PgcheckupCli.RunAsync( + ["scan", await postgres.CheckupUrlAsync()], [check], output, error, new Dictionary(), outputRedirected: true, TestContext.Current.CancellationToken); + + Assert.Equal(2, exitCode); + Assert.StartsWith("pgcheckup: ", error.ToString()); + } + + [Fact] + public async Task Exits_1_when_a_finding_reaches_fail_on() + { + var (exitCode, _, _) = await CommandLineTests.RunAsync("scan", await postgres.CheckupUrlAsync(), "--fail-on", "warning"); + + Assert.Equal(1, exitCode); + } +} diff --git a/tests/Pgcheckup.Tests/Cli/ConnectionInputTests.cs b/tests/Pgcheckup.Tests/Cli/ConnectionInputTests.cs new file mode 100644 index 0000000..ff517b0 --- /dev/null +++ b/tests/Pgcheckup.Tests/Cli/ConnectionInputTests.cs @@ -0,0 +1,150 @@ +using Npgsql; +using Pgcheckup.Cli; + +namespace Pgcheckup.Tests.Cli; + +public class ConnectionInputTests +{ + private static readonly Dictionary NoEnvironment = []; + + private static NpgsqlConnectionStringBuilder Parse(string? input, Dictionary? environment = null) => + ConnectionInput.Parse(input, environment ?? NoEnvironment); + + [Fact] + public void Reads_a_postgres_url() + { + var settings = Parse("postgres://checkup:s%40cret@db.example.com:5433/app?sslmode=verify-full"); + + Assert.Equal("db.example.com", settings.Host); + Assert.Equal(5433, settings.Port); + Assert.Equal("app", settings.Database); + Assert.Equal("checkup", settings.Username); + Assert.Equal("s@cret", settings.Password); + Assert.Equal(SslMode.VerifyFull, settings.SslMode); + } + + [Fact] + public void Reads_a_postgresql_url_without_user_or_port() + { + var settings = Parse("postgresql://db.example.com/app"); + + Assert.Equal("db.example.com", settings.Host); + Assert.Equal(5432, settings.Port); + Assert.Equal("app", settings.Database); + Assert.Null(settings.Username); + } + + [Fact] + public void Reads_ipv6_and_several_hosts() + { + Assert.Equal("::1", Parse("postgres://[::1]:5432/app").Host); + Assert.Equal("h1.example.com:5432,h2.example.com:5433", Parse("postgres://h1.example.com:5432,h2.example.com:5433/app").Host); + } + + [Fact] + public void Reads_a_socket_directory_from_the_host_parameter() + { + var settings = Parse("postgres:///app?host=%2Fvar%2Frun%2Fpostgresql"); + + Assert.Equal("/var/run/postgresql", settings.Host); + Assert.Equal("app", settings.Database); + } + + [Fact] + public void Maps_libpq_parameters_to_npgsql() + { + var settings = Parse("postgres://db.example.com/app?connect_timeout=10&target_session_attrs=read-write&sslrootcert=ca.pem&sslcert=client.pem&sslkey=client.key&passfile=pass.conf"); + + Assert.Equal(10, settings.Timeout); + Assert.Equal("read-write", settings.TargetSessionAttributes); + Assert.Equal("ca.pem", settings.RootCertificate); + Assert.Equal("client.pem", settings.SslCertificate); + Assert.Equal("client.key", settings.SslKey); + Assert.Equal("pass.conf", settings.Passfile); + } + + [Fact] + public void Ignores_the_application_name_because_pgcheckup_sets_its_own() + { + Assert.Null(Parse("postgres://db.example.com/app?application_name=myapp").ApplicationName); + } + + [Fact] + public void Reads_a_libpq_key_value_string() + { + var settings = Parse("host=db.example.com port=5433 dbname=app user=checkup sslmode=require password='a b\\'c'"); + + Assert.Equal("db.example.com", settings.Host); + Assert.Equal(5433, settings.Port); + Assert.Equal("app", settings.Database); + Assert.Equal("checkup", settings.Username); + Assert.Equal(SslMode.Require, settings.SslMode); + Assert.Equal("a b'c", settings.Password); + } + + [Fact] + public void Fills_what_the_input_leaves_out_from_pg_environment_variables() + { + var environment = new Dictionary + { + ["PGHOST"] = "db.example.com", + ["PGPORT"] = "6543", + ["PGDATABASE"] = "app", + ["PGSSLMODE"] = "require", + }; + + var fromEnvironment = Parse(null, environment); + Assert.Equal("db.example.com", fromEnvironment.Host); + Assert.Equal(6543, fromEnvironment.Port); + Assert.Equal("app", fromEnvironment.Database); + Assert.Equal(SslMode.Require, fromEnvironment.SslMode); + + var mixed = Parse("postgres://other.example.com/app", environment); + Assert.Equal("other.example.com", mixed.Host); + Assert.Equal(6543, mixed.Port); + } + + [Fact] + public void Connects_to_localhost_when_nothing_names_a_host() + { + Assert.Equal("localhost", Parse(null).Host); + Assert.Equal("localhost", Parse("dbname=app").Host); + } + + [Fact] + public void Reads_a_password_with_a_question_mark_as_psql_does() + { + // libpq takes everything up to the first @ that comes before any / as credentials. + Assert.Equal("hun?ter2", Parse("postgres://checkup:hun?ter2@db.example.com/app").Password); + } + + [Theory] + [InlineData("postgres://db.example.com/app?colour=blue", "parameter pgcheckup doesn't read")] + [InlineData("host=db.example.com colour=blue", "parameter pgcheckup doesn't read")] + [InlineData("postgres://db.example.com/app?sslmode=sometimes", "verify-full")] + [InlineData("postgres://db.example.com:abc/app", "port")] + [InlineData("mysql://db.example.com/app", "postgres://")] + [InlineData("host=db.example.com password='unterminated", "quote")] + [InlineData("host=db.example.com dbname", "key=value")] + public void Explains_what_is_wrong_with_the_input(string input, string expected) + { + var error = Assert.Throws(() => Parse(input)); + + Assert.Contains(expected, error.Message); + } + + [Theory] + [InlineData("postgres://checkup:hunter2@db.example.com:abc/app", "hunter2", "abc")] + [InlineData("postgres://checkup:hun/ter2@db.example.com/app", "hun", "ter2")] + [InlineData("host=db.example.com password=hunter2 colour=blue", "hunter2", "colour")] + [InlineData("host=db.example.com password=hun ter2", "hun", "ter2")] + [InlineData("host=db.example.com port=hunter2", "hunter2", "hunter2")] + [InlineData("host=db.example.com sslmode=hunter2", "hunter2", "hunter2")] + public void Never_repeats_any_of_the_input_in_an_error(string input, string first, string second) + { + var error = Assert.Throws(() => Parse(input)); + + Assert.DoesNotContain(first, error.Message); + Assert.DoesNotContain(second, error.Message); + } +} diff --git a/tests/Pgcheckup.Tests/Cli/NativeBinaryTests.cs b/tests/Pgcheckup.Tests/Cli/NativeBinaryTests.cs new file mode 100644 index 0000000..99fa4bb --- /dev/null +++ b/tests/Pgcheckup.Tests/Cli/NativeBinaryTests.cs @@ -0,0 +1,49 @@ +using System.Diagnostics; +using Pgcheckup.Tests.Postgres; + +namespace Pgcheckup.Tests.Cli; + +// M0's acceptance test: the published NativeAOT binary reports an inactive slot and exits 1 with +// --fail-on warning. CI's AOT job points PGCHECKUP_BINARY at the binary it just published. +public class NativeBinaryTests(InactiveSlotFixture postgres) : IClassFixture +{ + private const string NoBinary = "Set PGCHECKUP_BINARY to a published pgcheckup binary to run this test."; + + public static bool HasBinary => Binary.Length > 0; + + private static string Binary => Environment.GetEnvironmentVariable("PGCHECKUP_BINARY") ?? ""; + + private static async Task<(int ExitCode, string Output)> RunAsync(params string[] args) + { + var start = new ProcessStartInfo(Binary) { RedirectStandardOutput = true, RedirectStandardError = true }; + foreach (var arg in args) + { + start.ArgumentList.Add(arg); + } + + using var process = Process.Start(start)!; + var output = process.StandardOutput.ReadToEndAsync(TestContext.Current.CancellationToken); + var error = process.StandardError.ReadToEndAsync(TestContext.Current.CancellationToken); + await process.WaitForExitAsync(TestContext.Current.CancellationToken); + return (process.ExitCode, await output + await error); + } + + [Fact(Skip = NoBinary, SkipUnless = nameof(HasBinary))] + public async Task Reports_the_inactive_slot_and_exits_1_with_fail_on_warning() + { + var (exitCode, output) = await RunAsync("scan", await postgres.CheckupUrlAsync(), "--fail-on", "warning"); + + Assert.Equal(1, exitCode); + Assert.Contains("WARNING replication-slot-inactive", output); + Assert.Contains("Slot debezium has been inactive", output); + } + + [Fact(Skip = NoBinary, SkipUnless = nameof(HasBinary))] + public async Task Prints_its_version() + { + var (exitCode, output) = await RunAsync("--version"); + + Assert.Equal(0, exitCode); + Assert.NotEmpty(output.Trim()); + } +} diff --git a/tests/Pgcheckup.Tests/Cli/TerminalReportTests.cs b/tests/Pgcheckup.Tests/Cli/TerminalReportTests.cs new file mode 100644 index 0000000..590ce30 --- /dev/null +++ b/tests/Pgcheckup.Tests/Cli/TerminalReportTests.cs @@ -0,0 +1,120 @@ +using Pgcheckup.Checks; +using Pgcheckup.Cli; +using Pgcheckup.Engine; + +namespace Pgcheckup.Tests.Cli; + +public class TerminalReportTests +{ + private static readonly ServerInfo Server = new("app", "db.example.com", "17.6"); + + private static CheckDefinition Check(string id, Severity severity = Severity.Warning) => new( + id, id, "wal", severity, 14, [], [], [], "SELECT 1", new Template([]), new Template([]), ""); + + private static Finding Finding(string checkId, Severity severity, string message, string fix) => + new(checkId, "subject", severity, message, fix, new Dictionary()); + + private static string Render(ScanReport report, bool color = false) + { + var output = new StringWriter { NewLine = "\n" }; + TerminalReport.Write(output, report, color); + return output.ToString(); + } + + [Fact] + public void Writes_each_finding_with_its_fix_under_a_header_and_above_a_summary() + { + var slot = Check("replication-slot-inactive"); + var report = new ScanReport(Server, + [ + new CheckResult(Check("connection-saturation"), []), + new CheckResult(slot, + [ + Finding(slot.Id, Severity.Warning, + "Slot debezium has been inactive for 3 days and is holding 48 GB of WAL.", + "restart its consumer, or drop the slot:\nSELECT pg_drop_replication_slot('debezium');"), + ]), + ]); + + Assert.Equal( + """ + pgcheckup · app on db.example.com · PostgreSQL 17.6 + + WARNING replication-slot-inactive + Slot debezium has been inactive for 3 days and is holding 48 GB of WAL. + Fix: restart its consumer, or drop the slot: + SELECT pg_drop_replication_slot('debezium'); + + 1 passed · 1 warning + + """.ReplaceLineEndings("\n"), + Render(report)); + } + + [Fact] + public void Lists_critical_findings_first_and_counts_each_check_once_at_its_worst() + { + var slot = Check("replication-slot-inactive"); + var xid = Check("xid-wraparound", Severity.Critical); + var report = new ScanReport(Server, + [ + new CheckResult(slot, + [ + Finding(slot.Id, Severity.Warning, "Slot a is inactive.", "drop a"), + Finding(slot.Id, Severity.Critical, "Slot b is inactive.", "drop b"), + ]), + new CheckResult(xid, [Finding(xid.Id, Severity.Critical, "Table orders is old.", "vacuum orders")]), + new CheckResult(Check("other-slot"), [Finding("other-slot", Severity.Warning, "Other.", "fix")]), + ]); + + var lines = Render(report).Split('\n'); + + Assert.Equal( + ["CRITICAL replication-slot-inactive", "CRITICAL xid-wraparound", "WARNING other-slot", "WARNING replication-slot-inactive"], + lines.Where(l => l.Length > 0 && !l.StartsWith(' ') && (l.StartsWith("CRITICAL") || l.StartsWith("WARNING")))); + Assert.Equal("0 passed · 2 critical · 1 warning", lines[^2]); + } + + [Fact] + public void Says_how_many_passed_when_nothing_is_found() + { + var report = new ScanReport(Server, [new CheckResult(Check("a"), []), new CheckResult(Check("b"), [])]); + + Assert.Equal("pgcheckup · app on db.example.com · PostgreSQL 17.6\n\n2 passed\n", Render(report)); + } + + [Fact] + public void Pluralises_warnings() + { + var report = new ScanReport(Server, + [ + new CheckResult(Check("a"), [Finding("a", Severity.Warning, "A.", "fix")]), + new CheckResult(Check("b"), [Finding("b", Severity.Warning, "B.", "fix")]), + ]); + + Assert.EndsWith("0 passed · 2 warnings\n", Render(report)); + } + + [Fact] + public void Colours_the_severity_word_only_when_asked() + { + var report = new ScanReport(Server, [new CheckResult(Check("a"), [Finding("a", Severity.Warning, "A.", "fix")])]); + + Assert.DoesNotContain("\u001b", Render(report, color: false)); + var coloured = Render(report, color: true); + Assert.Contains("\u001b[1;33mWARNING\u001b[0m", coloured); + } + + [Theory] + [InlineData(false, null, null, true)] + [InlineData(true, null, null, false)] + [InlineData(false, "1", null, false)] + [InlineData(false, "", null, true)] + [InlineData(false, null, "dumb", false)] + public void Uses_colour_only_on_a_terminal_without_NO_COLOR(bool redirected, string? noColor, string? term, bool expected) + { + var environment = new Dictionary { ["NO_COLOR"] = noColor, ["TERM"] = term }; + + Assert.Equal(expected, TerminalReport.UseColor(redirected, environment)); + } +} diff --git a/tests/Pgcheckup.Tests/Engine/CheckRunnerTests.cs b/tests/Pgcheckup.Tests/Engine/CheckRunnerTests.cs new file mode 100644 index 0000000..89140a3 --- /dev/null +++ b/tests/Pgcheckup.Tests/Engine/CheckRunnerTests.cs @@ -0,0 +1,93 @@ +using Pgcheckup.Checks; +using Pgcheckup.Engine; +using Pgcheckup.Tests.Postgres; + +namespace Pgcheckup.Tests.Engine; + +public class CheckRunnerTests(PostgresServerFixture postgres) : IClassFixture +{ + private static CancellationToken Cancel => TestContext.Current.CancellationToken; + + private static CheckDefinition Check(string sql, params Threshold[] thresholds) => new( + Id: "sample-check", + Title: "Sample check", + Category: "wal", + Severity: Severity.Warning, + MinVersion: 14, + Privileges: [], + SkipOn: [], + Thresholds: thresholds, + Sql: sql, + Message: new Template([new TextPart("Thing "), new ValuePart("subject", ValueFormat.Default), new TextPart(" holds "), new ValuePart("size", ValueFormat.Bytes), new TextPart(".")]), + Fix: new Template([new TextPart("Drop "), new ValuePart("subject", ValueFormat.Default), new TextPart(".")]), + Note: ""); + + private async Task> RunAsync(CheckDefinition check) + { + await using var session = await ReadOnlySession.OpenAsync(postgres.Server.Checkup, Cancel); + return await CheckRunner.RunAsync(session, check, Cancel); + } + + [Fact] + public async Task Reports_a_finding_per_row_with_its_message_and_fix() + { + var findings = await RunAsync(Check("SELECT 'a' AS subject, 1024::bigint AS size UNION ALL SELECT 'b', 2048")); + + Assert.Collection( + findings, + f => + { + Assert.Equal("sample-check", f.CheckId); + Assert.Equal("a", f.Subject); + Assert.Equal(Severity.Warning, f.Severity); + Assert.Equal("Thing a holds 1 kB.", f.Message); + Assert.Equal("Drop a.", f.Fix); + }, + f => Assert.Equal("Thing b holds 2 kB.", f.Message)); + } + + [Fact] + public async Task Reports_nothing_when_the_query_returns_no_rows() + { + Assert.Empty(await RunAsync(Check("SELECT 'a' AS subject, 1 AS size WHERE false"))); + } + + [Fact] + public async Task Lets_a_row_raise_its_severity() + { + var findings = await RunAsync(Check( + "SELECT 'a' AS subject, 1 AS size, 'critical' AS severity UNION ALL SELECT 'b', 1, NULL")); + + Assert.Equal([Severity.Critical, Severity.Warning], findings.Select(f => f.Severity)); + } + + [Fact] + public async Task Binds_thresholds_as_typed_parameters_in_order() + { + var check = Check( + "SELECT 'a' AS subject, 1 AS size WHERE $1 = 1073741824::bigint AND $2 = interval '1 hour' AND $3 = 5 AND $4 = 0.9", + new Threshold("min_size", ThresholdKind.Bytes, 1_073_741_824m, "1GB"), + new Threshold("min_age", ThresholdKind.Duration, 3_600_000_000m, "1h"), + new Threshold("min_count", ThresholdKind.Integer, 5m, "5"), + new Threshold("min_ratio", ThresholdKind.Number, 0.9m, "0.9")); + + Assert.Single(await RunAsync(check)); + } + + [Fact] + public async Task Rejects_a_row_without_a_subject() + { + var error = await Assert.ThrowsAsync(() => RunAsync(Check("SELECT NULL::text AS subject, 1 AS size"))); + + Assert.Contains("subject", error.Message); + } + + [Fact] + public async Task Rejects_an_unknown_severity() + { + var error = await Assert.ThrowsAsync(() => + RunAsync(Check("SELECT 'a' AS subject, 1 AS size, 'high' AS severity"))); + + Assert.Contains("high", error.Message); + } +} diff --git a/tests/Pgcheckup.Tests/Engine/ReadOnlySessionTests.cs b/tests/Pgcheckup.Tests/Engine/ReadOnlySessionTests.cs new file mode 100644 index 0000000..b5776f3 --- /dev/null +++ b/tests/Pgcheckup.Tests/Engine/ReadOnlySessionTests.cs @@ -0,0 +1,134 @@ +using Npgsql; +using Pgcheckup.Engine; +using Pgcheckup.Tests.Postgres; + +namespace Pgcheckup.Tests.Engine; + +public class ReadOnlySessionTests(PostgresServerFixture postgres) : IClassFixture +{ + private static CancellationToken Cancel => TestContext.Current.CancellationToken; + + private async Task>> QueryAsync(string sql, params object[] parameters) + { + await using var session = await ReadOnlySession.OpenAsync(postgres.Server.Checkup, Cancel); + return await session.QueryAsync(sql, parameters, Cancel); + } + + [Fact] + public async Task Runs_each_query_read_only_with_statement_and_lock_timeouts() + { + var row = Assert.Single(await QueryAsync(""" + SELECT current_setting('transaction_read_only') AS read_only, + current_setting('statement_timeout') AS statement_timeout, + current_setting('lock_timeout') AS lock_timeout, + current_setting('application_name') AS application_name + """)); + + Assert.Equal("on", row["read_only"]); + Assert.Equal("5s", row["statement_timeout"]); + Assert.Equal("1s", row["lock_timeout"]); + Assert.Equal("pgcheckup", row["application_name"]); + } + + [Fact] + public async Task Resolves_names_in_pg_catalog_before_anything_a_user_planted() + { + // An exact argument-type match in public would otherwise beat pg_catalog's polymorphic + // quote_literal(anyelement), and run as the scanning role (CVE-2018-1058). + await postgres.Server.ExecuteAsSuperuserAsync( + Cancel, + "CREATE FUNCTION public.quote_literal(name) RETURNS text LANGUAGE sql AS $$ SELECT 'planted' $$"); + + var row = Assert.Single(await QueryAsync("SELECT quote_literal('x'::name) AS quoted, current_setting('search_path') AS search_path")); + + Assert.Equal("'x'", row["quoted"]); + Assert.Equal("pg_catalog, pg_temp", row["search_path"]); + } + + [Fact] + public async Task Ends_the_transaction_after_each_query() + { + await using var session = await ReadOnlySession.OpenAsync(postgres.Server.Checkup, Cancel); + + var first = Assert.Single(await session.QueryAsync("SELECT now() AS started", [], Cancel))["started"]; + var second = Assert.Single(await session.QueryAsync("SELECT now() AS started", [], Cancel))["started"]; + + Assert.NotEqual(first, second); + } + + [Fact] + public async Task Sets_nothing_for_the_whole_session() + { + await using var session = await ReadOnlySession.OpenAsync(postgres.Server.Checkup, Cancel); + await session.QueryAsync("SELECT 1 AS one", [], Cancel); + + // Behind a transaction pooler, anything set for the session reaches the app's next + // transaction. reset_val shows settings sent when connecting. + var row = Assert.Single(await session.QueryAsync(""" + SELECT current_setting('default_transaction_read_only') AS read_only, + (SELECT reset_val FROM pg_settings WHERE name = 'statement_timeout') AS statement_timeout, + (SELECT reset_val FROM pg_settings WHERE name = 'lock_timeout') AS lock_timeout + """, [], Cancel)); + + Assert.Equal("off", row["read_only"]); + Assert.Equal("0", row["statement_timeout"]); + Assert.Equal("0", row["lock_timeout"]); + } + + [Fact] + public async Task Rejects_a_write_even_when_the_role_may_write() + { + await postgres.Server.ExecuteAsSuperuserAsync(Cancel, "CREATE TABLE written (n int)", "GRANT INSERT ON written TO checkup"); + + var error = await Assert.ThrowsAsync(() => + QueryAsync("WITH w AS (INSERT INTO public.written VALUES (1) RETURNING n) SELECT n FROM w")); + + Assert.Equal(PostgresErrorCodes.ReadOnlySqlTransaction, error.SqlState); + } + + [Fact] + public async Task Rejects_a_second_statement() + { + var error = await Assert.ThrowsAsync(() => QueryAsync("SELECT 1; SELECT 2")); + + Assert.Equal(PostgresErrorCodes.SyntaxError, error.SqlState); + } + + [Fact] + public async Task Gives_up_on_a_lock_instead_of_queueing_behind_it() + { + await postgres.Server.ExecuteAsSuperuserAsync(Cancel, "CREATE TABLE migrating (n int)", "GRANT SELECT ON migrating TO checkup"); + await using var migration = await postgres.Server.OpenSuperuserAsync(Cancel); + await using var transaction = await migration.BeginTransactionAsync(Cancel); + await using (var lockTable = new NpgsqlCommand("LOCK TABLE migrating IN ACCESS EXCLUSIVE MODE", migration, transaction)) + { + await lockTable.ExecuteNonQueryAsync(Cancel); + } + + var error = await Assert.ThrowsAsync(() => QueryAsync("SELECT count(*) AS n FROM public.migrating")); + + Assert.Equal(PostgresErrorCodes.LockNotAvailable, error.SqlState); + } + + [Fact] + public async Task Binds_threshold_parameters_by_position() + { + var row = Assert.Single(await QueryAsync( + "SELECT $1 AS bytes, $2 AS age, $3 AS ratio", + 1_073_741_824L, + TimeSpan.FromHours(1), + 0.9m)); + + Assert.Equal(1_073_741_824L, row["bytes"]); + Assert.Equal(TimeSpan.FromHours(1), row["age"]); + Assert.Equal(0.9m, row["ratio"]); + } + + [Fact] + public async Task Returns_null_for_sql_null() + { + var row = Assert.Single(await QueryAsync("SELECT NULL::interval AS missing")); + + Assert.Null(row["missing"]); + } +} diff --git a/tests/Pgcheckup.Tests/Pgcheckup.Tests.csproj b/tests/Pgcheckup.Tests/Pgcheckup.Tests.csproj new file mode 100644 index 0000000..dbff69f --- /dev/null +++ b/tests/Pgcheckup.Tests/Pgcheckup.Tests.csproj @@ -0,0 +1,21 @@ + + + Exe + false + + + + + + + + + + + + + + + + + diff --git a/tests/Pgcheckup.Tests/Postgres/FixtureScript.cs b/tests/Pgcheckup.Tests/Postgres/FixtureScript.cs new file mode 100644 index 0000000..46491f8 --- /dev/null +++ b/tests/Pgcheckup.Tests/Postgres/FixtureScript.cs @@ -0,0 +1,80 @@ +using System.Text.RegularExpressions; +using Pgcheckup.Checks; +using Pgcheckup.Checks.Generator; +using RuntimeThresholdKind = Pgcheckup.Checks.ThresholdKind; + +namespace Pgcheckup.Tests.Postgres; + +// A check's fixture: setup statements, plus `-- threshold name = value` lines that lower a +// threshold when the real condition can't be reproduced at full scale. +internal sealed partial class FixtureScript +{ + private FixtureScript(IReadOnlyList statements, IReadOnlyDictionary thresholds) + { + Statements = statements; + Thresholds = thresholds; + } + + public IReadOnlyList Statements { get; } + + public IReadOnlyDictionary Thresholds { get; } + + public static FixtureScript Load(string checkId, string fixture) => + Parse(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "checks", checkId, "fixtures", fixture + ".sql"))); + + public static FixtureScript Parse(string text) + { + var thresholds = ThresholdLine().Matches(text).ToDictionary(m => m.Groups[1].Value, m => m.Groups[2].Value.Trim()); + + var errors = new List(); + var tokens = SqlTokenizer.Tokenize(text, errors); + if (errors.Count > 0) + { + throw new InvalidOperationException($"The fixture doesn't parse: {string.Join("; ", errors)}"); + } + + var statements = new List(); + var start = 0; + foreach (var end in tokens.Where(t => t.Kind == TokenKind.Semicolon).Select(t => t.Start).Append(text.Length)) + { + if (tokens.Any(t => t.Start >= start && t.Start < end && t.Kind != TokenKind.Semicolon)) + { + statements.Add(text[start..end].Trim()); + } + + start = end + 1; + } + + return new FixtureScript(statements, thresholds); + } + + public CheckDefinition Apply(CheckDefinition check) + { + var unknown = Thresholds.Keys.Except(check.Thresholds.Select(t => t.Name)).ToList(); + if (unknown.Count > 0) + { + throw new InvalidOperationException($"The fixture lowers {string.Join(", ", unknown)}, which {check.Id} doesn't have."); + } + + return check with + { + Thresholds = check.Thresholds.Select(t => + { + if (!Thresholds.TryGetValue(t.Name, out var text)) + { + return t; + } + + if (!ThresholdValue.TryParse(text, out var value, out var error) || value.Kind.ToString() != t.Kind.ToString()) + { + throw new InvalidOperationException($"The fixture sets {t.Name} to {text}, which isn't a {t.Kind} value. {error}"); + } + + return t with { Value = value.Value, Text = text }; + }).ToList(), + }; + } + + [GeneratedRegex(@"^--\s*threshold\s+([a-z][a-z0-9_]*)\s*=\s*(.+)$", RegexOptions.Multiline)] + private static partial Regex ThresholdLine(); +} diff --git a/tests/Pgcheckup.Tests/Postgres/InactiveSlotFixture.cs b/tests/Pgcheckup.Tests/Postgres/InactiveSlotFixture.cs new file mode 100644 index 0000000..3b19422 --- /dev/null +++ b/tests/Pgcheckup.Tests/Postgres/InactiveSlotFixture.cs @@ -0,0 +1,43 @@ +using Npgsql; + +namespace Pgcheckup.Tests.Postgres; + +// A Postgres whose inactive slot holds more WAL than replication-slot-inactive's default 1 GB, +// so a scan with default thresholds reports it. It starts on first use, so skipped tests cost nothing. +public sealed class InactiveSlotFixture : IAsyncDisposable +{ + private readonly Lazy> server = new(StartAsync); + + public Task ServerAsync() => server.Value; + + public async Task CheckupUrlAsync() + { + var checkup = (await ServerAsync()).Checkup; + return $"postgres://checkup:checkup@{checkup.Host}:{checkup.Port}/app"; + } + + public async ValueTask DisposeAsync() + { + if (server.IsValueCreated) + { + await (await server.Value).DisposeAsync(); + } + } + + // Not tied to the first test's cancellation, because every test in the class shares it. + private static async Task StartAsync() + { + var server = await PostgresServer.StartAsync(CancellationToken.None); + await server.ExecuteAsSuperuserAsync(CancellationToken.None, "SELECT pg_create_physical_replication_slot('debezium', true)"); + + // Logical messages add WAL without writing table data, which keeps this fast. + await using var connection = await server.OpenSuperuserAsync(CancellationToken.None); + for (var i = 0; i < 17; i++) + { + await using var command = new NpgsqlCommand("SELECT pg_logical_emit_message(false, 'pgcheckup', repeat('x', 64 * 1024 * 1024))", connection); + await command.ExecuteNonQueryAsync(); + } + + return server; + } +} diff --git a/tests/Pgcheckup.Tests/Postgres/PostgresServer.cs b/tests/Pgcheckup.Tests/Postgres/PostgresServer.cs new file mode 100644 index 0000000..8cc6521 --- /dev/null +++ b/tests/Pgcheckup.Tests/Postgres/PostgresServer.cs @@ -0,0 +1,63 @@ +using Npgsql; +using Testcontainers.PostgreSql; + +namespace Pgcheckup.Tests.Postgres; + +// A throwaway Postgres with the placeholder database `app` and a `checkup` role that has only +// pg_monitor, the least privilege pgcheckup promises to need. +public sealed class PostgresServer : IAsyncDisposable +{ + private readonly PostgreSqlContainer container; + + private PostgresServer(PostgreSqlContainer container) => this.container = container; + + // CI runs the suite once per supported major, for example PGCHECKUP_TEST_POSTGRES=14. + public static string Version => + Environment.GetEnvironmentVariable("PGCHECKUP_TEST_POSTGRES") is { Length: > 0 } version ? version : "18"; + + public string SuperuserConnectionString => container.GetConnectionString(); + + public NpgsqlConnectionStringBuilder Checkup => new(container.GetConnectionString()) + { + Username = "checkup", + Password = "checkup", + }; + + public static async Task StartAsync(CancellationToken cancellationToken) + { + var container = new PostgreSqlBuilder($"postgres:{Version}-alpine").WithDatabase("app").Build(); + await container.StartAsync(cancellationToken); + var server = new PostgresServer(container); + await server.ExecuteAsSuperuserAsync(cancellationToken, "CREATE ROLE checkup LOGIN PASSWORD 'checkup' IN ROLE pg_monitor"); + return server; + } + + public async Task OpenSuperuserAsync(CancellationToken cancellationToken) + { + var connection = new NpgsqlConnection(SuperuserConnectionString); + await connection.OpenAsync(cancellationToken); + return connection; + } + + // One statement per string: SQL rewriting is off in tests too, as it is in pgcheckup. + public async Task ExecuteAsSuperuserAsync(CancellationToken cancellationToken, params string[] statements) + { + await using var connection = await OpenSuperuserAsync(cancellationToken); + foreach (var sql in statements) + { + await using var command = new NpgsqlCommand(sql, connection); + await command.ExecuteNonQueryAsync(cancellationToken); + } + } + + public ValueTask DisposeAsync() => container.DisposeAsync(); +} + +public sealed class PostgresServerFixture : IAsyncLifetime +{ + public PostgresServer Server { get; private set; } = null!; + + public async ValueTask InitializeAsync() => Server = await PostgresServer.StartAsync(TestContext.Current.CancellationToken); + + public ValueTask DisposeAsync() => Server.DisposeAsync(); +}