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 +3 -0
- firedrill/archive.py +206 -0
- firedrill/cli.py +170 -0
- firedrill/config.py +530 -0
- firedrill/docker.py +246 -0
- firedrill/drill.py +629 -0
- firedrill/finding.py +99 -0
- firedrill/history.py +106 -0
- firedrill/ladder.py +539 -0
- firedrill/pitr.py +224 -0
- firedrill/report.py +197 -0
- firedrill/restore.py +299 -0
- firedrill/sources.py +229 -0
- firedrill-0.1.0.dist-info/METADATA +422 -0
- firedrill-0.1.0.dist-info/RECORD +19 -0
- firedrill-0.1.0.dist-info/WHEEL +5 -0
- firedrill-0.1.0.dist-info/entry_points.txt +2 -0
- firedrill-0.1.0.dist-info/licenses/LICENSE +21 -0
- firedrill-0.1.0.dist-info/top_level.txt +1 -0
firedrill/__init__.py
ADDED
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())
|