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.
@@ -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.
@@ -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.