diff --git a/CHANGELOG.md b/CHANGELOG.md index d41c1c6..478e85b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,68 @@ keeps reading that version for at least 24 months after a successor lands. ## [Unreleased] +## [1.0.9] — 2026-07-13 + +Twenty operator-annoyance fixes, found by systematically exercising the +user-facing surface (first-run flows, error hints, exit codes, output +consistency) and each covered by a regression test. + +### Fix: first-run experience + +- `init` no longer busy-loops forever (flooding the terminal) when + stdin is closed — a CI pipe or Ctrl-D now aborts with a structured + error pointing at flags + `--yes`. +- `init --quick` defaults to a user-writable repository path for + non-root users instead of failing on `/var/backups/pg_hardstorage` + with a permission error. +- init's "Next steps" suggests the flagless `wal stream ` + (the config it just wrote makes it work) instead of a literal + `--pg-connection ...` placeholder that retried an unparseable DSN + forever; operator-input (`usage.*`) errors now fail the stream + setup fast instead of retrying. +- Ctrl-C / SIGTERM now cancel the command context so deferred cleanup + runs — interrupting `demo` no longer leaks its throwaway PostgreSQL + container. + +### Fix: hints and error classification + +- Every remediation hint is copy-pasteable: doctor's audit-anchor + hints include `--repo `; the checkpoint-mismatch suggestion + gives the resume command (it previously steered operators — and + automation reading its `command` field — toward `rm -rf` of the + partially-restored target, and referenced a flag that doesn't + exist); the GDPR erasure report and `jit` help no longer recommend + the nonexistent `kms shred --tenant`; the plain-restore notice + names the real `--to` flag. +- A typo'd subcommand under a group (`wal audi`, `repo bogus`) now + fails with exit 2 and a "did you mean" instead of printing help and + exiting 0 (a cron job with a typo stayed green forever); unknown + top-level commands also exit 2. +- An empty backup-ID argument (unset shell variable) is a usage error + (exit 2) — `verify` previously reported it as a manifest SIGNATURE + failure (exit 9, the pager-worthy "corrupt/tampered" code). +- A typo'd deployment name yields `notfound.deployment` listing the + configured names instead of demanding `--pg-connection`/`--repo`. + +### Fix: consistency and safety + +- `--version` works (CLI muscle memory); the help banner no longer + claims "v0.2"; `changelog` reports the real binary version. +- `daily_at` schedules are documented as host-local time (they always + were) and the schedule display shows the actual zone + UTC offset. +- `backup delete` — the most destructive verb — now requires `--yes` + (or an approval), matching every other gated verb. +- Bare `status` / `rotate` / `audit anchor` resolve `--repo` from the + config when every deployment shares one repository. +- `--verify` and `--verify-restore` accept each other's vocabulary + (`skip`≡`off`, `require`≡`required`). +- Durations render as `N ms` everywhere (list/init/verify matched + show/backup/restore); the `status` tombstone footnote no longer + blows the table fifty columns wide. +- The generated `restore_command` runs `wal fetch` with `-o text -q`, + so routine end-of-WAL probes log one line instead of ten-line JSON + documents in the PostgreSQL server log. + ## [1.0.8] — 2026-07-06 ### Fix: post-restore verification failed on every base-only restore diff --git a/docs/compliance/pci-dss.md b/docs/compliance/pci-dss.md index 0d70173..4c59496 100644 --- a/docs/compliance/pci-dss.md +++ b/docs/compliance/pci-dss.md @@ -143,7 +143,7 @@ audit period plus the cosign signatures on the running binary: ```sh -VERSION=1.0.8 # the release / image tag you're attesting +VERSION=1.0.9 # the release / image tag you're attesting # 1. Audit chain bundle pg_hardstorage audit export-bundle \ diff --git a/docs/compliance/slsa-l3-provenance.md b/docs/compliance/slsa-l3-provenance.md index 34ef546..d0181b9 100644 --- a/docs/compliance/slsa-l3-provenance.md +++ b/docs/compliance/slsa-l3-provenance.md @@ -74,7 +74,7 @@ Verify a downloaded artifact against its provenance with the `slsa-verifier`: ```sh -VERSION=1.0.8 # the release you're verifying +VERSION=1.0.9 # the release you're verifying slsa-verifier verify-artifact \ --provenance-path pg_hardstorage.intoto.jsonl \ --source-uri github.com/cybertec-postgresql/pg_hardstorage \ @@ -109,7 +109,7 @@ When images ship, they are intended to carry a cosign signature plus an image-level SLSA provenance attestation: ```sh -VERSION=1.0.8 # the image tag you're verifying +VERSION=1.0.9 # the image tag you're verifying # Verify the image signature cosign verify "ghcr.io/cybertec-postgresql/pg_hardstorage:v${VERSION}" \ @@ -139,7 +139,7 @@ JSON format, generated by `syft` (configured via the and attached to the GitHub Release: ```sh -VERSION=1.0.8 # the release you're inspecting +VERSION=1.0.9 # the release you're inspecting # The SBOM is a plain file on the Release; inspect it directly jq -r '.packages[].name' \ "pg_hardstorage_${VERSION}_linux_amd64.tar.gz.spdx.sbom.json" diff --git a/docs/faq.md b/docs/faq.md index efecf28..6a8c565 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -218,9 +218,11 @@ pgBackRest's incremental chain; see #### How do I delete an old backup? -`pg_hardstorage backup delete `. -The manifest soft-deletes immediately; chunks stay until -the next `repo gc` decides nothing else references them. +`pg_hardstorage backup delete --yes` +(the `--yes` confirmation gate is required; an approved +`--require-approval` also satisfies it). The manifest soft-deletes +immediately; chunks stay until the next `repo gc` decides nothing +else references them. See [`backup delete` reference](reference/cli/pg_hardstorage_backup_delete.md) for `--undelete` (within the trash TTL) and `--force` (skip confirmation prompt). Retention can run this for you diff --git a/docs/how-to/operating/legal-hold.md b/docs/how-to/operating/legal-hold.md index 55be3a6..8cc8249 100644 --- a/docs/how-to/operating/legal-hold.md +++ b/docs/how-to/operating/legal-hold.md @@ -38,9 +38,9 @@ pg_hardstorage list db1 ```console Backups for db1 (3): BACKUP ID TYPE WHEN FILES SIZE DEDUP DURATION - db1.full.20260427T020000Z full 2026-04-27 02:00 1284 12.3 GiB 1.42x 38210ms - db1.full.20260420T020000Z full 2026-04-20 02:00 1281 12.1 GiB 1.41x 37544ms - db1.full.20260413T020000Z full 2026-04-13 02:00 1279 11.9 GiB 1.40x 36988ms + db1.full.20260427T020000Z full 2026-04-27 02:00 1284 12.3 GiB 1.42x 38210 ms + db1.full.20260420T020000Z full 2026-04-20 02:00 1281 12.1 GiB 1.41x 37544 ms + db1.full.20260413T020000Z full 2026-04-13 02:00 1279 11.9 GiB 1.40x 36988 ms ``` ### 2. Place an indefinite hold diff --git a/docs/how-to/operating/verify-fast-vs-full.md b/docs/how-to/operating/verify-fast-vs-full.md index 7361573..ece991e 100644 --- a/docs/how-to/operating/verify-fast-vs-full.md +++ b/docs/how-to/operating/verify-fast-vs-full.md @@ -42,7 +42,7 @@ pg_hardstorage verify db1 latest verify db1/db1.full.20260427T020000Z (full) manifest signature: valid chunks: 3245 referenced, 3245 unique, 3245 sampled - ✓ 3245 chunk(s) verified — 12.3 GiB in 9412ms + ✓ 3245 chunk(s) verified — 12.3 GiB in 9412 ms ``` `--repo ` overrides the deployment's configured repo for diff --git a/docs/reference/cli/pg_hardstorage.md b/docs/reference/cli/pg_hardstorage.md index 24f2cea..94b27f8 100644 --- a/docs/reference/cli/pg_hardstorage.md +++ b/docs/reference/cli/pg_hardstorage.md @@ -26,6 +26,10 @@ Every command is a real implementation (no scaffolding stubs). The advanced surfaces — gRPC, OIDC + RBAC, advise+execute LLM mode, sandbox-PG runtime — extend per docs/SPEC.md. +``` +pg_hardstorage [flags] +``` + ### Options ``` diff --git a/docs/reference/cli/pg_hardstorage_anomaly.md b/docs/reference/cli/pg_hardstorage_anomaly.md index 2a13751..aa747fd 100644 --- a/docs/reference/cli/pg_hardstorage_anomaly.md +++ b/docs/reference/cli/pg_hardstorage_anomaly.md @@ -34,6 +34,10 @@ Findings flip the exit code to 9 (ExitVerifyFailed) so cron-driven checks alarm. An audit-chain entry is written on every flag — bit-rot-style: rare enough to be signal, not noise. +``` +pg_hardstorage anomaly [flags] +``` + ### Options ``` diff --git a/docs/reference/cli/pg_hardstorage_approval.md b/docs/reference/cli/pg_hardstorage_approval.md index 00184c0..434099d 100644 --- a/docs/reference/cli/pg_hardstorage_approval.md +++ b/docs/reference/cli/pg_hardstorage_approval.md @@ -29,6 +29,10 @@ Approvals are signed with each approver's ed25519 keypair (the same shape as the manifest-signing keys we already use). Tampering with the request invalidates every existing signature. +``` +pg_hardstorage approval [flags] +``` + ### Options ``` diff --git a/docs/reference/cli/pg_hardstorage_audit.md b/docs/reference/cli/pg_hardstorage_audit.md index 07c35ad..e18ff3b 100644 --- a/docs/reference/cli/pg_hardstorage_audit.md +++ b/docs/reference/cli/pg_hardstorage_audit.md @@ -23,6 +23,10 @@ event's prev_hash; `audit verify-chain` walks the chain and reports both kinds of finding (hash mismatch on an event, or chain break between events). +``` +pg_hardstorage audit [flags] +``` + ### Options ``` diff --git a/docs/reference/cli/pg_hardstorage_backup_delete.md b/docs/reference/cli/pg_hardstorage_backup_delete.md index ec7790b..ffc32df 100644 --- a/docs/reference/cli/pg_hardstorage_backup_delete.md +++ b/docs/reference/cli/pg_hardstorage_backup_delete.md @@ -48,6 +48,7 @@ pg_hardstorage backup delete [flags] --reason string free-form reason captured in the tombstone + audit chain --repo string repository URL (required) --require-approval string approval request ID that must be in approved state for backup.delete + this backup ID (n-of-m gate; strongly recommended) + --yes confirm the tombstone (not needed when --require-approval is given) ``` ### Options inherited from parent commands diff --git a/docs/reference/cli/pg_hardstorage_capacity.md b/docs/reference/cli/pg_hardstorage_capacity.md index 88e0017..467488b 100644 --- a/docs/reference/cli/pg_hardstorage_capacity.md +++ b/docs/reference/cli/pg_hardstorage_capacity.md @@ -14,6 +14,10 @@ tags: Projected repository size, WAL volume, and pre-flight free-space checks +``` +pg_hardstorage capacity [flags] +``` + ### Options ``` diff --git a/docs/reference/cli/pg_hardstorage_classify.md b/docs/reference/cli/pg_hardstorage_classify.md index 129f25f..16406e3 100644 --- a/docs/reference/cli/pg_hardstorage_classify.md +++ b/docs/reference/cli/pg_hardstorage_classify.md @@ -23,6 +23,10 @@ The tag is informational in v0.1 — surfaced in doctor and stored in pg_hardstorage.yaml. wires it to retention floor, allowed regions, and required-encryption enforcement. +``` +pg_hardstorage classify [flags] +``` + ### Options ``` diff --git a/docs/reference/cli/pg_hardstorage_compat.md b/docs/reference/cli/pg_hardstorage_compat.md index b2932d4..531ec71 100644 --- a/docs/reference/cli/pg_hardstorage_compat.md +++ b/docs/reference/cli/pg_hardstorage_compat.md @@ -29,6 +29,10 @@ surface live in their own binaries (pg-hardstorage-pgbackrest, pg-hardstorage-barman); this subcommand is only for the config-translation half of the migration story. +``` +pg_hardstorage compat [flags] +``` + ### Options ``` diff --git a/docs/reference/cli/pg_hardstorage_compliance.md b/docs/reference/cli/pg_hardstorage_compliance.md index 285e581..9519a81 100644 --- a/docs/reference/cli/pg_hardstorage_compliance.md +++ b/docs/reference/cli/pg_hardstorage_compliance.md @@ -25,6 +25,10 @@ Today the only subcommand is `compliance report`; the namespace is reserved for additional reports without disturbing the v1 contract on the existing one. +``` +pg_hardstorage compliance [flags] +``` + ### Options ``` diff --git a/docs/reference/cli/pg_hardstorage_cost.md b/docs/reference/cli/pg_hardstorage_cost.md index 1953d9d..e212def 100644 --- a/docs/reference/cli/pg_hardstorage_cost.md +++ b/docs/reference/cli/pg_hardstorage_cost.md @@ -14,6 +14,10 @@ tags: Per-deployment / per-tenant repository cost +``` +pg_hardstorage cost [flags] +``` + ### Options ``` diff --git a/docs/reference/cli/pg_hardstorage_db.md b/docs/reference/cli/pg_hardstorage_db.md index ce006ac..2452326 100644 --- a/docs/reference/cli/pg_hardstorage_db.md +++ b/docs/reference/cli/pg_hardstorage_db.md @@ -14,6 +14,10 @@ tags: In-database integration (SQL views, upsert helpers) +``` +pg_hardstorage db [flags] +``` + ### Options ``` diff --git a/docs/reference/cli/pg_hardstorage_deployment.md b/docs/reference/cli/pg_hardstorage_deployment.md index f349c34..a3483eb 100644 --- a/docs/reference/cli/pg_hardstorage_deployment.md +++ b/docs/reference/cli/pg_hardstorage_deployment.md @@ -20,6 +20,10 @@ A deployment is the unit of "what we back up" — one PostgreSQL service. The subcommands here mutate the deployments: block in pg_hardstorage.yaml. +``` +pg_hardstorage deployment [flags] +``` + ### Options ``` diff --git a/docs/reference/cli/pg_hardstorage_dsa.md b/docs/reference/cli/pg_hardstorage_dsa.md index 64450ba..b89e517 100644 --- a/docs/reference/cli/pg_hardstorage_dsa.md +++ b/docs/reference/cli/pg_hardstorage_dsa.md @@ -37,6 +37,10 @@ Exit codes: 6 notfound (report id absent for show/verify) 9 verify failure (signature does not validate) +``` +pg_hardstorage dsa [flags] +``` + ### Options ``` diff --git a/docs/reference/cli/pg_hardstorage_fleet.md b/docs/reference/cli/pg_hardstorage_fleet.md index 72b5613..b6b5186 100644 --- a/docs/reference/cli/pg_hardstorage_fleet.md +++ b/docs/reference/cli/pg_hardstorage_fleet.md @@ -22,6 +22,10 @@ v0.1 ships fleet search — point at a repository, give a key:value query, and get back every matching backup. Multi-repo aggregation arrives with the control plane. +``` +pg_hardstorage fleet [flags] +``` + ### Options ``` diff --git a/docs/reference/cli/pg_hardstorage_gameday.md b/docs/reference/cli/pg_hardstorage_gameday.md index 49fa9f6..656ef74 100644 --- a/docs/reference/cli/pg_hardstorage_gameday.md +++ b/docs/reference/cli/pg_hardstorage_gameday.md @@ -14,6 +14,10 @@ tags: Opt-in chaos automation +``` +pg_hardstorage gameday [flags] +``` + ### Options ``` diff --git a/docs/reference/cli/pg_hardstorage_hold.md b/docs/reference/cli/pg_hardstorage_hold.md index dcd33db..15cfcf0 100644 --- a/docs/reference/cli/pg_hardstorage_hold.md +++ b/docs/reference/cli/pg_hardstorage_hold.md @@ -24,6 +24,10 @@ so it survives agent restarts and is visible across operators. Removing a hold is the explicit `hold remove` operation; nothing else clears it. +``` +pg_hardstorage hold [flags] +``` + ### Options ``` diff --git a/docs/reference/cli/pg_hardstorage_insider.md b/docs/reference/cli/pg_hardstorage_insider.md index cc238b8..d3f0286 100644 --- a/docs/reference/cli/pg_hardstorage_insider.md +++ b/docs/reference/cli/pg_hardstorage_insider.md @@ -33,6 +33,10 @@ Each scan compares a baseline window (default 30 d) against a target window (default 24 h). Findings carry severity, actor, action, the audit event IDs that triggered them, and a human-readable reason. +``` +pg_hardstorage insider [flags] +``` + ### Options ``` diff --git a/docs/reference/cli/pg_hardstorage_integrity.md b/docs/reference/cli/pg_hardstorage_integrity.md index 044580e..fb01882 100644 --- a/docs/reference/cli/pg_hardstorage_integrity.md +++ b/docs/reference/cli/pg_hardstorage_integrity.md @@ -38,6 +38,10 @@ Exit codes: code matches the "verify-failed" namespace; cron will alert. 6 notfound (run id or strategy target absent) +``` +pg_hardstorage integrity [flags] +``` + ### Options ``` diff --git a/docs/reference/cli/pg_hardstorage_jit.md b/docs/reference/cli/pg_hardstorage_jit.md index 0a984e0..3bed0c7 100644 --- a/docs/reference/cli/pg_hardstorage_jit.md +++ b/docs/reference/cli/pg_hardstorage_jit.md @@ -37,8 +37,8 @@ Operationally: # operator consumes (future commit will wire --jit-token # into kms shred + other destructive commands) - pg_hardstorage kms shred --tenant T \ - --jit-token --repo s3://acme + pg_hardstorage kms shred --repo s3://acme \ + --confirm-keyring --require-approval --yes # admin lists current tokens pg_hardstorage jit list --repo s3://acme @@ -46,6 +46,10 @@ Operationally: # admin revokes if needed pg_hardstorage jit revoke --reason "..." +``` +pg_hardstorage jit [flags] +``` + ### Options ``` diff --git a/docs/reference/cli/pg_hardstorage_kms.md b/docs/reference/cli/pg_hardstorage_kms.md index 1db36eb..d45658f 100644 --- a/docs/reference/cli/pg_hardstorage_kms.md +++ b/docs/reference/cli/pg_hardstorage_kms.md @@ -21,6 +21,10 @@ verbs (rotate, shred, hsm-status) land alongside the KMS plugin tier (gcp-kms, azure-key-vault, vault-transit) and the PKCS#11 / TPM 2.0 integrations. +``` +pg_hardstorage kms [flags] +``` + ### Options ``` diff --git a/docs/reference/cli/pg_hardstorage_llm_history.md b/docs/reference/cli/pg_hardstorage_llm_history.md index 9ea01fd..be5af2a 100644 --- a/docs/reference/cli/pg_hardstorage_llm_history.md +++ b/docs/reference/cli/pg_hardstorage_llm_history.md @@ -14,6 +14,10 @@ tags: Inspect / shred encrypted LLM conversation transcripts +``` +pg_hardstorage llm history [flags] +``` + ### Options ``` diff --git a/docs/reference/cli/pg_hardstorage_llm_skill.md b/docs/reference/cli/pg_hardstorage_llm_skill.md index 14990b9..92c7cd3 100644 --- a/docs/reference/cli/pg_hardstorage_llm_skill.md +++ b/docs/reference/cli/pg_hardstorage_llm_skill.md @@ -14,6 +14,10 @@ tags: Manage LLM skill files +``` +pg_hardstorage llm skill [flags] +``` + ### Options ``` diff --git a/docs/reference/cli/pg_hardstorage_logical.md b/docs/reference/cli/pg_hardstorage_logical.md index 282fb34..544aa86 100644 --- a/docs/reference/cli/pg_hardstorage_logical.md +++ b/docs/reference/cli/pg_hardstorage_logical.md @@ -28,6 +28,10 @@ v0.1 ships pgoutput + chunked sink. The chunked sink writes batched XLogData payloads into the same CAS-backed repo as physical WAL, under logical///.json. +``` +pg_hardstorage logical [flags] +``` + ### Options ``` diff --git a/docs/reference/cli/pg_hardstorage_manifest.md b/docs/reference/cli/pg_hardstorage_manifest.md index 219ed48..d640fb6 100644 --- a/docs/reference/cli/pg_hardstorage_manifest.md +++ b/docs/reference/cli/pg_hardstorage_manifest.md @@ -23,6 +23,10 @@ the repository's manifests/ prefix. `manifest show` is the same operation as the top-level `show` command. +``` +pg_hardstorage manifest [flags] +``` + ### Options ``` diff --git a/docs/reference/cli/pg_hardstorage_notify.md b/docs/reference/cli/pg_hardstorage_notify.md index 42d5bfd..15c6efc 100644 --- a/docs/reference/cli/pg_hardstorage_notify.md +++ b/docs/reference/cli/pg_hardstorage_notify.md @@ -23,6 +23,10 @@ operate on that name. Adding a sink validates the configuration via the same SinkRegistry the agent uses at start-up, so a typo'd webhook URL or a missing key is rejected before it lands on disk. +``` +pg_hardstorage notify [flags] +``` + ### Options ``` diff --git a/docs/reference/cli/pg_hardstorage_partial.md b/docs/reference/cli/pg_hardstorage_partial.md index 61b313a..cd11e4e 100644 --- a/docs/reference/cli/pg_hardstorage_partial.md +++ b/docs/reference/cli/pg_hardstorage_partial.md @@ -28,6 +28,10 @@ ships alongside the sandbox-PG verifier. The fallback documented in the structured error is: full restore into a staging directory + pg_dump --table=... +``` +pg_hardstorage partial [flags] +``` + ### Options ``` diff --git a/docs/reference/cli/pg_hardstorage_patroni.md b/docs/reference/cli/pg_hardstorage_patroni.md index c4c3b6d..c631efc 100644 --- a/docs/reference/cli/pg_hardstorage_patroni.md +++ b/docs/reference/cli/pg_hardstorage_patroni.md @@ -32,6 +32,10 @@ Authentication: Patroni's REST is HTTP basic-auth or no auth. Read endpoints are typically open; pass --user/--password if your deployment locks them down. +``` +pg_hardstorage patroni [flags] +``` + ### Options ``` diff --git a/docs/reference/cli/pg_hardstorage_plugin.md b/docs/reference/cli/pg_hardstorage_plugin.md index d617eeb..ba86e53 100644 --- a/docs/reference/cli/pg_hardstorage_plugin.md +++ b/docs/reference/cli/pg_hardstorage_plugin.md @@ -25,6 +25,10 @@ what the agent will see before relying on a third-party plugin. See docs/reference/plugins/tier2-go-plugin-protocol.md for the wire contract. +``` +pg_hardstorage plugin [flags] +``` + ### Options ``` diff --git a/docs/reference/cli/pg_hardstorage_recovery.md b/docs/reference/cli/pg_hardstorage_recovery.md index 1ad26b8..25a9cdf 100644 --- a/docs/reference/cli/pg_hardstorage_recovery.md +++ b/docs/reference/cli/pg_hardstorage_recovery.md @@ -32,6 +32,10 @@ recovery answers "can we recover", "how long would it take", and Read-only by construction; safe at any cadence. +``` +pg_hardstorage recovery [flags] +``` + ### Options ``` diff --git a/docs/reference/cli/pg_hardstorage_redact.md b/docs/reference/cli/pg_hardstorage_redact.md index 51dda3f..38268ca 100644 --- a/docs/reference/cli/pg_hardstorage_redact.md +++ b/docs/reference/cli/pg_hardstorage_redact.md @@ -14,6 +14,10 @@ tags: Post-restore PII redaction (rules-driven UPDATE statements) +``` +pg_hardstorage redact [flags] +``` + ### Options ``` diff --git a/docs/reference/cli/pg_hardstorage_repair.md b/docs/reference/cli/pg_hardstorage_repair.md index a1fe177..062b616 100644 --- a/docs/reference/cli/pg_hardstorage_repair.md +++ b/docs/reference/cli/pg_hardstorage_repair.md @@ -23,6 +23,10 @@ match the rest of the system: nothing destructive happens without the operator typing --apply (and, for the most destructive paths, --yes). +``` +pg_hardstorage repair [flags] +``` + ### Options ``` diff --git a/docs/reference/cli/pg_hardstorage_repo.md b/docs/reference/cli/pg_hardstorage_repo.md index 57843c0..5f96bce 100644 --- a/docs/reference/cli/pg_hardstorage_repo.md +++ b/docs/reference/cli/pg_hardstorage_repo.md @@ -14,6 +14,10 @@ tags: Manage the repository +``` +pg_hardstorage repo [flags] +``` + ### Options ``` diff --git a/docs/reference/cli/pg_hardstorage_repo_bundle.md b/docs/reference/cli/pg_hardstorage_repo_bundle.md index 9e47bd1..29ffee4 100644 --- a/docs/reference/cli/pg_hardstorage_repo_bundle.md +++ b/docs/reference/cli/pg_hardstorage_repo_bundle.md @@ -14,6 +14,10 @@ tags: Air-gap export/import of repo state (manifests + chunks + WAL) +``` +pg_hardstorage repo bundle [flags] +``` + ### Options ``` diff --git a/docs/reference/cli/pg_hardstorage_residency.md b/docs/reference/cli/pg_hardstorage_residency.md index edfc4d0..9abea4d 100644 --- a/docs/reference/cli/pg_hardstorage_residency.md +++ b/docs/reference/cli/pg_hardstorage_residency.md @@ -29,6 +29,10 @@ non-empty residency check — local-disk repos can't enforce residency, and silently treating that as a pass would defeat the purpose. +``` +pg_hardstorage residency [flags] +``` + ### Options ``` diff --git a/docs/reference/cli/pg_hardstorage_runbook.md b/docs/reference/cli/pg_hardstorage_runbook.md index c1bfb1c..fd71327 100644 --- a/docs/reference/cli/pg_hardstorage_runbook.md +++ b/docs/reference/cli/pg_hardstorage_runbook.md @@ -21,6 +21,10 @@ commands pre-filled with the deployment's actual configuration (repo URL, deployment name, keyring path). They map to the disaster scenarios documented in docs/SPEC.md (R1–R7). +``` +pg_hardstorage runbook [flags] +``` + ### Options ``` diff --git a/docs/reference/cli/pg_hardstorage_slo.md b/docs/reference/cli/pg_hardstorage_slo.md index 31cb4dd..1820617 100644 --- a/docs/reference/cli/pg_hardstorage_slo.md +++ b/docs/reference/cli/pg_hardstorage_slo.md @@ -24,6 +24,10 @@ StoppedAt), and reports met / missed against the target. Durations parse as Go-style ("1h", "30m", "24h", "7d"-shorthand also accepted). +``` +pg_hardstorage slo [flags] +``` + ### Options ``` diff --git a/docs/reference/cli/pg_hardstorage_standby.md b/docs/reference/cli/pg_hardstorage_standby.md index 60cfd18..5a6b4dc 100644 --- a/docs/reference/cli/pg_hardstorage_standby.md +++ b/docs/reference/cli/pg_hardstorage_standby.md @@ -29,6 +29,10 @@ or pg_ctl. The Result body emits the recommended invocation. The state file lives under paths.State()/standbys.json — same back-compat commitment (24-month) as the manifest schema. +``` +pg_hardstorage standby [flags] +``` + ### Options ``` diff --git a/docs/reference/cli/pg_hardstorage_threshold.md b/docs/reference/cli/pg_hardstorage_threshold.md index 3d4fa71..3fba67f 100644 --- a/docs/reference/cli/pg_hardstorage_threshold.md +++ b/docs/reference/cli/pg_hardstorage_threshold.md @@ -50,6 +50,10 @@ Operationally: backup_manifest db1.full.20260501T120000Z \ --repo s3://acme +``` +pg_hardstorage threshold [flags] +``` + ### Options ``` diff --git a/docs/reference/cli/pg_hardstorage_threshold_attest.md b/docs/reference/cli/pg_hardstorage_threshold_attest.md index 7814c52..4fbacbc 100644 --- a/docs/reference/cli/pg_hardstorage_threshold_attest.md +++ b/docs/reference/cli/pg_hardstorage_threshold_attest.md @@ -14,6 +14,10 @@ tags: Sign / verify / show k-of-n attestations +``` +pg_hardstorage threshold attest [flags] +``` + ### Options ``` diff --git a/docs/reference/cli/pg_hardstorage_threshold_roster.md b/docs/reference/cli/pg_hardstorage_threshold_roster.md index da0d7a0..50dca08 100644 --- a/docs/reference/cli/pg_hardstorage_threshold_roster.md +++ b/docs/reference/cli/pg_hardstorage_threshold_roster.md @@ -14,6 +14,10 @@ tags: Create / list / show rosters +``` +pg_hardstorage threshold roster [flags] +``` + ### Options ``` diff --git a/docs/reference/cli/pg_hardstorage_timetable.md b/docs/reference/cli/pg_hardstorage_timetable.md index 2ebc7a9..438cdfe 100644 --- a/docs/reference/cli/pg_hardstorage_timetable.md +++ b/docs/reference/cli/pg_hardstorage_timetable.md @@ -14,6 +14,10 @@ tags: Generate scheduling SQL for pg_timetable +``` +pg_hardstorage timetable [flags] +``` + ### Options ``` diff --git a/docs/reference/cli/pg_hardstorage_timetravel.md b/docs/reference/cli/pg_hardstorage_timetravel.md index 266d734..622148e 100644 --- a/docs/reference/cli/pg_hardstorage_timetravel.md +++ b/docs/reference/cli/pg_hardstorage_timetravel.md @@ -27,6 +27,10 @@ forgotten sessions are reapable via 'timetravel cleanup'. The data dir lives at --target; the operator starts PG separately (systemd, pg_ctl, container — same posture as 'standby create'). +``` +pg_hardstorage timetravel [flags] +``` + ### Options ``` diff --git a/docs/reference/cli/pg_hardstorage_wal.md b/docs/reference/cli/pg_hardstorage_wal.md index 1d6df70..e6415dc 100644 --- a/docs/reference/cli/pg_hardstorage_wal.md +++ b/docs/reference/cli/pg_hardstorage_wal.md @@ -25,6 +25,10 @@ is needed. It targets PostgreSQL you run yourself; fully-managed DBaaS and are not supported. +``` +pg_hardstorage wal [flags] +``` + ### Options ``` diff --git a/docs/reference/runbooks/control-plane-setup.md b/docs/reference/runbooks/control-plane-setup.md index d8b4640..68f97f8 100644 --- a/docs/reference/runbooks/control-plane-setup.md +++ b/docs/reference/runbooks/control-plane-setup.md @@ -249,7 +249,7 @@ curl --cacert /etc/pg_hardstorage/server/cert.pem \ # { # "id": "db1.example.com", # "host": "db1.example.com", -# "version": "v1.0.8", +# "version": "v1.0.9", # "deployments": ["db1", "db2"], # "registered_at": "...", # "last_heartbeat": "..." diff --git a/docs/tutorials/first-backup-restore.md b/docs/tutorials/first-backup-restore.md index a58de78..500081c 100644 --- a/docs/tutorials/first-backup-restore.md +++ b/docs/tutorials/first-backup-restore.md @@ -120,7 +120,7 @@ pg_hardstorage list db1 --repo file:///tmp/hs-tutorial-repo ```console Backups for db1 (1): BACKUP ID TYPE WHEN FILES SIZE DEDUP DURATION - db1.full.20260504T120000Z.a1b2 full 2026-05-04 12:00 967 22.2 MiB 2.56x 1617ms + db1.full.20260504T120000Z.a1b2 full 2026-05-04 12:00 967 22.2 MiB 2.56x 1617 ms ``` The backup ID has the shape `db1.full.YYYYMMDDThhmmssZ.` — diff --git a/docs/tutorials/getting-started.md b/docs/tutorials/getting-started.md index ecad61b..7045a8b 100644 --- a/docs/tutorials/getting-started.md +++ b/docs/tutorials/getting-started.md @@ -35,7 +35,7 @@ tarballs (Windows is CLI-only). Grab the matching one from verify the cosign signature, and drop the binary on your `$PATH`: ```sh -VERSION=1.0.8 # latest release: https://github.com/cybertec-postgresql/pg_hardstorage/releases/latest +VERSION=1.0.9 # latest release: https://github.com/cybertec-postgresql/pg_hardstorage/releases/latest curl -LO "https://github.com/cybertec-postgresql/pg_hardstorage/releases/download/v${VERSION}/pg_hardstorage_${VERSION}_linux_amd64.tar.gz" tar xzf "pg_hardstorage_${VERSION}_linux_amd64.tar.gz" sudo install -m 0755 pg_hardstorage /usr/local/bin/ @@ -45,7 +45,7 @@ pg_hardstorage version ### `.deb` (Debian / Ubuntu) ```sh -VERSION=1.0.8 # the release you downloaded +VERSION=1.0.9 # the release you downloaded sudo dpkg -i "pg-hardstorage_${VERSION}_amd64.deb" ``` @@ -57,7 +57,7 @@ creates `/etc/pg_hardstorage/`, `/var/lib/pg_hardstorage/`, ### `.rpm` (Fedora / RHEL / Rocky / Alma) ```sh -VERSION=1.0.8 # the release you downloaded +VERSION=1.0.9 # the release you downloaded sudo rpm -i "pg-hardstorage-${VERSION}-1.x86_64.rpm" ``` @@ -259,7 +259,7 @@ you are doing — exit 9 means the verifier said no. ```sh $ pg_hardstorage version -pg_hardstorage v1.0.8 (abc1234, built 2026-04-29T12:00:00Z) +pg_hardstorage v1.0.9 (abc1234, built 2026-04-29T12:00:00Z) ``` `doctor` is the single-command "is anything wrong" check. It prints a diff --git a/internal/cli/annoyance_round2_test.go b/internal/cli/annoyance_round2_test.go new file mode 100644 index 0000000..596a616 --- /dev/null +++ b/internal/cli/annoyance_round2_test.go @@ -0,0 +1,58 @@ +package cli_test + +import ( + "strings" + "testing" + + "github.com/cybertec-postgresql/pg_hardstorage/internal/output" +) + +// Bug #13: a typo'd subcommand under a group (`wal audi`, `repo bogus`) +// used to print the group help and exit 0 — cron/CI stayed green forever. +// It must be a usage error (exit 2). Bare groups still help+0. +func TestGroupCommands_UnknownSubcommandFails(t *testing.T) { + for _, args := range [][]string{ + {"wal", "audi"}, + {"repo", "bogus"}, + {"kms", "nonsense"}, + } { + _, _, exit := runCLI(t, args...) + if exit != int(output.ExitMisuse) { + t.Errorf("%v exit = %d, want %d (usage)", args, exit, output.ExitMisuse) + } + } + // Bare group: conventional help, exit 0. + _, _, exit := runCLI(t, "wal") + if exit != 0 { + t.Errorf("bare `wal` exit = %d, want 0 (help)", exit) + } +} + +// Bug #14: an empty backup-ID positional (unset shell variable) was +// reported as a manifest SIGNATURE failure (exit 9) by verify and as an +// internal error (exit 1) by show. It is a usage error (exit 2). +func TestEmptyBackupID_IsUsageError(t *testing.T) { + for _, args := range [][]string{ + {"verify", "db1", "", "--repo", "file:///nonexistent"}, + {"show", "db1", "", "--repo", "file:///nonexistent"}, + {"restore", "db1", "", "--repo", "file:///nonexistent", "--target", t.TempDir()}, + } { + out, errOut, exit := runCLI(t, args...) + if exit != int(output.ExitMisuse) { + t.Errorf("%v exit = %d, want %d (usage); out=%s err=%s", args, exit, output.ExitMisuse, out, errOut) + } + } +} + +// Bug #18: `backup delete` (the most destructive verb) executed with no +// confirmation while config-only `deployment remove` required --yes. It +// now refuses without --yes / --require-approval. +func TestBackupDelete_RequiresConfirmation(t *testing.T) { + out, errOut, exit := runCLI(t, + "backup", "delete", "db1", "some.full.id", + "--repo", "file:///nonexistent", "-o", "json") + combined := out + errOut + if !strings.Contains(combined, "aborted.confirmation_required") { + t.Errorf("expected aborted.confirmation_required, got exit=%d out=%s", exit, combined) + } +} diff --git a/internal/cli/args_error.go b/internal/cli/args_error.go index db5f40c..49f4f37 100644 --- a/internal/cli/args_error.go +++ b/internal/cli/args_error.go @@ -4,11 +4,14 @@ package cli import ( "fmt" "regexp" + "sort" "strings" "github.com/spf13/cobra" + "github.com/cybertec-postgresql/pg_hardstorage/internal/config" "github.com/cybertec-postgresql/pg_hardstorage/internal/output" + "github.com/cybertec-postgresql/pg_hardstorage/internal/paths" ) // requiredFlagNameRe extracts the quoted flag names from cobra's @@ -228,3 +231,57 @@ func substituteExampleTokens(s string) string { ) return repl.Replace(s) } + +// enrichUnknownDeploymentError rewrites the "--pg-connection, --repo are +// required" class of failure into a "deployment not found" error when the +// evidence says that's what actually happened: the command was invoked +// with a positional deployment name, a config with deployments exists, +// and the name isn't in it. +// +// Operators with a configured catalogue never pass those flags — when +// they typo the deployment (`backup db2` vs `db1`) the missing-flag +// message sends them hunting for connection strings instead of at the +// actual problem. URL-ish positionals (repo verbs) are left alone. +func enrichUnknownDeploymentError(cmd *cobra.Command, err error) error { + if cmd == nil || err == nil { + return err + } + oe, ok := output.AsOutputError(err) + if !ok || oe.Code != "usage.missing_flag" { + return err + } + if !strings.Contains(oe.Message, "--pg-connection") && !strings.Contains(oe.Message, "--repo") { + return err + } + args := cmd.Flags().Args() + if len(args) == 0 { + return err + } + name := args[0] + if strings.Contains(name, "://") || strings.Contains(name, "/") { + return err // a URL/path positional, not a deployment name + } + p, perr := paths.Resolve(paths.DefaultOptions()) + if perr != nil { + return err + } + loaded, lerr := config.Load(p) + if lerr != nil || len(loaded.Config.Deployments) == 0 { + return err + } + if _, exists := loaded.Config.Deployments[name]; exists { + return err // known deployment; the flags really are missing + } + known := make([]string, 0, len(loaded.Config.Deployments)) + for n := range loaded.Config.Deployments { + known = append(known, n) + } + sort.Strings(known) + return output.NewError("notfound.deployment", + fmt.Sprintf("%s: deployment %q is not in pg_hardstorage.yaml (configured: %s)", + cmd.Name(), name, strings.Join(known, ", "))). + WithSuggestion(&output.Suggestion{ + Human: "check the name with `pg_hardstorage deployment list`, or pass --pg-connection/--repo explicitly for an unconfigured database", + Command: "pg_hardstorage deployment list", + }) +} diff --git a/internal/cli/backup_delete.go b/internal/cli/backup_delete.go index e193daf..41142b2 100644 --- a/internal/cli/backup_delete.go +++ b/internal/cli/backup_delete.go @@ -47,6 +47,7 @@ func newBackupDeleteCmd() *cobra.Command { reason string requireApproval string cascade bool + yes bool ) c := &cobra.Command{ Use: "delete ", @@ -73,6 +74,17 @@ soft-delete plan from the configured policy.`, Args: cobra.ExactArgs(2), SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { + // Confirmation gate: the most destructive verb in the CLI + // must not be WEAKER-guarded than the config-only + // `deployment remove` (which refuses without --yes). An + // approved n-of-m gate also counts as confirmation. + if !yes && requireApproval == "" { + return output.NewError("aborted.confirmation_required", + fmt.Sprintf("backup delete: refusing to tombstone %s/%s without --yes (or an approval via --require-approval)", args[0], args[1])). + WithSuggestion(&output.Suggestion{ + Human: "re-run with --yes to confirm; the backup stays recoverable via `backup undelete` until the next `repo gc --apply`", + }) + } return runBackupDelete(cmd, args[0], args[1], repoURL, reason, requireApproval, cascade) }, } @@ -85,6 +97,8 @@ soft-delete plan from the configured policy.`, "approval request ID that must be in approved state for backup.delete + this backup ID (n-of-m gate; strongly recommended)") c.Flags().BoolVar(&cascade, "cascade", false, "also delete every incremental descendant of this backup, leaf-first") + c.Flags().BoolVar(&yes, "yes", false, + "confirm the tombstone (not needed when --require-approval is given)") return c } diff --git a/internal/cli/backup_delete_hold_test.go b/internal/cli/backup_delete_hold_test.go index 76635fd..9ef5bbe 100644 --- a/internal/cli/backup_delete_hold_test.go +++ b/internal/cli/backup_delete_hold_test.go @@ -70,7 +70,7 @@ func TestBackupDelete_RefusesHeldManifest(t *testing.T) { } stdout, stderr, exit := runCLI(t, - "backup", "delete", "db1", rootID, + "backup", "delete", "db1", rootID, "--yes", "--repo", w.repoURL, "-o", "json", ) @@ -111,7 +111,7 @@ func TestBackupDelete_HoldReleased_AllowsDelete(t *testing.T) { } // Refused first. _, _, exit := runCLI(t, - "backup", "delete", "db2", leafID, + "backup", "delete", "db2", leafID, "--yes", "--repo", w.repoURL, ) if exit == int(output.ExitOK) { @@ -123,7 +123,7 @@ func TestBackupDelete_HoldReleased_AllowsDelete(t *testing.T) { } // Now succeeds. _, _, exit = runCLI(t, - "backup", "delete", "db2", leafID, + "backup", "delete", "db2", leafID, "--yes", "--repo", w.repoURL, ) if exit != int(output.ExitOK) { @@ -148,7 +148,7 @@ func TestBackupDelete_CascadeRefusesIfAnyLinkHeld(t *testing.T) { } stdout, stderr, exit := runCLI(t, - "backup", "delete", "db1", rootID, + "backup", "delete", "db1", rootID, "--yes", "--repo", w.repoURL, "--cascade", "-o", "json", diff --git a/internal/cli/backup_delete_test.go b/internal/cli/backup_delete_test.go index 659cd94..d042f78 100644 --- a/internal/cli/backup_delete_test.go +++ b/internal/cli/backup_delete_test.go @@ -19,7 +19,7 @@ import ( func TestBackupDelete_RequiresFlags(t *testing.T) { // No --repo. _, stderr, exit := runCLI(t, - "backup", "delete", "db1", "some-id", + "backup", "delete", "db1", "some-id", "--yes", "-o", "json", ) if exit != int(output.ExitMisuse) { @@ -43,7 +43,7 @@ func TestBackupDelete_NotFound(t *testing.T) { t.Fatalf("repo init failed") } _, stderr, exit := runCLI(t, - "backup", "delete", "db1", "missing.full.x", + "backup", "delete", "db1", "missing.full.x", "--yes", "--repo", repoURL, "-o", "json", ) @@ -85,7 +85,7 @@ func TestBackupDelete_RequireApproval_GateRefusesPending(t *testing.T) { // nonexistent approval ID, expect notfound.backup BEFORE the // gate. This proves the read-first-gate-second ordering. _, stderr, exit := runCLI(t, - "backup", "delete", "db1", "missing.full.x", + "backup", "delete", "db1", "missing.full.x", "--yes", "--repo", repoURL, "--require-approval", "appr-doesnotexist", "-o", "json", @@ -147,7 +147,7 @@ func TestBackupDelete_RequireApproval_OpMismatch(t *testing.T) { // — tested at the package boundary so multiple destructive ops // don't have to re-run the same assertion. _, stderr, exit := runCLI(t, - "backup", "delete", "db1", "db1.full.x", + "backup", "delete", "db1", "db1.full.x", "--yes", "--repo", repoURL, "--require-approval", requestID, "-o", "json", @@ -221,7 +221,7 @@ func TestBackupDelete_ChainProtection(t *testing.T) { } _, stderr, exit := runCLI(t, - "backup", "delete", "db1", full.BackupID, + "backup", "delete", "db1", full.BackupID, "--yes", "--repo", w.repoURL, "-o", "json", ) @@ -318,7 +318,7 @@ func TestBackupDelete_CascadeDrainsChain(t *testing.T) { } stdout, _, exit := runCLI(t, - "backup", "delete", "db1", "db1.full.A", + "backup", "delete", "db1", "db1.full.A", "--yes", "--repo", w.repoURL, "--cascade", "-o", "json", @@ -413,7 +413,7 @@ func TestBackupDelete_ChainProtection_RefusalSuggestsCascade(t *testing.T) { } _, stderr, exit := runCLI(t, - "backup", "delete", "db1", "db1.full.A", + "backup", "delete", "db1", "db1.full.A", "--yes", "--repo", w.repoURL, "-o", "json", ) @@ -482,7 +482,7 @@ func TestBackupDelete_ChainProtection_LeafFirstPasses(t *testing.T) { // Leaf first: ok. _, stderr, exit := runCLI(t, - "backup", "delete", "db1", inc.BackupID, + "backup", "delete", "db1", inc.BackupID, "--yes", "--repo", w.repoURL, "-o", "json", ) @@ -491,7 +491,7 @@ func TestBackupDelete_ChainProtection_LeafFirstPasses(t *testing.T) { } // Anchor next: ok now that no live descendants remain. _, stderr, exit = runCLI(t, - "backup", "delete", "db1", full.BackupID, + "backup", "delete", "db1", full.BackupID, "--yes", "--repo", w.repoURL, "-o", "json", ) diff --git a/internal/cli/backup_graph_test.go b/internal/cli/backup_graph_test.go index feee7ba..0cf49a0 100644 --- a/internal/cli/backup_graph_test.go +++ b/internal/cli/backup_graph_test.go @@ -370,7 +370,7 @@ func TestBackupGraph_IncludeTombstoned(t *testing.T) { [][]byte{[]byte("b")}) // Soft-delete via the existing CLI to exercise the real path. - _, _, exit := runCLI(t, "backup", "delete", "db1", dead, + _, _, exit := runCLI(t, "backup", "delete", "db1", dead, "--yes", "--repo", w.repoURL, "--reason", "test", "-o", "json") if exit != int(output.ExitOK) { t.Fatalf("backup delete exit = %d", exit) diff --git a/internal/cli/backup_undelete_test.go b/internal/cli/backup_undelete_test.go index 7d41556..4903ec0 100644 --- a/internal/cli/backup_undelete_test.go +++ b/internal/cli/backup_undelete_test.go @@ -54,7 +54,7 @@ func TestBackupUndelete_RestoresAfterDelete(t *testing.T) { // Delete first. if _, _, exit := runCLI(t, - "backup", "delete", "db1", "db1.full.A", + "backup", "delete", "db1", "db1.full.A", "--yes", "--repo", w.repoURL, "--reason", "test", ); exit != int(output.ExitOK) { @@ -161,7 +161,7 @@ func TestBackupUndelete_MultipleIDs_UnwindsCascade(t *testing.T) { // Cascade delete. if _, _, exit := runCLI(t, - "backup", "delete", "db1", "db1.full.A", + "backup", "delete", "db1", "db1.full.A", "--yes", "--repo", w.repoURL, "--cascade", ); exit != int(output.ExitOK) { diff --git a/internal/cli/cmdtree/tree.go b/internal/cli/cmdtree/tree.go index 4cef08e..1d9ff5b 100644 --- a/internal/cli/cmdtree/tree.go +++ b/internal/cli/cmdtree/tree.go @@ -256,13 +256,17 @@ func walk(c *cobra.Command, parentPath string) *Node { path = parentPath + " " + c.Name() } n := &Node{ - Name: c.Name(), - Path: path, - Use: c.Use, - Short: c.Short, - Long: c.Long, - Hidden: c.Hidden, - Runnable: c.Runnable(), + Name: c.Name(), + Path: path, + Use: c.Use, + Short: c.Short, + Long: c.Long, + Hidden: c.Hidden, + // A group whose RunE was synthesised purely to reject unknown + // subcommands (hardenGroupCommands) is NOT runnable for + // validation purposes — `deployment create` must still be + // classified as unknown_command, not as positional args. + Runnable: c.Runnable() && c.Annotations["pg_hardstorage.group_guard"] != "1", Aliases: append([]string(nil), c.Aliases...), Flags: collectFlags(c), } diff --git a/internal/cli/deployment_defaults.go b/internal/cli/deployment_defaults.go index 46e73b4..025b7ee 100644 --- a/internal/cli/deployment_defaults.go +++ b/internal/cli/deployment_defaults.go @@ -10,9 +10,12 @@ package cli import ( + "strings" + "github.com/spf13/cobra" "github.com/cybertec-postgresql/pg_hardstorage/internal/config" + "github.com/cybertec-postgresql/pg_hardstorage/internal/output" "github.com/cybertec-postgresql/pg_hardstorage/internal/paths" ) @@ -73,9 +76,6 @@ func deploymentDefaults(deployment, pgConn, repoURL string) (string, string) { // exactly as before. Path/config-load failures are non-fatal — Cobra's // required-flag check then fires just as it did previously. func resolveDeploymentDefaultsPreRun(cmd *cobra.Command, args []string) error { - if len(args) == 0 { - return nil - } fl := cmd.Flags() repoF := fl.Lookup("repo") pgF := fl.Lookup("pg-connection") @@ -92,6 +92,21 @@ func resolveDeploymentDefaultsPreRun(cmd *cobra.Command, args []string) error { if err != nil { return nil } + if len(args) == 0 { + // All-deployments mode (bare `status`, `rotate`, `audit + // anchor`, ...): there is no positional to key on, but when + // every configured deployment points at the SAME repository + // the answer is unambiguous — use it. `status db1` already + // worked flag-free; bare `status` demanding --repo the config + // plainly knows was maddening. With multiple distinct repos + // we stay hands-off and the required-flag error fires. + if repoNeed { + if repo, ok := soleConfiguredRepo(loaded.Config.Deployments); ok { + _ = fl.Set("repo", repo) + } + } + return nil + } dep, ok := loaded.Config.Deployments[args[0]] if !ok { return nil @@ -104,3 +119,37 @@ func resolveDeploymentDefaultsPreRun(cmd *cobra.Command, args []string) error { } return nil } + +// soleConfiguredRepo returns the repo URL shared by every configured +// deployment, or ok=false when there are none or they disagree. +func soleConfiguredRepo(deps map[string]config.DeploymentConfig) (string, bool) { + repo := "" + for _, d := range deps { + if d.Repo == "" { + continue + } + if repo == "" { + repo = d.Repo + continue + } + if d.Repo != repo { + return "", false + } + } + return repo, repo != "" +} + +// requireBackupIDArg rejects an empty/whitespace backup-ID positional as a +// usage error. Without it, `verify db1 ""` (the classic unset-shell-variable +// slip) fell through to the manifest read and was reported as a manifest +// SIGNATURE failure (exit 9 — the "backup corrupt/tampered" code that pages +// people), and `show db1 ""` as an internal error (exit 1). An empty +// argument is operator input, not corruption. +func requireBackupIDArg(command, id string) error { + if strings.TrimSpace(id) == "" { + return output.NewError("usage.bad_args", + command+": backup ID must not be empty (use `latest` for the newest backup)"). + Wrap(output.ErrUsage) + } + return nil +} diff --git a/internal/cli/dsa_test.go b/internal/cli/dsa_test.go index cf95596..81b657f 100644 --- a/internal/cli/dsa_test.go +++ b/internal/cli/dsa_test.go @@ -201,7 +201,8 @@ func TestDSALocate_HappyArticle17_RecommendsKMSShred(t *testing.T) { hasShred := false for _, a := range v.SuggestedActions { if a.Article == "art_17_erasure" && - a.Command == "pg_hardstorage kms shred --tenant tenant-a" { + strings.HasPrefix(a.Command, "pg_hardstorage kms shred --repo") && + strings.Contains(a.Command, "--require-approval") { hasShred = true } } diff --git a/internal/cli/hold_until_test.go b/internal/cli/hold_until_test.go index aa76359..478cc71 100644 --- a/internal/cli/hold_until_test.go +++ b/internal/cli/hold_until_test.go @@ -195,7 +195,7 @@ func TestBackupDelete_PastExpiredHold(t *testing.T) { t.Fatal(err) } - _, _, exit := runCLI(t, "backup", "delete", "db1", id, + _, _, exit := runCLI(t, "backup", "delete", "db1", id, "--yes", "--repo", w.repoURL, "--reason", "after-hold-expired") if exit != int(output.ExitOK) { diff --git a/internal/cli/init.go b/internal/cli/init.go index c92ba63..ff8c396 100644 --- a/internal/cli/init.go +++ b/internal/cli/init.go @@ -464,7 +464,7 @@ func (b initResultBody) WriteText(w io.Writer) error { fmt.Fprintln(bw, "") fmt.Fprintf(bw, " First backup: %s\n", b.FirstBackup.BackupID) fmt.Fprintf(bw, " logical: %s\n", humanBytes(b.FirstBackup.LogicalBytes)) - fmt.Fprintf(bw, " duration: %dms\n", b.FirstBackup.DurationMS) + fmt.Fprintf(bw, " duration: %d ms\n", b.FirstBackup.DurationMS) } fmt.Fprintln(bw, "") fmt.Fprintln(bw, "Next steps:") diff --git a/internal/cli/jit.go b/internal/cli/jit.go index 58b1dee..6e1808d 100644 --- a/internal/cli/jit.go +++ b/internal/cli/jit.go @@ -57,8 +57,8 @@ Operationally: # operator consumes (future commit will wire --jit-token # into kms shred + other destructive commands) - pg_hardstorage kms shred --tenant T \ - --jit-token --repo s3://acme + pg_hardstorage kms shred --repo s3://acme \ + --confirm-keyring --require-approval --yes # admin lists current tokens pg_hardstorage jit list --repo s3://acme diff --git a/internal/cli/list.go b/internal/cli/list.go index 581a354..55fea99 100644 --- a/internal/cli/list.go +++ b/internal/cli/list.go @@ -271,7 +271,7 @@ func (b listBody) WriteText(w io.Writer) error { if r.Tombstoned { typeCol = r.Type + " [DELETED]" } - fmt.Fprintf(tw, " %s\t%s\t%s\t%d\t%s\t%s\t%dms\n", + fmt.Fprintf(tw, " %s\t%s\t%s\t%d\t%s\t%s\t%d ms\n", r.BackupID, typeCol, r.StoppedAt.Format("2006-01-02 15:04"), r.FileCount, humanBytes(r.LogicalBytes), dedup, r.DurationMS) // Tombstone metadata on a continuation line so the diff --git a/internal/cli/llm_chat.go b/internal/cli/llm_chat.go index 39d2efc..0c13f0b 100644 --- a/internal/cli/llm_chat.go +++ b/internal/cli/llm_chat.go @@ -428,7 +428,7 @@ func streamChatTurn(ctx context.Context, w io.Writer, session *chat.Session, pro if reply.Text != "" { renderConfigFindings(w, configcheck.Scrub(reply.Text)) } - fmt.Fprintf(w, " — %d prompt + %d completion tokens · %dms\n\n", + fmt.Fprintf(w, " — %d prompt + %d completion tokens · %d ms\n\n", reply.Usage.PromptTokens, reply.Usage.CompletionTokens, took.Milliseconds()) return nil } diff --git a/internal/cli/repo_audit_test.go b/internal/cli/repo_audit_test.go index c1e2c88..04f9de6 100644 --- a/internal/cli/repo_audit_test.go +++ b/internal/cli/repo_audit_test.go @@ -285,7 +285,7 @@ func TestRepoAudit_TombstonedDeployment(t *testing.T) { commitVerifiableBackup(t, w, "db1", 0, []byte("alive")) dead := commitVerifiableBackup(t, w, "db1", 1, []byte("dead")) - _, _, exit := runCLI(t, "backup", "delete", "db1", dead, + _, _, exit := runCLI(t, "backup", "delete", "db1", dead, "--yes", "--repo", w.repoURL, "--reason", "test", "-o", "json") if exit != int(output.ExitOK) { t.Fatalf("backup delete exit = %d", exit) diff --git a/internal/cli/restore.go b/internal/cli/restore.go index bb008e6..29125e1 100644 --- a/internal/cli/restore.go +++ b/internal/cli/restore.go @@ -66,6 +66,9 @@ binary's wal-fetch shim.`, RunE: func(cmd *cobra.Command, args []string) error { opts.deployment = args[0] opts.backupID = args[1] + if err := requireBackupIDArg("restore", opts.backupID); err != nil { + return err + } return runRestore(cmd, opts) }, } diff --git a/internal/cli/root.go b/internal/cli/root.go index f868b1d..8aa6387 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -12,6 +12,7 @@ import ( "io" "os" "os/signal" + "strings" "syscall" "github.com/spf13/cobra" @@ -62,6 +63,12 @@ func Run(root *cobra.Command) int { if err == nil { return int(output.ExitOK) } + // Cobra's "unknown command" error is a usage error (exit 2), not + // the generic exit 1 — scripts distinguish "I called it wrong" + // from "it ran and failed". + if strings.HasPrefix(err.Error(), "unknown command ") { + err = output.NewError("usage.unknown_command", err.Error()).Wrap(output.ErrUsage) + } // Rewrite cobra's bare "accepts N arg(s), received M" // into a message that names the expected positionals // and shows a working example. The original error's @@ -72,6 +79,10 @@ func Run(root *cobra.Command) int { // declarative path matches the older hand-written "X is required" // checks (same code, same exit). err = enrichRequiredFlagError(cmd, err) + // A missing --pg-connection/--repo on a command whose positional + // names an UNKNOWN deployment is really a typo'd deployment name — + // say so instead of demanding flags the operator never uses. + err = enrichUnknownDeploymentError(cmd, err) // Audit+ #3 — `--on-error-llm` auto-launch. If the // global flag (or env var) is set AND the failure carries a // structured error code AND a loaded skill declares @@ -241,6 +252,7 @@ sandbox-PG runtime — extend per docs/SPEC.md.`, newCompletionCmd(root), newDumpCmdTreeCmd(), ) + hardenGroupCommands(root) return root } @@ -267,6 +279,47 @@ func (v versionBody) WriteText(w io.Writer) error { return err } +// hardenGroupCommands walks the command tree and gives every pure group +// command (has subcommands, no Run of its own) a RunE that FAILS on an +// unknown subcommand instead of printing help and exiting 0. +// +// Without this, `pg_hardstorage wal audi` (a typo of the "cron-friendly" +// `wal audit`), `repo bogus`, `kms nonsense`, ... all printed the group +// help to stdout and exited 0 — a cron/CI script with a typo'd +// subcommand stays green forever while the real job never runs. Bare +// group invocations (`pg_hardstorage wal`) still print help with exit 0, +// which is the conventional discovery path. +func hardenGroupCommands(cmd *cobra.Command) { + if cmd.HasSubCommands() && cmd.Run == nil && cmd.RunE == nil { + // Mark the synthetic handler so introspection (cmdtree, the + // LLM command validator) still treats this as a pure group — + // its RunE exists only to reject typos, not to accept args. + if cmd.Annotations == nil { + cmd.Annotations = map[string]string{} + } + cmd.Annotations[groupGuardAnnotation] = "1" + cmd.RunE = func(c *cobra.Command, args []string) error { + if len(args) == 0 { + return c.Help() + } + msg := fmt.Sprintf("unknown subcommand %q for %q", args[0], c.CommandPath()) + if sugg := c.SuggestionsFor(args[0]); len(sugg) > 0 { + msg += fmt.Sprintf(" — did you mean %q?", sugg[0]) + } + return output.NewError("usage.unknown_subcommand", msg).Wrap(output.ErrUsage) + } + } + for _, sub := range cmd.Commands() { + hardenGroupCommands(sub) + } +} + +// groupGuardAnnotation marks a command whose RunE was synthesised by +// hardenGroupCommands purely to reject unknown subcommands. Introspection +// (cmdtree / the LLM command validator) treats such commands as +// NON-runnable groups. +const groupGuardAnnotation = "pg_hardstorage.group_guard" + // fipsVersionSuffix mirrors versionBody.WriteText's " [FIPS]" marker for // the root --version flag. func fipsVersionSuffix() string { diff --git a/internal/cli/show.go b/internal/cli/show.go index dad2a84..5dadcb0 100644 --- a/internal/cli/show.go +++ b/internal/cli/show.go @@ -67,6 +67,9 @@ when triaging undelete candidates discovered via Args: cobra.ExactArgs(2), SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { + if err := requireBackupIDArg("show", args[1]); err != nil { + return err + } return runShow(cmd, args[0], args[1], repoURL, includeDeleted) }, } diff --git a/internal/cli/status.go b/internal/cli/status.go index adcb4f0..abebf06 100644 --- a/internal/cli/status.go +++ b/internal/cli/status.go @@ -349,7 +349,12 @@ func (b statusBody) WriteText(w io.Writer) error { // they exist. lifecycle := lifecycleSummary(dep) if lifecycle != "" { - fmt.Fprintf(tw, " └─ %s\t\t\t\t\t\t\t\n", lifecycle) + // NO tab characters here: a tab-free line is a single + // trailing cell, which tabwriter excludes from column + // sizing. With the trailing tabs this 60+-char note + // became column 1's width and blew the whole table + // ~50 columns wide (plus a line of trailing spaces). + fmt.Fprintf(tw, " └─ %s\n", lifecycle) } } if err := tw.Flush(); err != nil { diff --git a/internal/cli/verify.go b/internal/cli/verify.go index 3497bc3..91e004e 100644 --- a/internal/cli/verify.go +++ b/internal/cli/verify.go @@ -89,6 +89,9 @@ Backup ID: if len(args) == 2 { backupID = args[1] } + if err := requireBackupIDArg("verify", backupID); err != nil { + return err + } full, _ := cmd.Flags().GetBool("full") pgMajor, _ := cmd.Flags().GetString("pg-major") if dispatch.controlPlane != "" { @@ -577,10 +580,10 @@ func (b verifyBody) WriteText(w io.Writer) error { b.ChunksReferenced, b.ChunksUnique, b.ChunksSampled) if b.ChunksMismatched == 0 { if b.ExistenceOnly { - fmt.Fprintf(bw, " ✓ %d chunk(s) present (no integrity check) in %dms\n", + fmt.Fprintf(bw, " ✓ %d chunk(s) present (no integrity check) in %d ms\n", b.ChunksVerified, b.DurationMS) } else { - fmt.Fprintf(bw, " ✓ %d chunk(s) verified — %s in %dms\n", + fmt.Fprintf(bw, " ✓ %d chunk(s) verified — %s in %d ms\n", b.ChunksVerified, humanBytes(b.BytesVerified), b.DurationMS) } } else { diff --git a/internal/dsa/dsa.go b/internal/dsa/dsa.go index 7243f95..aaf6730 100644 --- a/internal/dsa/dsa.go +++ b/internal/dsa/dsa.go @@ -355,8 +355,8 @@ func suggestedActionsFor(article Article, tenant string) []SuggestedAction { return []SuggestedAction{ { Article: ArticleErasure, - Description: "Crypto-shred the tenant's KEK to render every affected backup unrecoverable. After successful shred, the data is mathematically inaccessible even though the on-disk bytes remain.", - Command: fmt.Sprintf("pg_hardstorage kms shred --tenant %s", tenant), + Description: fmt.Sprintf("Crypto-shred the KEK protecting tenant %q's backups to render every affected backup unrecoverable. After successful shred, the data is mathematically inaccessible even though the on-disk bytes remain. kms shred destroys the whole keyring KEK (it is not tenant-scoped), so confirm the affected-backup list first with --dry-run.", tenant), + Command: "pg_hardstorage kms shred --repo --confirm-keyring --require-approval --reason \"GDPR Art. 17\" --yes", DocURL: "https://docs.pghardstorage.org/runbooks/gdpr-art-17-erasure", }, { diff --git a/internal/dsa/dsa_test.go b/internal/dsa/dsa_test.go index 5e20a39..52f9e02 100644 --- a/internal/dsa/dsa_test.go +++ b/internal/dsa/dsa_test.go @@ -7,6 +7,7 @@ import ( "encoding/json" "errors" "net/url" + "strings" "testing" "time" @@ -341,7 +342,8 @@ func TestSuggestedActions_Article17_RecommendsKMSShred(t *testing.T) { hasShred := false for _, a := range r.SuggestedActions { if a.Article == dsa.ArticleErasure && - a.Command == "pg_hardstorage kms shred --tenant tenant-a" { + strings.HasPrefix(a.Command, "pg_hardstorage kms shred --repo") && + strings.Contains(a.Command, "--require-approval") { hasShred = true } } diff --git a/internal/restore/restore.go b/internal/restore/restore.go index b6bd0c2..43de921 100644 --- a/internal/restore/restore.go +++ b/internal/restore/restore.go @@ -396,8 +396,14 @@ func Restore(ctx context.Context, opts Options) (res *Result, err error) { fmt.Sprintf("restore: checkpoint at %s belongs to backup %q, not %q", opts.TargetDir, cp.BackupID, m.BackupID)). WithSuggestion(&output.Suggestion{ - Human: "remove the target dir entirely if you want a fresh restore, or pass --backup matching the existing checkpoint", - Command: "rm -rf " + opts.TargetDir, + // The safe default is RESUMING the interrupted restore + // (its backup ID is the positional argument) — never + // steer an operator or automation toward `rm -rf` of a + // directory that may hold their partially-restored data. + Human: fmt.Sprintf("to resume the interrupted restore, re-run with its backup ID: `pg_hardstorage restore %s %s --target %s`; only if you deliberately want a FRESH restore of a different backup, remove the target directory first", + m.Deployment, cp.BackupID, opts.TargetDir), + Command: fmt.Sprintf("pg_hardstorage restore %s %s --target %s", + m.Deployment, cp.BackupID, opts.TargetDir), }) } completed := map[string]struct{}{} diff --git a/internal/restore/walfetchcmd/differential_test.go b/internal/restore/walfetchcmd/differential_test.go index 415c732..ee2a654 100644 --- a/internal/restore/walfetchcmd/differential_test.go +++ b/internal/restore/walfetchcmd/differential_test.go @@ -72,7 +72,7 @@ func TestBuild_DifferentialAgainstRealShell(t *testing.T) { t.Fatal(err) } argv := strings.Split(strings.TrimRight(string(raw), "\n"), "\n") - want := []string{"wal", "fetch", dep, walName, dest, "--repo", c.repoURL} + want := []string{"wal", "fetch", dep, walName, dest, "--repo", c.repoURL, "-o", "text", "-q"} if len(argv) != len(want) { t.Fatalf("agent saw %d args, want %d\n got=%q\nwant=%q\nrestore_command=%s", len(argv), len(want), argv, want, rc) diff --git a/internal/restore/walfetchcmd/walfetchcmd.go b/internal/restore/walfetchcmd/walfetchcmd.go index 7a80c9f..635ca56 100644 --- a/internal/restore/walfetchcmd/walfetchcmd.go +++ b/internal/restore/walfetchcmd/walfetchcmd.go @@ -104,7 +104,14 @@ const RestoreBinEnv = "PG_HARDSTORAGE_RESTORE_BIN" // `%f` / `%p` are emitted as literal placeholders for PG to // substitute per the usual restore_command contract. func Build(agentBin, deployment, repoURL string) string { - return fmt.Sprintf(`%s wal fetch %s %%f %%p --repo %s; ec=$?; [ $ec = 6 ] && exit 1 || exit $ec`, + // `-o text -q`: restore_command runs off-TTY, where the global + // output default is JSON — every routine "segment not in repo" + // probe (normal at end-of-WAL) would otherwise dump a 10-line + // pretty-printed JSON document into the PostgreSQL server log, + // twice per poll cycle, and restore error reports quoting the log + // tail became walls of nested escaped JSON. Text mode keeps it to + // a single line. + return fmt.Sprintf(`%s wal fetch %s %%f %%p --repo %s -o text -q; ec=$?; [ $ec = 6 ] && exit 1 || exit $ec`, ShellQuote(resolveRestoreBin(agentBin)), ShellQuote(deployment), ShellQuote(repoURL)) diff --git a/man/man1/pg_hardstorage-backup-delete.1 b/man/man1/pg_hardstorage-backup-delete.1 index 7092afb..3267c58 100644 --- a/man/man1/pg_hardstorage-backup-delete.1 +++ b/man/man1/pg_hardstorage-backup-delete.1 @@ -58,6 +58,10 @@ soft-delete plan from the configured policy. \fB--require-approval\fP="" approval request ID that must be in approved state for backup.delete + this backup ID (n-of-m gate; strongly recommended) +.PP +\fB--yes\fP[=false] + confirm the tombstone (not needed when --require-approval is given) + .SH OPTIONS INHERITED FROM PARENT COMMANDS .PP diff --git a/man/man1/pg_hardstorage-jit.1 b/man/man1/pg_hardstorage-jit.1 index c49c861..09fbc13 100644 --- a/man/man1/pg_hardstorage-jit.1 +++ b/man/man1/pg_hardstorage-jit.1 @@ -37,8 +37,8 @@ pg_hardstorage jit issue ops@acme.example \\ # operator consumes (future commit will wire --jit-token # into kms shred + other destructive commands) -pg_hardstorage kms shred --tenant T \\ - --jit-token --repo s3://acme +pg_hardstorage kms shred --repo s3://acme \\ + --confirm-keyring --require-approval --yes # admin lists current tokens pg_hardstorage jit list --repo s3://acme