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
54 changes: 54 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,60 @@ keeps reading that version for at least 24 months after a successor lands.

## [Unreleased]

## [1.0.11] — 2026-07-16

Twelve operator-inconvenience fixes found by exercising the CLI surface,
each covered by a regression test.

### Fix: false alarms and silent wrong-target

- `repo scrub` reported 100% chunk corruption (exit 9) on every
ENCRYPTED repository — the default posture after `init` — because it
built a CAS with no decryptor. It now scrubs manifest-aware (the same
per-manifest CAS `repair scrub` uses), so encrypted chunks decrypt
and verify. Scheduled scrubs no longer page on every run.
- The global `-c`/`--config` flag was advertised everywhere but read
nowhere; the tool always loaded the XDG/FHS default. It is now honored
for both reads and write-back, so `-c staging.yaml` operates on that
file.
- `lint` always returned `{"status":"valid"}` without reading anything.
It now validates the resolved config with the real loader (strict
KnownFields + validation) and fails, with the reason, on a broken one.

### Fix: dry-run / advisory tools no longer give false confidence

- `recovery windows` advertised a PITR range straight across a WAL
archive hole; it now caps `latest_restore_lsn` at the first hole and
records the gap.
- `restore --preview` reported "Pre-flight: ✓ ready" for a target past a
WAL hole that the real restore warns will HALT recovery; preview now
surfaces the same `wal_archive_hole` finding.
- `capacity report` extrapolated a seconds-long sampling window into
absurd per-day growth labeled "medium confidence"; confidence now
requires a real observation window (≥1 day for medium, ≥1 week for
high) and a sub-day window carries an explicit caveat.
- `rotate` stamped legally-HELD backups `[del ]` in its per-backup
listing while the summary said `held: N (excluded from delete)`; held
backups now render `[held]`.

### Fix: wrong error class, hollow stubs, muscle memory

- `recovery readiness` printed the RTO throughput as a nonsensical
duration (`46603h22m40s`) instead of a byte-rate (`160.0 MiB/s`).
- `--incremental-from` against a PostgreSQL < 17 server was reported as
the generic `internal` (file-a-bug) code; it is now the structured
`backup.incremental_unsupported` usage error with a hint.
- `repo init` accepts the repository URL via `--repo` (matching every
other `repo` verb), not only as a bare positional.
- `explain <command>` now returns the command's real summary, usage, and
description instead of echoing the argument back.
- `glossary <term>` now returns the term's definition (an unknown term
is `notfound.term`) instead of dropping the description.

Also: the renderer integer-fidelity fix (YAML/CSV scientific-notation)
was extended to a shared `jsonshape` helper covering tap/junit/pdf/
template.

## [1.0.10] — 2026-07-15

### Fix: `recovery drill` failed every WAL-streaming backup (#26)
Expand Down
2 changes: 1 addition & 1 deletion docs/compliance/pci-dss.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ audit period plus the cosign signatures on the running
binary:

```sh
VERSION=1.0.10 # the release / image tag you're attesting
VERSION=1.0.11 # the release / image tag you're attesting

# 1. Audit chain bundle
pg_hardstorage audit export-bundle \
Expand Down
6 changes: 3 additions & 3 deletions docs/compliance/slsa-l3-provenance.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ Verify a downloaded artifact against its provenance with the
`slsa-verifier`:

```sh
VERSION=1.0.10 # the release you're verifying
VERSION=1.0.11 # the release you're verifying
slsa-verifier verify-artifact \
--provenance-path pg_hardstorage.intoto.jsonl \
--source-uri github.com/cybertec-postgresql/pg_hardstorage \
Expand Down Expand Up @@ -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.10 # the image tag you're verifying
VERSION=1.0.11 # the image tag you're verifying

# Verify the image signature
cosign verify "ghcr.io/cybertec-postgresql/pg_hardstorage:v${VERSION}" \
Expand Down Expand Up @@ -139,7 +139,7 @@ JSON format, generated by `syft` (configured via the
and attached to the GitHub Release:

```sh
VERSION=1.0.10 # the release you're inspecting
VERSION=1.0.11 # 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"
Expand Down
1 change: 1 addition & 0 deletions docs/reference/cli/pg_hardstorage_repo_init.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ pg_hardstorage repo init <url> [flags]
```
--compression string zstd encoder level: fast | balanced | max (default: balanced)
-h, --help help for init
--repo string repository URL to create (alternative to the positional <url>)
--worm-mode string WORM retention mode: compliance | governance (set with --worm-retention)
--worm-retention string WORM retention duration: e.g. 7y, 30d, 8760h (required with --worm-mode)
```
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/runbooks/control-plane-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,7 @@ curl --cacert /etc/pg_hardstorage/server/cert.pem \
# {
# "id": "db1.example.com",
# "host": "db1.example.com",
# "version": "v1.0.10",
# "version": "v1.0.11",
# "deployments": ["db1", "db2"],
# "registered_at": "...",
# "last_heartbeat": "..."
Expand Down
8 changes: 4 additions & 4 deletions docs/tutorials/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.10 # latest release: https://github.com/cybertec-postgresql/pg_hardstorage/releases/latest
VERSION=1.0.11 # 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/
Expand All @@ -45,7 +45,7 @@ pg_hardstorage version
### `.deb` (Debian / Ubuntu)

```sh
VERSION=1.0.10 # the release you downloaded
VERSION=1.0.11 # the release you downloaded
sudo dpkg -i "pg-hardstorage_${VERSION}_amd64.deb"
```

Expand All @@ -57,7 +57,7 @@ creates `/etc/pg_hardstorage/`, `/var/lib/pg_hardstorage/`,
### `.rpm` (Fedora / RHEL / Rocky / Alma)

```sh
VERSION=1.0.10 # the release you downloaded
VERSION=1.0.11 # the release you downloaded
sudo rpm -i "pg-hardstorage-${VERSION}-1.x86_64.rpm"
```

Expand Down Expand Up @@ -259,7 +259,7 @@ you are doing — exit 9 means the verifier said no.

```sh
$ pg_hardstorage version
pg_hardstorage v1.0.10 (abc1234, built 2026-04-29T12:00:00Z)
pg_hardstorage v1.0.11 (abc1234, built 2026-04-29T12:00:00Z)
```

`doctor` is the single-command "is anything wrong" check. It prints a
Expand Down
11 changes: 9 additions & 2 deletions internal/backup/runner/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -605,9 +605,16 @@ func Take(ctx context.Context, opts TakeOptions) (*Result, error) {
// can chain-walk correctly.
backupType := backup.BackupTypeFull
if opts.Incremental != nil && len(opts.Incremental.ParentPGManifest) > 0 {
// Refuse incremental against pre-PG-17 servers up front.
// Refuse incremental against pre-PG-17 servers up front. This
// is an environment precondition (the operator's server is too
// old), NOT a tool bug — classify it as a structured usage-class
// error, not the generic "internal" (file-a-bug) code.
if pgVersion.Major < 17 {
return nil, fmt.Errorf("backup: incremental backups require PG 17+; source is PG %d", pgVersion.Major)
return nil, output.NewError("backup.incremental_unsupported",
fmt.Sprintf("backup: incremental backups require PostgreSQL 17+; source is PG %d", pgVersion.Major)).
WithSuggestion(&output.Suggestion{
Human: "upgrade the source server to PostgreSQL 17+ (and enable summarize_wal), or take a full backup by omitting --incremental-from",
}).Wrap(output.ErrUsage)
}
backupType = backup.BackupTypeIncremental
}
Expand Down
48 changes: 41 additions & 7 deletions internal/capacity/capacity.go
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,12 @@ func Project(ctx context.Context, sp storage.StoragePlugin, repoURL string, opts
r.SamplesUsed = len(allSamples)
r.ProjectedDeltaBytes = int64(float64(r.BytesPerDay) * horizon.Hours() / 24)
r.ProjectedBytes = r.CurrentBytes + r.ProjectedDeltaBytes
r.Confidence = confidenceFor(r.RSquared, len(allSamples))
windowSeconds := sampleWindowSeconds(allSamples)
r.Confidence = confidenceFor(r.RSquared, len(allSamples), windowSeconds)
if windowSeconds < 86400 {
r.Note = fmt.Sprintf("samples span only %s; a per-day projection from a sub-day window is unreliable — collect backups over several days before trusting the growth rate",
time.Duration(windowSeconds*float64(time.Second)).Round(time.Second))
}
} else {
r.SamplesUsed = len(allSamples)
r.Confidence = "insufficient"
Expand All @@ -203,20 +208,49 @@ func sumDeploymentCurrent(deps []DeploymentProjection) int64 {
return s
}

// confidenceFor maps (R², sample count) to a human-friendly bucket.
// R² > 0.85 with ≥10 samples = high; > 0.7 with ≥5 = medium; otherwise
// low. Below 3 samples = insufficient (handled upstream).
func confidenceFor(r2 float64, n int) string {
// confidenceFor maps (R², sample count, observation window) to a
// human-friendly bucket. The observation WINDOW is the load-bearing
// guard: a per-day projection extrapolated from samples spanning only
// seconds/minutes is meaningless no matter how tight the R² — e.g. five
// backups taken within a minute yielded "85 GiB/day, 7.5 TiB projected,
// confidence: medium". A projection can only be high/medium if the
// samples actually span a meaningful fraction of the extrapolation unit.
func confidenceFor(r2 float64, n int, windowSeconds float64) string {
const (
day = 86400.0
week = 7 * day
)
switch {
case r2 >= 0.85 && n >= 10:
case r2 >= 0.85 && n >= 10 && windowSeconds >= week:
return "high"
case r2 >= 0.70 && n >= 5:
case r2 >= 0.70 && n >= 5 && windowSeconds >= day:
return "medium"
default:
// Under a day of observation the per-day slope is not
// trustworthy; cap at "low" regardless of fit.
return "low"
}
}

// sampleWindowSeconds returns the wall-clock span covered by the
// samples (last timestamp minus first). Samples are assumed sorted or
// are min/max-scanned here.
func sampleWindowSeconds(samples []sample) float64 {
if len(samples) < 2 {
return 0
}
min, max := samples[0].at, samples[0].at
for _, s := range samples[1:] {
if s.at.Before(min) {
min = s.at
}
if s.at.After(max) {
max = s.at
}
}
return max.Sub(min).Seconds()
}

// sample is one (timestamp, cumulative-bytes) point.
type sample struct {
at time.Time
Expand Down
22 changes: 22 additions & 0 deletions internal/capacity/window_guard_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package capacity

import "testing"

// Regression: a sub-day observation window must never yield medium/high
// confidence, no matter how tight R² — a per-day projection from
// seconds of data is meaningless (five backups in a minute previously
// gave "medium").
func TestConfidenceFor_ShortWindowCappedLow(t *testing.T) {
// Perfect fit, plenty of samples, but only 90 seconds of window.
if got := confidenceFor(0.99, 20, 90); got != "low" {
t.Errorf("confidenceFor(tight fit, 90s window) = %q, want low", got)
}
// A week of samples with a good fit earns high.
if got := confidenceFor(0.90, 12, 8*86400); got != "high" {
t.Errorf("confidenceFor(week window) = %q, want high", got)
}
// A couple of days with a decent fit earns medium.
if got := confidenceFor(0.75, 6, 3*86400); got != "medium" {
t.Errorf("confidenceFor(3-day window) = %q, want medium", got)
}
}
23 changes: 23 additions & 0 deletions internal/cli/configio.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import (
"fmt"
"os"
"path/filepath"
"strings"

"github.com/spf13/cobra"

"github.com/cybertec-postgresql/pg_hardstorage/internal/config"
"github.com/cybertec-postgresql/pg_hardstorage/internal/fsutil"
Expand All @@ -16,7 +19,27 @@ import (
// Same lookup as the rest of the CLI: paths.Resolve handles the
// XDG / FHS / env / --config precedence; we just append the
// well-known filename here.
// captureConfigFlag forwards the global -c/--config flag to the paths
// layer via PG_HARDSTORAGE_CONFIG_FILE, so every config.Load call in
// this process (all 40-odd, plus write-back) honours it uniformly.
// Must run BEFORE installDispatcher / resolveDeploymentDefaults, which
// both load config. The flag was previously registered but read
// nowhere — a silent no-op.
func captureConfigFlag(cmd *cobra.Command, _ []string) error {
cfgFile, _ := cmd.Flags().GetString("config")
if strings.TrimSpace(cfgFile) != "" {
_ = os.Setenv("PG_HARDSTORAGE_CONFIG_FILE", cfgFile)
}
return nil
}

func configFilePath(p *paths.Paths) string {
// Honour an explicit -c/--config file for write-back too, so
// `deployment edit -c staging.yaml` mutates staging.yaml rather
// than silently rewriting the XDG/FHS default.
if override := p.ConfigFileOverride; override != "" {
return override
}
return filepath.Join(p.Config.Value, "pg_hardstorage.yaml")
}

Expand Down
Loading
Loading