Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
name: static-ci

on:
pull_request:
push:
branches: [main]

permissions:
contents: read

jobs:
static:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- uses: hashicorp/setup-terraform@v3
- name: Go formatting
run: test -z "$(gofmt -l cmd internal)"
- name: Go tests and vet
run: make pgsafe-test
- name: Build and static acceptance fixtures
run: make pgsafe-fixtures
- name: Terraform formatting and validation
run: make tf-validate
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,4 @@ override.tf.json
.terraformrc
terraform.rc
.pgsentry/
/bin/
31 changes: 30 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ SCENARIO ?= primary-service-loss
TRIAL ?= 1
MODE ?= async
CHAOS_SCENARIO ?= primary-dcs-isolation
FILE ?= testdata/migrations/safe/staged.sql
PGSAFE ?= ./bin/pgsafe
TERRAFORM ?= terraform
TF_DIR := terraform/environments/$(PROFILE)
VALID_PROFILES := full colocated

.PHONY: tf-fmt tf-validate cluster-up cluster-down check-profile pg-configure pg-verify pg-failover pg-clean etcd-configure etcd-verify etcd-quorum-test etcd-clean patroni-configure patroni-verify haproxy-configure haproxy-verify patroni-failover-test failure-baseline failure-scenario failure-matrix failure-report failure-clean durability-set durability-verify durability-latency durability-failure-test durability-standby-loss durability-strict-test durability-matrix durability-report durability-clean chaos-baseline chaos-scenario chaos-matrix chaos-report chaos-clean
.PHONY: tf-fmt tf-validate cluster-up cluster-down check-profile pg-configure pg-verify pg-failover pg-clean etcd-configure etcd-verify etcd-quorum-test etcd-clean patroni-configure patroni-verify haproxy-configure haproxy-verify patroni-failover-test failure-baseline failure-scenario failure-matrix failure-report failure-clean durability-set durability-verify durability-latency durability-failure-test durability-standby-loss durability-strict-test durability-matrix durability-report durability-clean chaos-baseline chaos-scenario chaos-matrix chaos-report chaos-clean pgsafe-build pgsafe-test pgsafe-check pgsafe-fixtures migration-baseline migration-runtime-test migration-clean

tf-fmt:
$(TERRAFORM) fmt -recursive terraform
Expand Down Expand Up @@ -126,3 +128,30 @@ chaos-report:

chaos-clean:
rm -rf .pgsentry/results/m7

pgsafe-build:
mkdir -p bin
go build -ldflags "-X main.version=m8" -o $(PGSAFE) ./cmd/pgsafe

pgsafe-test:
go test ./...
go vet ./...

pgsafe-check: pgsafe-build
$(PGSAFE) check $(FILE)

pgsafe-fixtures: pgsafe-build
$(PGSAFE) check testdata/migrations/safe/staged.sql
@$(PGSAFE) check testdata/migrations/unsafe/risky.sql >/dev/null 2>&1; test $$? -eq 1
@$(PGSAFE) check testdata/migrations/edge/malformed.sql >/dev/null 2>&1; test $$? -eq 2
$(PGSAFE) check testdata/migrations/edge/suppressed.sql
$(PGSAFE) check testdata/migrations/edge/quoted.sql --format=json >/dev/null

migration-baseline: check-profile
./scripts/migrations/baseline.sh $(PROFILE)

migration-runtime-test: check-profile
./scripts/migrations/runtime-test.sh $(PROFILE)

migration-clean: check-profile
./scripts/migrations/clean.sh $(PROFILE)
17 changes: 16 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ M1 provides VM infrastructure, M2 demonstrates manual PostgreSQL replication, M3
| M5 | Automated failure and durability harness | Implemented and verified |
| M6 | Synchronous durability experiments | Implemented and verified |
| M7 | Network-partition and DCS chaos | Complete |
| M8 | pgsafe migration safety CLI | Planned |
| M8 | pgsafe migration safety CLI | Complete |
| M9 | Backup, WAL, and PITR verification | Planned |
| M10 | Observability, runbooks, and portfolio polish | Planned |

Expand Down Expand Up @@ -138,3 +138,18 @@ make chaos-report
```

Every scenario is destructive but self-restoring. Normal writes remain on the same HAProxy endpoint, and direct SQL sampling fails immediately if more than one writable primary is observed. See [the M7 runbook](docs/m7-network-dcs-chaos.md).

## M8 quick start

`pgsafe` statically analyzes PostgreSQL 16 migration SQL and never connects to or executes against a database:

```bash
make pgsafe-build
./bin/pgsafe check migration.sql
./bin/pgsafe check migration.sql --format=json --fail-on=high
./bin/pgsafe check migration.sql --transaction-mode=wrapped
./bin/pgsafe rules
./bin/pgsafe explain PGSAFE001
```

Static fixtures run in hosted CI with `make pgsafe-fixtures`. The explicitly named `make migration-runtime-test PROFILE=full` separately manipulates only disposable objects on the canonical local cluster. See [the M8 runbook](docs/m8-pgsafe.md) and [rule catalog](docs/pgsafe-rules.md).
93 changes: 93 additions & 0 deletions cmd/pgsafe/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package main

import (
"fmt"
"os"
"strings"

"github.com/nhatminh06/pgsentry/internal/pgsafe"
)

var version = "dev"

func usage() {
fmt.Fprintln(os.Stderr, "usage: pgsafe check [--format=text|json] [--fail-on=SEVERITY] [--transaction-mode=single|wrapped] <file...>\n pgsafe rules\n pgsafe explain <RULE_ID>\n pgsafe version")
}
func main() { os.Exit(run(os.Args[1:])) }
func run(args []string) int {
if len(args) == 0 {
usage()
return 2
}
switch args[0] {
case "version":
fmt.Printf("pgsafe %s (PostgreSQL 16 rules)\n", version)
return 0
case "rules":
for _, r := range pgsafe.Rules() {
fmt.Printf("%s %-8s %s\n", r.ID, r.Severity, r.Name)
}
return 0
case "explain":
if len(args) != 2 {
usage()
return 2
}
r, ok := pgsafe.RuleByID(strings.ToUpper(args[1]))
if !ok {
fmt.Fprintln(os.Stderr, "unknown rule")
return 2
}
fmt.Printf("%s %s\nSeverity: %s\nConfidence: %s\nRisk: %s\nSafer alternative: %s\n", r.ID, r.Name, r.Severity, r.Confidence, r.Risk, r.Alternative)
return 0
case "check":
return check(args[1:])
default:
usage()
return 2
}
}
func check(args []string) int {
format := "text"
fail := pgsafe.High
mode := "single"
files := []string{}
for _, a := range args {
switch {
case strings.HasPrefix(a, "--format="):
format = strings.TrimPrefix(a, "--format=")
case strings.HasPrefix(a, "--fail-on="):
var ok bool
fail, ok = pgsafe.ParseSeverity(strings.TrimPrefix(a, "--fail-on="))
if !ok {
fmt.Fprintln(os.Stderr, "invalid --fail-on")
return 2
}
case strings.HasPrefix(a, "--transaction-mode="):
mode = strings.TrimPrefix(a, "--transaction-mode=")
case strings.HasPrefix(a, "-") && a != "-":
fmt.Fprintf(os.Stderr, "unknown option: %s\n", a)
return 2
default:
files = append(files, a)
}
}
if len(files) == 0 || (format != "text" && format != "json") {
usage()
return 2
}
r, err := pgsafe.Check(files, os.Stdin, pgsafe.Options{FailOn: fail, TransactionMode: mode})
if err != nil {
fmt.Fprintln(os.Stderr, "pgsafe:", err)
return 2
}
if format == "json" {
if err = pgsafe.WriteJSON(os.Stdout, r); err != nil {
fmt.Fprintln(os.Stderr, err)
return 2
}
} else {
pgsafe.WriteText(os.Stdout, r)
}
return r.ExitCode
}
6 changes: 6 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,9 @@ The same M5 sequenced workload measures healthy commit latency, failure interrup
M7 keeps the canonical topology, Patroni ownership, etcd mutual TLS, async default, and stable HAProxy write path. It introduces one bounded failure dimension at a time: DCS isolation of a database member, loss of etcd quorum, peer isolation of the elected etcd leader, or interruption of both replica WAL connections. Tagged guest-local firewall rules preserve SSH and unrelated ports and are removed exactly by cleanup traps.

Direct SQL observations sample all database members throughout each fault. More than one writable PostgreSQL node is an immediate safety failure. These controlled partitions do not model arbitrary packet loss, delay, reordering, Byzantine behavior, or every asymmetric topology.

## M8 migration-safety boundary

`pgsafe` is a standalone, read-only Go CLI. SQL flows through the PostgreSQL 16 `libpg_query` parser, its AST is evaluated by deterministic rules, and diagnostics are rendered as text or JSON. The CLI has no database driver or execution path. GitHub-hosted CI runs only static build, test, fixture, and Terraform checks.

Selected lock demonstrations are separate shell automation for the canonical full topology. They use a disposable `pgsafe_m8` schema, bounded PostgreSQL timeouts, the existing HAProxy client path, and ignored evidence under `.pgsentry/results/m8/`. Patroni continues to own PostgreSQL; M8 does not inject node, DCS, or network failures.
48 changes: 48 additions & 0 deletions docs/m8-pgsafe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# M8 pgsafe migration safety CLI

## Why this exists

A healthy Patroni cluster can still suffer an application outage when DDL queues behind a lock or blocks writes. Patroni handles node leadership; it does not judge migration SQL. `pgsafe` provides a deterministic pre-merge explanation of PostgreSQL 16 operational hazards without connecting to PostgreSQL or executing the migration.

```text
SQL -> PostgreSQL parser -> AST -> rule registry -> diagnostics -> text / JSON
```

The parser is `github.com/pganalyze/pg_query_go/v5` v5.1.0, backed by the PostgreSQL 16 generation of `libpg_query`. It correctly separates comments, quoted identifiers, strings containing semicolons, schema-qualified names, multiline input, and multiple statements. It uses CGO and therefore requires a C compiler at build time; Ubuntu hosted runners provide one. Regular expressions are used only for the auditable suppression comment syntax.

## CLI and policy

- `pgsafe check <file...>` reads only explicit files; `-` reads stdin.
- `--format=text|json` selects human or stable schema-versioned output.
- `--fail-on=info|warning|high|critical` sets the CI threshold; default is `high`.
- `--transaction-mode=single|wrapped` models whether a runner wraps the migration. `wrapped` rejects concurrent index operations.
- `pgsafe rules`, `pgsafe explain RULE_ID`, and `pgsafe version` expose the stable contract.

Exit 0 means no unsuppressed diagnostic meets the threshold, exit 1 means findings meet it, and exit 2 means usage, configuration, I/O, parse, or internal failure. A parse error never reports a migration as safe. Files and diagnostics are sorted by file, statement, then rule ID.

## Suppressions

An exception applies only to the following statement and one explicit rule:

```sql
-- pgsafe: ignore PGSAFE001 reason="small table, approved maintenance window"
CREATE INDEX idx_code ON small_lookup(code);
```

The rule ID and non-empty reason are mandatory. Malformed or unknown suppressions produce exit 2. Suppressed diagnostics remain visible in text and JSON and do not suppress other rules on the statement. There is no global ignore-all switch.

## PostgreSQL 16 decisions and safe patterns

For a live table, prefer `CREATE INDEX CONCURRENTLY` when its longer runtime, two scans, transaction restriction, and possible invalid-index cleanup are acceptable. Stage CHECK and foreign-key constraints with `NOT VALID`, then run `VALIDATE CONSTRAINT`; validation is not lock-free but permits ordinary writes. For NOT NULL, a validated `CHECK (column IS NOT NULL)` can let PostgreSQL avoid a fresh full-table proof. Large type changes often need expand/backfill/contract because static analysis cannot prove binary compatibility or cost.

PostgreSQL 11 and newer can use a missing-value optimization when adding a column with a non-volatile default. `pgsafe` therefore does not repeat old advice that every constant default rewrites the table; it flags known volatile calls such as `clock_timestamp()`, `random()`, and `nextval()`. `lock_timeout` bounds time waiting to acquire a lock, not statement execution. `statement_timeout` bounds execution too, and should not be set unrealistically low for valid long-running concurrent builds or validation.

## Runtime evidence boundary

`make migration-runtime-test PROFILE=full` is deliberately separate from the analyzer. It creates only `pgsafe_m8`, demonstrates a plain index build timing out behind a writer, creates a concurrent index while writes use `control-01:5000`, demonstrates an AccessExclusiveLock ALTER boundary, stages and validates a CHECK, and proves harmless committed DDL appears on all physical replicas. Each operation has bounded timeouts; cleanup terminates only `pgsafe-m8%` sessions and drops only the disposable schema. Evidence is ignored under `.pgsentry/results/m8/`.

Hosted CI never claims this KVM evidence. It runs Go formatting, tests, vet, build, compiled fixture acceptance, and Terraform static validation. Canonical runtime verification remains local/self-hosted.

## Limits

Static SQL cannot reveal table size, row count, workload, current lock holders, storage speed, replication lag, duration, or application semantics. A finding describes possible operational impact, not certain downtime; absence of a finding is not a universal safety guarantee. M8 does not cover every PostgreSQL version or ORM, malicious SQL, business-data correctness, zero-downtime guarantees, production SLAs, backup correctness, WAL archiving, or PITR. Backup/WAL/PITR remains M9.
24 changes: 24 additions & 0 deletions docs/pgsafe-rules.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# pgsafe rule catalog

The CLI's `pgsafe rules` and `pgsafe explain` commands are authoritative for the pinned release. Diagnostics are conservative operational signals; suppress only one rule on the following statement with an explicit reason.

| ID | Severity | Operation | Why it matters | Safer approach |
| --- | --- | --- | --- | --- |
| PGSAFE001 | HIGH | CREATE INDEX | A regular build can block table writes. | Consider CONCURRENTLY outside a transaction. |
| PGSAFE002 | HIGH | DROP INDEX | A regular drop requires a strong table lock. | Use CONCURRENTLY where PostgreSQL permits. |
| PGSAFE003 | HIGH | concurrent index in transaction | PostgreSQL rejects this combination. | Disable runner transaction wrapping. |
| PGSAFE004 | HIGH | ALTER COLUMN TYPE | May rewrite data/rebuild indexes under strong locks. | Expand, backfill, and contract for large live tables. |
| PGSAFE005 | HIGH | SET NOT NULL | May scan existing rows under a strong lock. | Prove with a staged valid CHECK first. |
| PGSAFE006 | HIGH | immediate CHECK | Existing rows are validated immediately. | Add NOT VALID, then validate separately. |
| PGSAFE007 | WARNING | VALIDATE CONSTRAINT | Scans rows with non-zero locking and runtime cost. | Run as a monitored, bounded step. |
| PGSAFE008 | HIGH | immediate foreign key | Validation affects referencing and referenced tables. | Add NOT VALID, then validate separately. |
| PGSAFE009 | CRITICAL | DROP table/schema/database | Destructive and potentially strongly locking. | Deprecate first; use reviewed recovery planning. |
| PGSAFE010 | CRITICAL | TRUNCATE | Removes all rows and takes ACCESS EXCLUSIVE locks. | Use reviewed bounded retention/deletion. |
| PGSAFE011 | CRITICAL | DELETE without WHERE | Can delete every row. | Add a reviewed predicate and batch large work. |
| PGSAFE012 | HIGH | UPDATE without WHERE | Rewrites every row version, generating locks, WAL, and bloat. | Add a predicate and batch large work. |
| PGSAFE013 | CRITICAL | DROP COLUMN | Destructive ALTER TABLE with strong locking. | Remove dependencies and defer cleanup. |
| PGSAFE014 | HIGH | CLUSTER/VACUUM FULL/non-concurrent REINDEX | Can block access or rewrite substantial storage. | Prefer concurrent forms or maintenance windows. |
| PGSAFE015 | WARNING | lock-heavy DDL without lock_timeout | Lock acquisition can wait indefinitely. | Set a non-zero lock_timeout; separately choose statement_timeout. |
| PGSAFE016 | HIGH | volatile ADD COLUMN default | PostgreSQL 16 cannot use the fast-default optimization for volatile expressions. | Add, batch-backfill, then set the default. |

False positives are possible because the analyzer intentionally does not know whether a relation is empty, tiny, temporary, or already inside an approved maintenance window. Those facts belong in an auditable statement-scoped suppression reason, not an invisible global exemption.
7 changes: 7 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
module github.com/nhatminh06/pgsentry

go 1.24

require github.com/pganalyze/pg_query_go/v5 v5.1.0

require google.golang.org/protobuf v1.31.0 // indirect
10 changes: 10 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/pganalyze/pg_query_go/v5 v5.1.0 h1:MlxQqHZnvA3cbRQYyIrjxEjzo560P6MyTgtlaf3pmXg=
github.com/pganalyze/pg_query_go/v5 v5.1.0/go.mod h1:FsglvxidZsVN+Ltw3Ai6nTgPVcK2BPukH3jCDEqc1Ug=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
Loading
Loading