firedrill 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- firedrill-0.1.0/LICENSE +21 -0
- firedrill-0.1.0/PKG-INFO +422 -0
- firedrill-0.1.0/README.md +396 -0
- firedrill-0.1.0/firedrill/__init__.py +3 -0
- firedrill-0.1.0/firedrill/archive.py +206 -0
- firedrill-0.1.0/firedrill/cli.py +170 -0
- firedrill-0.1.0/firedrill/config.py +530 -0
- firedrill-0.1.0/firedrill/docker.py +246 -0
- firedrill-0.1.0/firedrill/drill.py +629 -0
- firedrill-0.1.0/firedrill/finding.py +99 -0
- firedrill-0.1.0/firedrill/history.py +106 -0
- firedrill-0.1.0/firedrill/ladder.py +539 -0
- firedrill-0.1.0/firedrill/pitr.py +224 -0
- firedrill-0.1.0/firedrill/report.py +197 -0
- firedrill-0.1.0/firedrill/restore.py +299 -0
- firedrill-0.1.0/firedrill/sources.py +229 -0
- firedrill-0.1.0/firedrill.egg-info/PKG-INFO +422 -0
- firedrill-0.1.0/firedrill.egg-info/SOURCES.txt +23 -0
- firedrill-0.1.0/firedrill.egg-info/dependency_links.txt +1 -0
- firedrill-0.1.0/firedrill.egg-info/entry_points.txt +2 -0
- firedrill-0.1.0/firedrill.egg-info/requires.txt +4 -0
- firedrill-0.1.0/firedrill.egg-info/top_level.txt +1 -0
- firedrill-0.1.0/pyproject.toml +53 -0
- firedrill-0.1.0/setup.cfg +4 -0
- firedrill-0.1.0/tests/test_firedrill.py +1969 -0
firedrill-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ritish Saini
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
firedrill-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: firedrill
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A backup you have not restored is not a backup. firedrill restores it and proves it is usable.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Source, https://github.com/MaXiMo000/firedrill
|
|
7
|
+
Project-URL: Issues, https://github.com/MaXiMo000/firedrill/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/MaXiMo000/firedrill/releases
|
|
9
|
+
Keywords: postgres,postgresql,backup,restore,disaster-recovery,rto,pg_dump,pg_restore,verification,ci
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Intended Audience :: System Administrators
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Topic :: Database
|
|
17
|
+
Classifier: Topic :: System :: Archiving :: Backup
|
|
18
|
+
Classifier: Topic :: System :: Recovery Tools
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Requires-Dist: PyYAML>=6.0
|
|
23
|
+
Provides-Extra: s3
|
|
24
|
+
Requires-Dist: boto3>=1.34; extra == "s3"
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
|
|
27
|
+
# firedrill
|
|
28
|
+
|
|
29
|
+
**You don't have backups. You have hopes.**
|
|
30
|
+
|
|
31
|
+
firedrill takes a Postgres backup and *actually restores it* into a disposable,
|
|
32
|
+
version-matched container, then reports whether it worked and how long each
|
|
33
|
+
stage took. It is built to fail your build the day a backup stops being
|
|
34
|
+
restorable — not the day you need it.
|
|
35
|
+
|
|
36
|
+
> A verification that could not run never reports as passing.
|
|
37
|
+
|
|
38
|
+
**<https://maximo000.github.io/firedrill/>** — what it checks, and the measurements behind each check.
|
|
39
|
+
|
|
40
|
+
That rule is the whole design. No Docker, an unreadable archive, a container
|
|
41
|
+
that never came up — every one of those produces **COULD NOT VERIFY** and a
|
|
42
|
+
non-zero exit, never a green tick. A backup tool that says "OK" because it
|
|
43
|
+
silently skipped the restore is worse than no tool, because it manufactures
|
|
44
|
+
confidence.
|
|
45
|
+
|
|
46
|
+
## Status
|
|
47
|
+
|
|
48
|
+
**Phases 0–4.** Fetch (local, presigned URL, or S3, checksum-verified),
|
|
49
|
+
restore into an ephemeral version-matched container, then the ladder:
|
|
50
|
+
structure, volume, semantics, integrity. Three tiers, JUnit and JSON output,
|
|
51
|
+
a history file that turns RTO into a measured trend, a GitHub Action, and
|
|
52
|
+
point-in-time recovery. See `PLAN.md` for what is deliberately not built.
|
|
53
|
+
|
|
54
|
+
What works today:
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
$ firedrill run backups/production-2026-08-23.dump
|
|
58
|
+
|
|
59
|
+
firedrill backups/production-2026-08-23.dump
|
|
60
|
+
archive custom v1.15.0 13.3KB from 'postgres'
|
|
61
|
+
source PostgreSQL 16.15 (Debian 16.15-1.pgdg13+2) -> restored into postgres:16
|
|
62
|
+
|
|
63
|
+
[ok ] inspect 0.00s 16.15 (Debian 16.15-1.pgdg13+2) (custom)
|
|
64
|
+
[ok ] target 0.83s postgres:16
|
|
65
|
+
[ok ] restore 0.09s exit 0
|
|
66
|
+
[ok ] smoke 0.15s 1 user table(s)
|
|
67
|
+
|
|
68
|
+
total 1.42s
|
|
69
|
+
|
|
70
|
+
PASS -- restored and answered queries.
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
And when it isn't fine:
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
[ok ] inspect 0.00s 16.15 (Debian 16.15-1.pgdg13+2) (custom)
|
|
77
|
+
[ok ] target 0.83s postgres:16
|
|
78
|
+
[FAIL] restore 0.09s exit 1
|
|
79
|
+
[ok ] smoke 0.16s 1 user table(s)
|
|
80
|
+
|
|
81
|
+
1 finding(s):
|
|
82
|
+
CRITICAL ARCHIVE_TRUNCATED could not read from input file: end of file
|
|
83
|
+
The archive is incomplete. The backup job most likely ran out of
|
|
84
|
+
disk or was killed. Check the writer's exit status and free space
|
|
85
|
+
at write time -- pg_dump can exit 0 having written a short file.
|
|
86
|
+
|
|
87
|
+
FAIL -- the restore ran and produced findings above.
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Note the schema restored and the table exists. Only the rows are missing. That
|
|
91
|
+
is what a truncated backup looks like from the outside.
|
|
92
|
+
|
|
93
|
+
## Install
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
pip install firedrill
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Python 3.10+, and a working Docker. No Postgres client is required on the host —
|
|
100
|
+
the archive header is parsed in pure Python and every database operation happens
|
|
101
|
+
inside the target container, which is also what makes the version matching real.
|
|
102
|
+
|
|
103
|
+
## Use
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
firedrill run path/to/dump.dump # restore and report
|
|
107
|
+
firedrill run dump.dump --json report.json # machine-readable
|
|
108
|
+
firedrill run dump.dump --rto 45m # exceeding the budget is a finding
|
|
109
|
+
firedrill run dump.dump --tier fast # schema only, for every commit
|
|
110
|
+
firedrill clean # remove containers left by a crash
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Exit code is `0` only when the restore genuinely ran and produced no finding at
|
|
114
|
+
or above `--fail-on` (default `high`).
|
|
115
|
+
|
|
116
|
+
## Where the backup lives
|
|
117
|
+
|
|
118
|
+
A path works, and so does the place the backup actually sits:
|
|
119
|
+
|
|
120
|
+
```yaml
|
|
121
|
+
version: 1
|
|
122
|
+
source:
|
|
123
|
+
type: s3
|
|
124
|
+
bucket: acme-backups
|
|
125
|
+
prefix: postgres/daily/
|
|
126
|
+
select: newest # or an explicit `key:`
|
|
127
|
+
sha256: "007168050a7570c6a9c93230992de425f81316bf688ceb277546e45265aae9d5"
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
`type: local` (a path), `type: https` (a presigned URL — S3, GCS and Azure all
|
|
131
|
+
issue them), and `type: s3` (`pip install firedrill[s3]`). Give a `sha256:` or
|
|
132
|
+
a `size:` and the artefact is checked against it **before** anything tries to
|
|
133
|
+
restore it: a dump that arrives truncated but plausible never reaches a
|
|
134
|
+
container, and the run reports rather than passes. Quote the digest — YAML
|
|
135
|
+
reads a bare all-digit value as a number.
|
|
136
|
+
|
|
137
|
+
Three properties worth stating plainly:
|
|
138
|
+
|
|
139
|
+
- **Read-only by construction.** `sources.py` contains no verb that writes,
|
|
140
|
+
deletes, copies or tags anything at the origin. "Never writes to the source"
|
|
141
|
+
is a property of the file, not a promise in a README.
|
|
142
|
+
- **Credentials from the environment only.** There is no `--access-key` and no
|
|
143
|
+
place for one in `firedrill.yml`, because `/proc/*/cmdline` is world-readable
|
|
144
|
+
and CI logs echo command lines. boto3's default chain already does the right
|
|
145
|
+
thing.
|
|
146
|
+
- **A presigned URL's signature is a credential**, so it is stripped from
|
|
147
|
+
every report, log line and finding, and plain `http` to a non-local host is
|
|
148
|
+
refused outright rather than putting a working one on the wire.
|
|
149
|
+
|
|
150
|
+
## Tiers: how much to restore
|
|
151
|
+
|
|
152
|
+
A 2 TB restore cannot run on every commit.
|
|
153
|
+
|
|
154
|
+
| tier | restores | what does NOT run |
|
|
155
|
+
|---|---|---|
|
|
156
|
+
| `full` | everything | — |
|
|
157
|
+
| `fast` | schema only | row counts, smoke queries, sequence checks |
|
|
158
|
+
| `sample` | schema + rows for named tables | smoke queries, sequence checks |
|
|
159
|
+
|
|
160
|
+
```yaml
|
|
161
|
+
version: 1
|
|
162
|
+
tier: sample
|
|
163
|
+
sample:
|
|
164
|
+
tables: [orders, customer]
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
The report always says which tier ran, in capitals when it is not `full`, and
|
|
168
|
+
a partial pass gets its own sentence — `PASS (fast tier) — the schema
|
|
169
|
+
restored. Whether the DATA is there was not checked.` A pass from a
|
|
170
|
+
schema-only run must never look like a pass from a full one.
|
|
171
|
+
|
|
172
|
+
The rungs a tier cannot honour report **NOT RUN**, never a tick, and the
|
|
173
|
+
config refuses combinations that would produce a misleading finding rather
|
|
174
|
+
than running them: `tier: sample` with a `volume` rule on an unsampled table,
|
|
175
|
+
or with `semantics` at all — a smoke query is arbitrary SQL, so there is no
|
|
176
|
+
knowing whether it reads a table whose rows came back.
|
|
177
|
+
|
|
178
|
+
## The ladder, and `firedrill.yml`
|
|
179
|
+
|
|
180
|
+
A bare `firedrill run` proves the backup restores and answers queries. To prove
|
|
181
|
+
it restored *the right data*, put a `firedrill.yml` next to it — it is picked up
|
|
182
|
+
automatically, or named with `--config`.
|
|
183
|
+
|
|
184
|
+
```yaml
|
|
185
|
+
version: 1
|
|
186
|
+
|
|
187
|
+
rto_budget: 45m
|
|
188
|
+
|
|
189
|
+
structure:
|
|
190
|
+
reference: schema/production.txt # committed, reviewable, diffable
|
|
191
|
+
|
|
192
|
+
volume:
|
|
193
|
+
tables:
|
|
194
|
+
orders: {min_rows: 1}
|
|
195
|
+
|
|
196
|
+
semantics:
|
|
197
|
+
- name: recent orders exist
|
|
198
|
+
sql: SELECT count(*) FROM orders WHERE created_at > now() - interval '7 days'
|
|
199
|
+
expect: "> 0"
|
|
200
|
+
|
|
201
|
+
ignore:
|
|
202
|
+
- check: COLLATION_UNVERIFIABLE
|
|
203
|
+
reason: "restoring on alpine in CI; tracked in DR-114"
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
Generate the structure reference once and commit it:
|
|
207
|
+
|
|
208
|
+
```bash
|
|
209
|
+
firedrill run dump.dump --write-reference schema/production.txt
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
It is one line per catalog object, sorted, so a schema change shows up as a
|
|
213
|
+
readable diff in review rather than a wall of `pg_dump` output.
|
|
214
|
+
|
|
215
|
+
Three things the loader does that are worth knowing, because each one is a
|
|
216
|
+
failure it refuses to let pass quietly:
|
|
217
|
+
|
|
218
|
+
- **An unknown key is an error, not a warning.** A typo'd `tolerence:` that
|
|
219
|
+
loaded silently would mean a check you believe is running is not running,
|
|
220
|
+
and the run would still go green.
|
|
221
|
+
- **Every `ignore` needs a written reason.** An unexplained suppression is a
|
|
222
|
+
config error. It is the only process this tool imposes, and it is what keeps
|
|
223
|
+
a green run meaningful.
|
|
224
|
+
- **`expect` must be a comparison against a number.** There is deliberately no
|
|
225
|
+
way to write a check whose result is printed, so a smoke query returns a
|
|
226
|
+
shape and never a row.
|
|
227
|
+
|
|
228
|
+
Rungs that nothing configured report `n/a` — *not configured* — rather than a
|
|
229
|
+
tick. "Nothing asked for this" and "this passed" are different facts.
|
|
230
|
+
|
|
231
|
+
## What it checks today
|
|
232
|
+
|
|
233
|
+
| Rule | Meaning |
|
|
234
|
+
|---|---|
|
|
235
|
+
| `ARCHIVE_UNREADABLE` | the header will not parse — truncated, empty, or not a custom-format dump |
|
|
236
|
+
| `ARCHIVE_TRUNCATED` | the restore hit end-of-file partway through |
|
|
237
|
+
| `ROLE_ABSENT` | `OWNER TO` names a role that does not exist on the target |
|
|
238
|
+
| `EXTENSION_ABSENT` | an extension's binaries are missing from the restore image |
|
|
239
|
+
| `RESTORE_ERROR` / `RESTORE_WARNING` | anything else `pg_restore` said |
|
|
240
|
+
| `RESTORE_FAILED` | non-zero exit with nothing classifiable — never treated as success |
|
|
241
|
+
| `EXIT_CODE_LIED` | exit 0 with errors on stderr |
|
|
242
|
+
| `EMPTY_RESTORE` | restored cleanly and contains no user tables |
|
|
243
|
+
| `TARGET_UNAVAILABLE` | the restore could not be attempted |
|
|
244
|
+
| `RTO_EXCEEDED` | slower than the stated budget |
|
|
245
|
+
| `FETCH_FAILED` | the artefact could not be obtained, or is not the bytes that were claimed |
|
|
246
|
+
| `SOURCE_AMBIGUOUS` | a path *and* a configured source — which backup did you mean? |
|
|
247
|
+
| `VOLUME_DRIFT` | a table lost more rows than the tolerance allows, against the last known-good run |
|
|
248
|
+
| `SEQUENCE_UNCHECKED` | the database has sequences and none could be tied to a column, so none were checked |
|
|
249
|
+
| `PITR_TARGET_UNREACHED` | the WAL archive ends before the moment you asked to recover to |
|
|
250
|
+
| `PITR_UNASSERTED` | recovery reached the target and nothing checked what the database then held |
|
|
251
|
+
| `VERSION_MISMATCH` | `--postgres` pinned a major the archive did not come from |
|
|
252
|
+
| `STRUCTURE_MISSING` | an object in the committed reference did not come back |
|
|
253
|
+
| `STRUCTURE_UNEXPECTED` | the database has drifted from the reference |
|
|
254
|
+
| `VOLUME_BELOW_MINIMUM` | a table restored with fewer rows than the config requires |
|
|
255
|
+
| `VOLUME_TABLE_MISSING` | the config expects a table the restore does not have |
|
|
256
|
+
| `SEMANTICS_FAILED` | a smoke query restored cleanly and answered the wrong thing |
|
|
257
|
+
| `SEQUENCE_BEHIND` | a sequence is below `max(id)`; the first insert will collide |
|
|
258
|
+
| `COLLATION_MISMATCH` | the target's libc differs from the reference's — text indexes sort differently |
|
|
259
|
+
| `COLLATION_UNVERIFIABLE` | the target reports no collation version, so sort order cannot be checked at all |
|
|
260
|
+
|
|
261
|
+
Each of these is proved against a deliberately broken backup that `pg_restore`
|
|
262
|
+
itself is perfectly happy with — that is the point of the whole ladder — and
|
|
263
|
+
each is also asserted *not* to fire on a healthy one. A false positive costs
|
|
264
|
+
exactly what a false negative costs: a DR tool that cries wolf gets muted, and
|
|
265
|
+
a muted DR tool is worse than none because it still looks like coverage.
|
|
266
|
+
|
|
267
|
+
## A correction to the plan
|
|
268
|
+
|
|
269
|
+
`PLAN.md` §3.3 says a missing role or extension surfaces as a `pg_restore`
|
|
270
|
+
*warning* **with a zero exit code**. Measured on PostgreSQL 16 and 18 with
|
|
271
|
+
custom-format archives, that is not what happens: the exit code was `1` in every
|
|
272
|
+
broken case, accompanied by `warning: errors ignored on restore: N`.
|
|
273
|
+
|
|
274
|
+
The exit code is still not sufficient, for reasons that survive the correction:
|
|
275
|
+
it is one bit, so it says *something* broke but never *what*, and "role absent"
|
|
276
|
+
and "archive truncated" need different findings and different fixes. firedrill
|
|
277
|
+
therefore uses both signals and reports which one fired — with `EXIT_CODE_LIED`
|
|
278
|
+
kept as a guard, because the tool must not depend on that measurement staying
|
|
279
|
+
true in a future release.
|
|
280
|
+
|
|
281
|
+
## In CI, in ten lines
|
|
282
|
+
|
|
283
|
+
```yaml
|
|
284
|
+
- uses: MaXiMo000/firedrill@v0
|
|
285
|
+
with:
|
|
286
|
+
config: firedrill.yml
|
|
287
|
+
rto: 45m
|
|
288
|
+
history: firedrill-history.json
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
The action fails the build when the drill fails **and** when it could not run
|
|
292
|
+
at all — "we did not verify" must never be quieter than "we verified and it
|
|
293
|
+
was fine". It publishes the report before failing, so a red build is an
|
|
294
|
+
actionable one, and exposes `ok`, `verified`, `findings`, `seconds` and a
|
|
295
|
+
`summary` like `restored in 4m12s, 0 findings`.
|
|
296
|
+
|
|
297
|
+
Copy-paste templates live in [`examples/`](examples/): a nightly full drill
|
|
298
|
+
with read-only AWS credentials via OIDC, and a PR comment that runs the fast
|
|
299
|
+
tier and edits its own comment instead of posting a new one each push.
|
|
300
|
+
|
|
301
|
+
`--junit report.xml` writes JUnit XML so CI shows each rung separately. A rung
|
|
302
|
+
that could not run is `<skipped>`, never a silent pass, because most
|
|
303
|
+
dashboards colour those differently — which is exactly the distinction worth
|
|
304
|
+
preserving.
|
|
305
|
+
|
|
306
|
+
## Trends: RTO you have measured, not claimed
|
|
307
|
+
|
|
308
|
+
```bash
|
|
309
|
+
firedrill run dump.dump --history firedrill-history.json
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
Each run appends its durations, row counts and versions, and is measured
|
|
313
|
+
against the last known-good run *of the same tier*. The report then says
|
|
314
|
+
`19% slower than the last good full run (2.3s on 2026-08-24T…)`.
|
|
315
|
+
|
|
316
|
+
That history is also what makes `volume.tolerance` meaningful — a tolerance
|
|
317
|
+
needs something to be tolerant of:
|
|
318
|
+
|
|
319
|
+
```yaml
|
|
320
|
+
version: 1
|
|
321
|
+
volume:
|
|
322
|
+
tolerance: 10%
|
|
323
|
+
tables:
|
|
324
|
+
audit_log: {tolerance: 50%} # append-only, grows fast
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
Only a **drop** past the tolerance is a finding. Tables grow; a rule that
|
|
328
|
+
fired on growth would go off every week until somebody muted it, taking the
|
|
329
|
+
real findings with it.
|
|
330
|
+
|
|
331
|
+
The history file holds counts, durations and versions — aggregates and
|
|
332
|
+
catalog facts. It has no field that could hold a row, which is pinned by a
|
|
333
|
+
test, because it is the artefact of this tool most likely to be committed to
|
|
334
|
+
a repo by accident.
|
|
335
|
+
|
|
336
|
+
## Point-in-time recovery
|
|
337
|
+
|
|
338
|
+
A dump proves you can get *a* database back. PITR proves you can get it back to
|
|
339
|
+
a **chosen moment** — which is what you need after a `DELETE` without a `WHERE`
|
|
340
|
+
at 14:02. Almost nobody tests it, because testing it means performing it.
|
|
341
|
+
|
|
342
|
+
```bash
|
|
343
|
+
firedrill pitr \
|
|
344
|
+
--base /backups/base --wal /backups/wal \
|
|
345
|
+
--target '2026-08-25 14:01:00' --config firedrill.yml
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
The assertion has two halves, and it needs both:
|
|
349
|
+
|
|
350
|
+
```yaml
|
|
351
|
+
version: 1
|
|
352
|
+
semantics:
|
|
353
|
+
- name: the row written before the target survived
|
|
354
|
+
sql: select count(*) from events where label = 'before'
|
|
355
|
+
expect: "== 1"
|
|
356
|
+
- name: the row written after the target did not
|
|
357
|
+
sql: select count(*) from events where label = 'after'
|
|
358
|
+
expect: "== 0"
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
Restoring *everything* satisfies the first. Restoring *nothing* satisfies the
|
|
362
|
+
second. Only together do they say recovery stopped where it was told. Configure
|
|
363
|
+
neither and firedrill reports `PITR_UNASSERTED` rather than calling it a pass —
|
|
364
|
+
a server that came up proves a server came up.
|
|
365
|
+
|
|
366
|
+
Two things measured on PostgreSQL 16 that shaped this:
|
|
367
|
+
|
|
368
|
+
- An unreachable target does **not** silently promote. Recovery exits with
|
|
369
|
+
`FATAL: recovery ended before configured recovery target was reached`, so
|
|
370
|
+
"did it get there" needs no heuristic.
|
|
371
|
+
- `recovery_target_time` counts as reached only when a commit with a **later**
|
|
372
|
+
timestamp exists in the WAL. A target after the final commit is *unreached*,
|
|
373
|
+
not "satisfied at end of WAL".
|
|
374
|
+
|
|
375
|
+
## Prior art, honestly
|
|
376
|
+
|
|
377
|
+
| Thing | What it does | The gap |
|
|
378
|
+
|---|---|---|
|
|
379
|
+
| pgBackRest / WAL-G / Barman | take backups; `--check` validates archives | validates the *archive*, not a restored database |
|
|
380
|
+
| Cloud snapshot restore | restores | manual, unscheduled, unverified, unmeasured |
|
|
381
|
+
| `pg_verifybackup` | checksums a `pg_basebackup` | file integrity, not usability |
|
|
382
|
+
| Enterprise DR products | do this, well | expensive, closed, agent-based |
|
|
383
|
+
| Cron scripts | what most teams have | unversioned, unreported, silently rotted |
|
|
384
|
+
|
|
385
|
+
firedrill **never takes backups**. A tool that both takes and verifies its own
|
|
386
|
+
backups is grading its own homework.
|
|
387
|
+
|
|
388
|
+
## Safety
|
|
389
|
+
|
|
390
|
+
This tool restores databases, so the interlocks are the price of admission:
|
|
391
|
+
|
|
392
|
+
- It only ever uses a target it created itself. There is no code path that
|
|
393
|
+
connects to a user-supplied DSN.
|
|
394
|
+
- The backup is bind-mounted **read-only**.
|
|
395
|
+
- No `--dsn` and no `--password` flags. The container password is generated per
|
|
396
|
+
run, passed to Docker by variable *name* so it never enters argv, and never
|
|
397
|
+
written to disk.
|
|
398
|
+
- No port is published; the target is unreachable from the host.
|
|
399
|
+
- Teardown runs in a `finally`, and `firedrill clean` removes anything a crash
|
|
400
|
+
orphaned.
|
|
401
|
+
|
|
402
|
+
See `SECURITY.md` and `PLAN.md` §7.
|
|
403
|
+
|
|
404
|
+
## Development
|
|
405
|
+
|
|
406
|
+
```bash
|
|
407
|
+
python tests/make_corpus.py # build the broken-backup corpus
|
|
408
|
+
python tests/test_firedrill.py --require-integration
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
The corpus is generated from real containers, never hand-edited. Findings are
|
|
412
|
+
asserted in **both directions** — a false positive fails the build exactly as
|
|
413
|
+
hard as a false negative, because a DR tool that cries wolf gets muted, and a
|
|
414
|
+
muted DR tool still looks like coverage.
|
|
415
|
+
|
|
416
|
+
`--require-integration` makes a skipped container test a build failure. Without
|
|
417
|
+
it (Windows, where Linux containers cannot run) those tests skip *by name* and
|
|
418
|
+
the count is printed, so "it passed" and "it did not run" never look alike.
|
|
419
|
+
|
|
420
|
+
## Licence
|
|
421
|
+
|
|
422
|
+
MIT.
|