firedrill 0.1.0__py3-none-any.whl

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/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """firedrill -- prove a Postgres backup actually restores."""
2
+
3
+ __version__ = "0.1.0"
firedrill/archive.py ADDED
@@ -0,0 +1,206 @@
1
+ """Read a pg_dump custom-format header in pure Python.
2
+
3
+ Why not shell out to `pg_restore --list`? Because of a bootstrap problem: the
4
+ whole point of §3.2 is to start a container matching the dump's server version,
5
+ and you cannot ask a version-matched container what version to be. Something
6
+ has to read the header before any container exists. Doing it here means the
7
+ host needs no Postgres client at all -- which is what lets firedrill run on a
8
+ laptop with no Postgres installed, and on a CI runner that cannot run Linux
9
+ containers.
10
+
11
+ The layout below was not taken from documentation. It was decoded byte by byte
12
+ from real dumps produced by PostgreSQL 14, 16 and 18 (archive versions 1.14,
13
+ 1.15 and 1.16) and cross-checked against `pg_restore --list` output for the
14
+ same files. tests/test_firedrill.py pins it against committed header bytes.
15
+
16
+ offset bytes meaning
17
+ 0 5 magic "PGDMP"
18
+ 5 1 archive version major
19
+ 6 1 archive version minor
20
+ 7 1 archive version revision
21
+ 8 1 sizeof(int) used for Int fields
22
+ 9 1 sizeof(off_t)
23
+ 10 1 format: 1=custom 3=tar 5=directory
24
+ 11 * compression: one raw byte from archive 1.15 onward,
25
+ an Int before that (PG 15 and earlier)
26
+ ... * 7 Ints: sec, min, hour, mday, mon, year, isdst
27
+ ... * Str dbname
28
+ ... * Str server version <- the one we came for
29
+ ... * Str pg_dump version
30
+
31
+ Int is a sign byte followed by `intSize` little-endian bytes.
32
+ Str is an Int length followed by that many bytes; a negative length is NULL.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import dataclasses
38
+ import pathlib
39
+ import re
40
+
41
+ MAGIC = b"PGDMP"
42
+
43
+ FORMAT_CUSTOM = 1
44
+ FORMAT_TAR = 3
45
+ FORMAT_DIRECTORY = 5
46
+
47
+ _FORMAT_NAMES = {1: "custom", 3: "tar", 5: "directory"}
48
+
49
+ # From this archive version the compression field is a single raw byte rather
50
+ # than an Int. Verified: PG 14 -> 1.14 (Int), PG 16 -> 1.15 (byte),
51
+ # PG 18 -> 1.16 (byte).
52
+ _COMPRESSION_IS_BYTE_FROM = (1, 15)
53
+
54
+
55
+ class ArchiveError(Exception):
56
+ """The archive could not be read. Never a pass -- always a finding."""
57
+
58
+
59
+ @dataclasses.dataclass(frozen=True)
60
+ class ArchiveHeader:
61
+ archive_version: tuple[int, int, int]
62
+ int_size: int
63
+ offset_size: int
64
+ format: int
65
+ compression: int
66
+ dbname: str | None
67
+ server_version: str | None
68
+ pgdump_version: str | None
69
+
70
+ @property
71
+ def format_name(self) -> str:
72
+ return _FORMAT_NAMES.get(self.format, f"unknown({self.format})")
73
+
74
+ @property
75
+ def server_major(self) -> str:
76
+ """The major version as Postgres names its images: '16', '9.6'."""
77
+ return major_of(self.server_version)
78
+
79
+
80
+ def major_of(version: str | None) -> str:
81
+ """'16.15 (Debian ...)' -> '16'. '9.6.24' -> '9.6'."""
82
+ if not version:
83
+ raise ArchiveError("archive header carries no server version")
84
+ match = re.match(r"\s*(\d+)(?:\.(\d+))?", version)
85
+ if not match:
86
+ raise ArchiveError(f"unparseable server version {version!r}")
87
+ first = int(match.group(1))
88
+ # Postgres switched to a single-number major at 10. Before that the major
89
+ # was two components, and 9.6 images are still named "9.6".
90
+ if first < 10:
91
+ if match.group(2) is None:
92
+ raise ArchiveError(f"unparseable pre-10 server version {version!r}")
93
+ return f"{first}.{int(match.group(2))}"
94
+ return str(first)
95
+
96
+
97
+ class _Reader:
98
+ """A cursor that refuses to read past the end of what it was given.
99
+
100
+ A truncated dump is the fixture this whole project exists for, so running
101
+ off the end has to raise something specific rather than an IndexError that
102
+ a caller might mistake for a bug in firedrill.
103
+ """
104
+
105
+ def __init__(self, data: bytes, int_size: int = 4):
106
+ self.data = data
107
+ self.pos = 0
108
+ self.int_size = int_size
109
+
110
+ def take(self, n: int) -> bytes:
111
+ if n < 0 or self.pos + n > len(self.data):
112
+ raise ArchiveError(
113
+ f"archive ends mid-header: wanted {n} bytes at offset {self.pos}, "
114
+ f"only {len(self.data) - self.pos} remain"
115
+ )
116
+ chunk = self.data[self.pos : self.pos + n]
117
+ self.pos += n
118
+ return chunk
119
+
120
+ def byte(self) -> int:
121
+ return self.take(1)[0]
122
+
123
+ def integer(self) -> int:
124
+ sign = self.byte()
125
+ value = 0
126
+ for shift in range(self.int_size):
127
+ value += self.byte() << (shift * 8)
128
+ return -value if sign else value
129
+
130
+ def string(self) -> str | None:
131
+ length = self.integer()
132
+ if length < 0:
133
+ return None
134
+ # A corrupt length is how a damaged header most often presents. Bound it
135
+ # against what is actually left rather than trusting the file.
136
+ if length > len(self.data) - self.pos:
137
+ raise ArchiveError(
138
+ f"archive declares a {length}-byte string at offset {self.pos} "
139
+ f"but only {len(self.data) - self.pos} bytes remain"
140
+ )
141
+ return self.take(length).decode("utf-8", errors="replace")
142
+
143
+
144
+ def parse_header(data: bytes) -> ArchiveHeader:
145
+ """Parse a custom-format header from the leading bytes of an archive."""
146
+ if len(data) < len(MAGIC):
147
+ raise ArchiveError("file is too short to be a pg_dump archive")
148
+ if not data.startswith(MAGIC):
149
+ raise ArchiveError(
150
+ "not a pg_dump custom-format archive: missing the PGDMP magic. "
151
+ "A plain-SQL dump or a gzipped file will look like this."
152
+ )
153
+
154
+ reader = _Reader(data)
155
+ reader.take(len(MAGIC))
156
+ vmaj, vmin, vrev = reader.byte(), reader.byte(), reader.byte()
157
+ int_size = reader.byte()
158
+ offset_size = reader.byte()
159
+ fmt = reader.byte()
160
+
161
+ if int_size < 1 or int_size > 8:
162
+ raise ArchiveError(f"implausible integer size {int_size} in archive header")
163
+ reader.int_size = int_size
164
+
165
+ if (vmaj, vmin) >= _COMPRESSION_IS_BYTE_FROM:
166
+ compression = reader.byte()
167
+ else:
168
+ compression = reader.integer()
169
+
170
+ for _ in range(7): # sec, min, hour, mday, mon, year, isdst
171
+ reader.integer()
172
+
173
+ dbname = reader.string()
174
+ server_version = reader.string()
175
+ pgdump_version = reader.string()
176
+
177
+ return ArchiveHeader(
178
+ archive_version=(vmaj, vmin, vrev),
179
+ int_size=int_size,
180
+ offset_size=offset_size,
181
+ format=fmt,
182
+ compression=compression,
183
+ dbname=dbname,
184
+ server_version=server_version,
185
+ pgdump_version=pgdump_version,
186
+ )
187
+
188
+
189
+ # The header is small and bounded; reading a fixed prefix keeps a 2 TB dump from
190
+ # being pulled into memory to learn its version number.
191
+ HEADER_BYTES = 512
192
+
193
+
194
+ def read_header(path: str | pathlib.Path) -> ArchiveHeader:
195
+ path = pathlib.Path(path)
196
+ if not path.exists():
197
+ raise ArchiveError(f"no such file: {path}")
198
+ if path.is_dir():
199
+ raise ArchiveError(
200
+ f"{path} is a directory. Directory-format dumps arrive in a later phase; "
201
+ "Phase 0 handles custom format (-Fc) only."
202
+ )
203
+ if path.stat().st_size == 0:
204
+ raise ArchiveError(f"{path} is empty (0 bytes)")
205
+ with path.open("rb") as handle:
206
+ return parse_header(handle.read(HEADER_BYTES))
firedrill/cli.py ADDED
@@ -0,0 +1,170 @@
1
+ """firedrill command line.
2
+
3
+ No --dsn and no --password, by design (PLAN.md §7): /proc/*/cmdline is
4
+ world-readable and CI logs echo command lines. Phase 0 needs neither, because
5
+ the only target it can build is one it created itself.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import dataclasses
12
+ import pathlib
13
+ import sys
14
+
15
+ from . import __version__, config, docker, drill, report as reporting
16
+ from .config import parse_duration as _duration
17
+ from .finding import SEVERITIES
18
+
19
+
20
+ def build_parser() -> argparse.ArgumentParser:
21
+ parser = argparse.ArgumentParser(
22
+ prog="firedrill",
23
+ description="Restore a Postgres backup into a disposable container and "
24
+ "report whether it actually worked.",
25
+ )
26
+ parser.add_argument("--version", action="version",
27
+ version=f"firedrill {__version__}")
28
+ sub = parser.add_subparsers(dest="command", required=True)
29
+
30
+ run = sub.add_parser("run", help="restore a dump and report")
31
+ run.add_argument("dump", help="path to a pg_dump custom-format (-Fc) file")
32
+ run.add_argument("--json", metavar="PATH",
33
+ help="write the machine-readable report here ('-' for stdout)")
34
+ run.add_argument("--rto", metavar="DURATION",
35
+ help="recovery-time budget, e.g. 45m. Exceeding it is a "
36
+ "finding, not a crash.")
37
+ run.add_argument("--fail-on", choices=SEVERITIES, default="high",
38
+ help="lowest severity that fails the run (default: high)")
39
+ run.add_argument("--postgres", metavar="MAJOR",
40
+ help="override the major version read from the archive")
41
+ run.add_argument("--image-flavour", default="",
42
+ help="suffix for the postgres image, e.g. '-alpine'. Not the "
43
+ "default: musl libc breaks collation comparisons.")
44
+ run.add_argument("--ready-timeout", type=int,
45
+ default=docker.DEFAULT_READY_TIMEOUT,
46
+ help="seconds to wait for the container (default: %(default)s)")
47
+ run.add_argument("--config", metavar="PATH",
48
+ help="firedrill.yml to read (default: one beside you, if "
49
+ "there is one)")
50
+ run.add_argument("--no-config", action="store_true",
51
+ help="ignore any firedrill.yml that is lying around")
52
+ run.add_argument("--write-reference", metavar="PATH",
53
+ help="write the restored catalog here as a structure "
54
+ "reference, instead of comparing against one. Commit "
55
+ "the result and review it like any other file.")
56
+ run.add_argument("--tier", choices=config.ALL_TIERS,
57
+ help="how much to restore. `fast` is schema-only: the "
58
+ "row-reading checks then report NOT RUN, never a pass.")
59
+ run.add_argument("--junit", metavar="PATH",
60
+ help="write a JUnit XML report here, for CI to display")
61
+ run.add_argument("--history", metavar="PATH",
62
+ help="append this run to a history file, and measure it "
63
+ "against the last known-good run recorded there")
64
+ run.add_argument("--quiet", action="store_true", help="suppress the table")
65
+
66
+ drill_pitr = sub.add_parser(
67
+ "pitr",
68
+ help="recover a base backup to a timestamp and assert the boundary")
69
+ drill_pitr.add_argument("--base", required=True, metavar="DIR",
70
+ help="the directory pg_basebackup produced")
71
+ drill_pitr.add_argument("--wal", required=True, metavar="DIR",
72
+ help="the archived WAL segments")
73
+ drill_pitr.add_argument("--target", required=True, metavar="TIMESTAMP",
74
+ help="recovery target, in UTC, e.g. "
75
+ "'2026-08-25 12:30:16'")
76
+ drill_pitr.add_argument("--config", metavar="PATH",
77
+ help="firedrill.yml holding the boundary checks")
78
+ drill_pitr.add_argument("--no-config", action="store_true")
79
+ drill_pitr.add_argument("--fail-on", choices=SEVERITIES, default="high")
80
+ drill_pitr.add_argument("--image-flavour", default="")
81
+ drill_pitr.add_argument("--ready-timeout", type=int,
82
+ default=docker.DEFAULT_READY_TIMEOUT)
83
+ drill_pitr.add_argument("--json", metavar="PATH")
84
+ drill_pitr.add_argument("--junit", metavar="PATH")
85
+ drill_pitr.add_argument("--quiet", action="store_true")
86
+
87
+ sub.add_parser("clean", help="remove containers left behind by a crash")
88
+ return parser
89
+
90
+
91
+ def _load_config(args):
92
+ """The config, or a ConfigError. Shared by `run` and `pitr`."""
93
+ if getattr(args, "no_config", False):
94
+ return config.DEFAULT
95
+ if getattr(args, "config", None):
96
+ return config.load(args.config)
97
+ found = config.find()
98
+ if found and not args.quiet:
99
+ print(f"using {found}")
100
+ return config.load(found) if found else config.DEFAULT
101
+
102
+
103
+ def main(argv: list[str] | None = None) -> int:
104
+ args = build_parser().parse_args(argv)
105
+
106
+ if args.command == "clean":
107
+ usable, why = docker.docker_available()
108
+ if not usable:
109
+ print(f"docker is not usable: {why}", file=sys.stderr)
110
+ return 1
111
+ removed = docker.clean()
112
+ print(f"removed {len(removed)} container(s)"
113
+ + (": " + ", ".join(removed) if removed else ""))
114
+ return 0
115
+
116
+ # An explicitly named config that cannot be read is a hard error. Falling
117
+ # back to defaults would run a weaker set of checks than the user asked
118
+ # for and still print a pass.
119
+ try:
120
+ cfg = _load_config(args)
121
+ if args.command == "pitr":
122
+ result = drill.run_pitr(
123
+ args.base, args.wal, args.target, cfg=cfg,
124
+ flavour=args.image_flavour, fail_on=args.fail_on,
125
+ ready_timeout=args.ready_timeout)
126
+ return _emit(result, args)
127
+ if args.history:
128
+ cfg = dataclasses.replace(cfg, history_path=pathlib.Path(args.history))
129
+ if args.tier:
130
+ cfg = dataclasses.replace(cfg, tier=args.tier)
131
+ if args.tier not in config.IMPLEMENTED_TIERS:
132
+ raise config.ConfigError(
133
+ f"tier {args.tier!r} is not implemented yet; "
134
+ f"available: {', '.join(config.IMPLEMENTED_TIERS)}")
135
+ except config.ConfigError as exc:
136
+ print(f"config error: {exc}", file=sys.stderr)
137
+ return 2
138
+
139
+ result = drill.run(
140
+ args.dump,
141
+ cfg=cfg,
142
+ write_reference=args.write_reference,
143
+ flavour=args.image_flavour,
144
+ rto_budget=_duration(args.rto) if args.rto else None,
145
+ fail_on=args.fail_on,
146
+ pin_major=args.postgres,
147
+ ready_timeout=args.ready_timeout,
148
+ )
149
+
150
+ return _emit(result, args)
151
+
152
+
153
+ def _emit(result, args) -> int:
154
+ if not args.quiet:
155
+ print(reporting.human(result))
156
+ if args.junit:
157
+ with open(args.junit, "w", encoding="utf-8") as handle:
158
+ handle.write(reporting.as_junit(result))
159
+ if args.json:
160
+ blob = reporting.as_json(result)
161
+ if args.json == "-":
162
+ print(blob)
163
+ else:
164
+ with open(args.json, "w", encoding="utf-8") as handle:
165
+ handle.write(blob + "\n")
166
+ return result.exit_code
167
+
168
+
169
+ if __name__ == "__main__":
170
+ raise SystemExit(main())