3tears-backup 0.16.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.
- 3tears_backup-0.16.0.dist-info/METADATA +41 -0
- 3tears_backup-0.16.0.dist-info/RECORD +13 -0
- 3tears_backup-0.16.0.dist-info/WHEEL +4 -0
- 3tears_backup-0.16.0.dist-info/licenses/LICENSE +21 -0
- threetears/backup/__init__.py +44 -0
- threetears/backup/config.py +93 -0
- threetears/backup/drivers.py +109 -0
- threetears/backup/engine.py +156 -0
- threetears/backup/gzip.py +46 -0
- threetears/backup/process.py +174 -0
- threetears/backup/py.typed +0 -0
- threetears/backup/retention.py +135 -0
- threetears/backup/verify.py +198 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: 3tears-backup
|
|
3
|
+
Version: 0.16.0
|
|
4
|
+
Summary: Encrypted, GFS-rotated database backups to any ObjectStore, with restore verification
|
|
5
|
+
Project-URL: Repository, https://github.com/pacepace/3tears
|
|
6
|
+
Author: pace
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Framework :: AsyncIO
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
14
|
+
Classifier: Topic :: Database
|
|
15
|
+
Classifier: Topic :: System :: Archiving :: Backup
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Python: >=3.14
|
|
18
|
+
Requires-Dist: 3tears-media-contracts
|
|
19
|
+
Requires-Dist: 3tears-object-store
|
|
20
|
+
Requires-Dist: 3tears-observe
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# 3tears-backup
|
|
24
|
+
|
|
25
|
+
Encrypted, grandfather-father-son (GFS) rotated **database backups** to any
|
|
26
|
+
`ObjectStore`, with **restore verification** — built on 3tears primitives so it
|
|
27
|
+
drops into any 3tears app.
|
|
28
|
+
|
|
29
|
+
- **Storage-agnostic.** The engine takes an injected `ObjectStore`
|
|
30
|
+
(`3tears-object-store` — S3 is the first driver, filesystem the second) and
|
|
31
|
+
streams the dump through `EncryptedObjectStore`, so a multi-GB dump is
|
|
32
|
+
client-side AES-256-GCM encrypted and never sits whole in memory.
|
|
33
|
+
- **Postgres *and* Yugabyte.** A pluggable `DbDumpDriver` wraps the right dump
|
|
34
|
+
tool; the engine autodetects the target from `version()`.
|
|
35
|
+
- **GFS retention.** Keep N daily / weekly / monthly backups; prune the rest.
|
|
36
|
+
- **Restore-verified.** Backups are proven by restoring into a throwaway
|
|
37
|
+
temporary database, with an optional hook to spin a stack against it.
|
|
38
|
+
|
|
39
|
+
Configuration is an injected, frozen `BackupConfig` — construct it however you
|
|
40
|
+
like (a `from_env` factory reads `THREETEARS_BACKUP_*` with defaults; most apps
|
|
41
|
+
will build it from control-plane settings instead).
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
threetears/backup/__init__.py,sha256=Q0wHoXRe93VkKcAXONLOD_3oMbIAdI29v9Nhb077ra8,1089
|
|
2
|
+
threetears/backup/config.py,sha256=NvqI8MzZEPwkekRLSvAOl2e6_pB2uAjdupp1K5mLykk,4358
|
|
3
|
+
threetears/backup/drivers.py,sha256=7-xkpxiMrtgJzvhF89xUvBUhI7J6cIXb9fXTDUJpXDU,4505
|
|
4
|
+
threetears/backup/engine.py,sha256=FXW7MOEAbQ0CjfODiKuzV_hsOvpiDJ_MTqc5Cwn25wM,6864
|
|
5
|
+
threetears/backup/gzip.py,sha256=eQSug2NINbmxquvaGnpeb52c_qJIpLUf8LmktRxxcWs,1637
|
|
6
|
+
threetears/backup/process.py,sha256=a0XcIWRXfytQnoppIeXYLDyHFM4MkxygTKHV7YoAxu8,7437
|
|
7
|
+
threetears/backup/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
threetears/backup/retention.py,sha256=2pR-QJgIhO7mRNpS_pAIg3jQh9HefX9N0BMwezPiRiM,5366
|
|
9
|
+
threetears/backup/verify.py,sha256=6IzFGmnwsUIHQQhcfxIuMaKKD3uExDHdLFJIThE7DRE,7705
|
|
10
|
+
3tears_backup-0.16.0.dist-info/METADATA,sha256=GuBmWkzT-roxoJ5ogY_fT8NHjYLVshLl_bqmiBRG8vc,1836
|
|
11
|
+
3tears_backup-0.16.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
12
|
+
3tears_backup-0.16.0.dist-info/licenses/LICENSE,sha256=7GWEoEOcFJenZLt4LDzqH2K7QLxo_2m8rzG7Vv8VGXo,1066
|
|
13
|
+
3tears_backup-0.16.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mark Pace
|
|
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,44 @@
|
|
|
1
|
+
"""Encrypted, GFS-rotated database backups to any ObjectStore, with restore verification."""
|
|
2
|
+
|
|
3
|
+
from threetears.backup.config import BackupConfig
|
|
4
|
+
from threetears.backup.drivers import (
|
|
5
|
+
DbDumpDriver,
|
|
6
|
+
PostgresDriver,
|
|
7
|
+
YugabyteDriver,
|
|
8
|
+
detect_driver,
|
|
9
|
+
driver_for_version,
|
|
10
|
+
)
|
|
11
|
+
from threetears.backup.engine import BackupEngine, DeleteNotAllowedError
|
|
12
|
+
from threetears.backup.process import BackupToolError
|
|
13
|
+
from threetears.backup.retention import (
|
|
14
|
+
BackupRecord,
|
|
15
|
+
GfsRetention,
|
|
16
|
+
RetentionDecision,
|
|
17
|
+
)
|
|
18
|
+
from threetears.backup.verify import (
|
|
19
|
+
RestoreVerifier,
|
|
20
|
+
VerificationResult,
|
|
21
|
+
count_tables,
|
|
22
|
+
make_subprocess_hook,
|
|
23
|
+
make_temp_db_provisioner,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"BackupConfig",
|
|
28
|
+
"BackupEngine",
|
|
29
|
+
"BackupRecord",
|
|
30
|
+
"BackupToolError",
|
|
31
|
+
"DbDumpDriver",
|
|
32
|
+
"DeleteNotAllowedError",
|
|
33
|
+
"GfsRetention",
|
|
34
|
+
"PostgresDriver",
|
|
35
|
+
"RestoreVerifier",
|
|
36
|
+
"RetentionDecision",
|
|
37
|
+
"VerificationResult",
|
|
38
|
+
"YugabyteDriver",
|
|
39
|
+
"count_tables",
|
|
40
|
+
"detect_driver",
|
|
41
|
+
"driver_for_version",
|
|
42
|
+
"make_subprocess_hook",
|
|
43
|
+
"make_temp_db_provisioner",
|
|
44
|
+
]
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Injected configuration for the backup engine.
|
|
2
|
+
|
|
3
|
+
:class:`BackupConfig` is a frozen value object you *pass in* -- the engine never reaches for the
|
|
4
|
+
environment itself. Most apps build it from control-plane settings; :meth:`BackupConfig.from_env`
|
|
5
|
+
is a convenience that reads ``THREETEARS_BACKUP_*`` with sensible defaults for the simple case.
|
|
6
|
+
|
|
7
|
+
It is deliberately storage-agnostic: there is no bucket here. The backend is an injected
|
|
8
|
+
``ObjectStore`` (which already knows where it writes), so the same config drives an S3 backup or a
|
|
9
|
+
filesystem one. What lives here is the encryption passphrase, the key prefix, the GFS retention
|
|
10
|
+
counts, and the delete safety switch.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
from collections.abc import Mapping
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
|
|
19
|
+
from pydantic import SecretStr
|
|
20
|
+
|
|
21
|
+
__all__ = ["BackupConfig"]
|
|
22
|
+
|
|
23
|
+
_ENV_PREFIX = "THREETEARS_BACKUP_"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True, slots=True)
|
|
27
|
+
class BackupConfig:
|
|
28
|
+
"""Backup engine configuration (injected; never self-loaded).
|
|
29
|
+
|
|
30
|
+
:param passphrase: AES-256-GCM encryption passphrase (per-object scrypt-derived key).
|
|
31
|
+
:param prefix: object-key prefix under which backups are written/listed.
|
|
32
|
+
:param retention_daily: number of daily backups to keep (>= 1).
|
|
33
|
+
:param retention_weekly: number of weekly backups to keep (>= 1).
|
|
34
|
+
:param retention_monthly: number of monthly backups to keep (>= 1).
|
|
35
|
+
:param allow_delete: master switch for destructive operations (delete / retention prune).
|
|
36
|
+
:param dump_timeout_seconds: wall-clock ceiling for a dump/restore subprocess (> 0).
|
|
37
|
+
:param encryption_work_factor: scrypt cost N for the per-object key (power of two > 1); the
|
|
38
|
+
default is deployment-grade, lower it only to trade brute-force resistance for speed.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
passphrase: SecretStr
|
|
42
|
+
prefix: str = "backups"
|
|
43
|
+
retention_daily: int = 7
|
|
44
|
+
retention_weekly: int = 4
|
|
45
|
+
retention_monthly: int = 3
|
|
46
|
+
allow_delete: bool = False
|
|
47
|
+
dump_timeout_seconds: int = 3600
|
|
48
|
+
encryption_work_factor: int = 2**18
|
|
49
|
+
|
|
50
|
+
def __post_init__(self) -> None:
|
|
51
|
+
if not self.prefix or self.prefix != self.prefix.strip("/"):
|
|
52
|
+
raise ValueError("prefix must be non-empty with no leading/trailing '/'")
|
|
53
|
+
for name in ("retention_daily", "retention_weekly", "retention_monthly"):
|
|
54
|
+
if getattr(self, name) < 1:
|
|
55
|
+
raise ValueError(f"{name} must be >= 1")
|
|
56
|
+
if self.dump_timeout_seconds <= 0:
|
|
57
|
+
raise ValueError("dump_timeout_seconds must be > 0")
|
|
58
|
+
if self.encryption_work_factor <= 1 or (self.encryption_work_factor & (self.encryption_work_factor - 1)) != 0:
|
|
59
|
+
raise ValueError("encryption_work_factor must be a power of two greater than 1")
|
|
60
|
+
if not self.passphrase.get_secret_value():
|
|
61
|
+
raise ValueError("passphrase must not be empty")
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def from_env(cls, env: Mapping[str, str] | None = None) -> BackupConfig:
|
|
65
|
+
"""Build a config from ``THREETEARS_BACKUP_*`` variables (defaults fill the rest).
|
|
66
|
+
|
|
67
|
+
:param env: environment mapping to read (defaults to ``os.environ``).
|
|
68
|
+
:raises ValueError: when ``THREETEARS_BACKUP_PASSPHRASE`` is unset, or a value is invalid.
|
|
69
|
+
"""
|
|
70
|
+
source = os.environ if env is None else env
|
|
71
|
+
passphrase = source.get(f"{_ENV_PREFIX}PASSPHRASE")
|
|
72
|
+
if not passphrase:
|
|
73
|
+
raise ValueError(f"{_ENV_PREFIX}PASSPHRASE is required")
|
|
74
|
+
return cls(
|
|
75
|
+
passphrase=SecretStr(passphrase),
|
|
76
|
+
prefix=source.get(f"{_ENV_PREFIX}PREFIX", "backups"),
|
|
77
|
+
retention_daily=_int(source, "RETENTION_DAILY", 7),
|
|
78
|
+
retention_weekly=_int(source, "RETENTION_WEEKLY", 4),
|
|
79
|
+
retention_monthly=_int(source, "RETENTION_MONTHLY", 3),
|
|
80
|
+
allow_delete=_bool(source, "ALLOW_DELETE", default=False),
|
|
81
|
+
dump_timeout_seconds=_int(source, "DUMP_TIMEOUT_SECONDS", 3600),
|
|
82
|
+
encryption_work_factor=_int(source, "ENCRYPTION_WORK_FACTOR", 2**18),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _int(source: Mapping[str, str], suffix: str, default: int) -> int:
|
|
87
|
+
raw = source.get(f"{_ENV_PREFIX}{suffix}")
|
|
88
|
+
return default if raw is None else int(raw)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _bool(source: Mapping[str, str], suffix: str, *, default: bool) -> bool:
|
|
92
|
+
raw = source.get(f"{_ENV_PREFIX}{suffix}")
|
|
93
|
+
return default if raw is None else raw.strip().lower() in {"1", "true", "yes", "on"}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Pluggable database dump/restore drivers, with Postgres/Yugabyte autodetection.
|
|
2
|
+
|
|
3
|
+
A driver knows one thing the engine doesn't: which command-line tool dumps and restores its
|
|
4
|
+
database, and with which flags. :class:`PostgresDriver` uses ``pg_dump``/``pg_restore`` (custom
|
|
5
|
+
archive format); :class:`YugabyteDriver` uses ``ysql_dump``/``ysqlsh`` (plain SQL) — Yugabyte
|
|
6
|
+
ships its own fork of the tools. The engine picks one by asking the database ``SELECT version()``:
|
|
7
|
+
Yugabyte stamps ``-YB-`` into its version string (the same tell scriob's ``is_yugabyte`` uses).
|
|
8
|
+
|
|
9
|
+
The argv builders and :func:`driver_for_version` are pure (unit-tested without a database); the
|
|
10
|
+
actual dump/restore streams through the shared subprocess plumbing in :mod:`threetears.backup.process`.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from abc import ABC, abstractmethod
|
|
16
|
+
from collections.abc import AsyncIterator, Mapping
|
|
17
|
+
from typing import ClassVar, Protocol, runtime_checkable
|
|
18
|
+
|
|
19
|
+
from threetears.backup.process import feed_stdin, stream_stdout
|
|
20
|
+
|
|
21
|
+
__all__ = ["DbDumpDriver", "PostgresDriver", "YugabyteDriver", "detect_driver", "driver_for_version"]
|
|
22
|
+
|
|
23
|
+
#: the marker Yugabyte stamps into ``version()`` (e.g. "... (YugabyteDB 2.20 ... -YB-...)").
|
|
24
|
+
_YUGABYTE_MARKER = "-YB-"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class DbDumpDriver(ABC):
|
|
28
|
+
"""Abstract dump/restore driver: declares the argv, inherits the streaming."""
|
|
29
|
+
|
|
30
|
+
name: ClassVar[str]
|
|
31
|
+
#: True when the dump format is already compressed (so the engine skips gzip).
|
|
32
|
+
compressed: ClassVar[bool]
|
|
33
|
+
|
|
34
|
+
@abstractmethod
|
|
35
|
+
def dump_argv(self, dsn: str) -> list[str]:
|
|
36
|
+
"""Argv that dumps ``dsn`` to stdout."""
|
|
37
|
+
|
|
38
|
+
@abstractmethod
|
|
39
|
+
def restore_argv(self, dsn: str) -> list[str]:
|
|
40
|
+
"""Argv that restores into ``dsn`` from stdin."""
|
|
41
|
+
|
|
42
|
+
def dump(
|
|
43
|
+
self, dsn: str, *, env: Mapping[str, str] | None = None, timeout: float | None = None
|
|
44
|
+
) -> AsyncIterator[bytes]:
|
|
45
|
+
"""Stream a dump of ``dsn`` as bytes (bounded by ``timeout`` seconds when given)."""
|
|
46
|
+
return stream_stdout(self.dump_argv(dsn), env=env, timeout=timeout)
|
|
47
|
+
|
|
48
|
+
async def restore(
|
|
49
|
+
self,
|
|
50
|
+
dsn: str,
|
|
51
|
+
source: AsyncIterator[bytes],
|
|
52
|
+
*,
|
|
53
|
+
env: Mapping[str, str] | None = None,
|
|
54
|
+
timeout: float | None = None,
|
|
55
|
+
) -> None:
|
|
56
|
+
"""Restore ``source`` (a dump stream) into ``dsn`` (bounded by ``timeout`` seconds)."""
|
|
57
|
+
await feed_stdin(self.restore_argv(dsn), source, env=env, timeout=timeout)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class PostgresDriver(DbDumpDriver):
|
|
61
|
+
"""Vanilla PostgreSQL via ``pg_dump`` (custom format) + ``pg_restore``."""
|
|
62
|
+
|
|
63
|
+
name: ClassVar[str] = "postgres"
|
|
64
|
+
compressed: ClassVar[bool] = True # pg_dump custom format is zlib-compressed already
|
|
65
|
+
|
|
66
|
+
def dump_argv(self, dsn: str) -> list[str]:
|
|
67
|
+
return ["pg_dump", "--dbname", dsn, "--format=custom", "--no-owner", "--no-privileges"]
|
|
68
|
+
|
|
69
|
+
def restore_argv(self, dsn: str) -> list[str]:
|
|
70
|
+
# a fresh (empty) target — the verifier's temp db — so no --clean is needed; fail loudly.
|
|
71
|
+
return ["pg_restore", "--dbname", dsn, "--no-owner", "--no-privileges", "--exit-on-error"]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class YugabyteDriver(DbDumpDriver):
|
|
75
|
+
"""YugabyteDB via ``ysql_dump`` (plain SQL) + ``ysqlsh``."""
|
|
76
|
+
|
|
77
|
+
name: ClassVar[str] = "yugabyte"
|
|
78
|
+
compressed: ClassVar[bool] = False # ysql_dump emits plain SQL — gzip it
|
|
79
|
+
|
|
80
|
+
def dump_argv(self, dsn: str) -> list[str]:
|
|
81
|
+
return ["ysql_dump", "--dbname", dsn, "--no-owner", "--no-privileges"]
|
|
82
|
+
|
|
83
|
+
def restore_argv(self, dsn: str) -> list[str]:
|
|
84
|
+
# ysqlsh reads SQL from stdin; ON_ERROR_STOP makes a bad statement a non-zero exit.
|
|
85
|
+
return ["ysqlsh", "--dbname", dsn, "--quiet", "--set", "ON_ERROR_STOP=1"]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def driver_for_version(version: str) -> DbDumpDriver:
|
|
89
|
+
"""Pick a driver from a ``version()`` string.
|
|
90
|
+
|
|
91
|
+
:param version: the output of ``SELECT version()``.
|
|
92
|
+
:return: a :class:`YugabyteDriver` if the string carries the Yugabyte marker, else Postgres.
|
|
93
|
+
"""
|
|
94
|
+
return YugabyteDriver() if _YUGABYTE_MARKER in version else PostgresDriver()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@runtime_checkable
|
|
98
|
+
class _VersionSource(Protocol):
|
|
99
|
+
async def fetchval(self, query: str) -> object: ...
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
async def detect_driver(conn: _VersionSource) -> DbDumpDriver:
|
|
103
|
+
"""Autodetect the driver by querying ``version()`` on an open connection.
|
|
104
|
+
|
|
105
|
+
:param conn: anything with an async ``fetchval(query)`` (e.g. an asyncpg connection).
|
|
106
|
+
:return: the driver matching the connected database engine.
|
|
107
|
+
"""
|
|
108
|
+
version = await conn.fetchval("SELECT version()")
|
|
109
|
+
return driver_for_version(str(version))
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""The backup engine — encrypted, compressed, GFS-rotated DB backups over any ObjectStore.
|
|
2
|
+
|
|
3
|
+
:class:`BackupEngine` composes the pieces: it wraps the injected backend store in
|
|
4
|
+
:class:`EncryptedObjectStore` (so backups are encrypted *by construction* — a caller can't
|
|
5
|
+
accidentally write plaintext), streams the driver's dump through gzip when the format isn't already
|
|
6
|
+
compressed, and writes it under a date-partitioned key. Listing, retention pruning, and restore all
|
|
7
|
+
funnel back through the same encrypted store. Destructive operations (delete, retention prune) are
|
|
8
|
+
gated on ``config.allow_delete``.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from collections.abc import Mapping
|
|
14
|
+
from datetime import UTC, datetime
|
|
15
|
+
from uuid import uuid7
|
|
16
|
+
|
|
17
|
+
from threetears.media.contracts import ObjectStore
|
|
18
|
+
from threetears.object_store import EncryptedObjectStore
|
|
19
|
+
from threetears.observe import get_logger
|
|
20
|
+
|
|
21
|
+
from threetears.backup.config import BackupConfig
|
|
22
|
+
from threetears.backup.drivers import DbDumpDriver
|
|
23
|
+
from threetears.backup.gzip import gunzip_stream, gzip_stream
|
|
24
|
+
from threetears.backup.retention import BackupRecord, GfsRetention, RetentionDecision
|
|
25
|
+
|
|
26
|
+
__all__ = ["BackupEngine", "DeleteNotAllowedError"]
|
|
27
|
+
|
|
28
|
+
log = get_logger(__name__)
|
|
29
|
+
|
|
30
|
+
_ENCRYPTED_CONTENT_TYPE = "application/octet-stream"
|
|
31
|
+
_KEY_STAMP_FORMAT = "%Y%m%dT%H%M%SZ"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _created_at_from_key(key: str, *, fallback: datetime) -> datetime:
|
|
35
|
+
"""Parse the creation timestamp encoded in a backup key's filename.
|
|
36
|
+
|
|
37
|
+
Keys are ``<prefix>/<Y>/<m>/<d>/<YYYYMMDDThhmmssZ>-<id>.<driver>.<suffix>.enc``; the stamp is
|
|
38
|
+
the filename up to the first ``-``. A key we didn't write (no parseable stamp) falls back to the
|
|
39
|
+
store's last-modified so a foreign object still sorts sanely.
|
|
40
|
+
"""
|
|
41
|
+
stamp = key.rsplit("/", 1)[-1].split("-", 1)[0]
|
|
42
|
+
try:
|
|
43
|
+
parsed = datetime.strptime(stamp, _KEY_STAMP_FORMAT).replace(tzinfo=UTC)
|
|
44
|
+
except ValueError:
|
|
45
|
+
parsed = fallback
|
|
46
|
+
return parsed
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class DeleteNotAllowedError(RuntimeError):
|
|
50
|
+
"""A destructive operation was attempted while ``config.allow_delete`` is False."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class BackupEngine:
|
|
54
|
+
"""Create / list / restore / prune encrypted database backups.
|
|
55
|
+
|
|
56
|
+
:param config: the injected :class:`BackupConfig`.
|
|
57
|
+
:param store: the backend :class:`ObjectStore` (S3, filesystem, …); wrapped in encryption here.
|
|
58
|
+
:param driver: the :class:`DbDumpDriver` for the target database.
|
|
59
|
+
:param env: environment for the dump/restore subprocess (e.g. ``PGPASSWORD``).
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def __init__(
|
|
63
|
+
self,
|
|
64
|
+
config: BackupConfig,
|
|
65
|
+
store: ObjectStore,
|
|
66
|
+
driver: DbDumpDriver,
|
|
67
|
+
*,
|
|
68
|
+
env: Mapping[str, str] | None = None,
|
|
69
|
+
) -> None:
|
|
70
|
+
self._config = config
|
|
71
|
+
self._driver = driver
|
|
72
|
+
self._env = env
|
|
73
|
+
# encrypt by construction: everything written/read goes through the AEAD wrapper.
|
|
74
|
+
self._store: ObjectStore = EncryptedObjectStore(
|
|
75
|
+
store, config.passphrase, scrypt_n=config.encryption_work_factor
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
def _key_for(self, when: datetime) -> str:
|
|
79
|
+
stamp = when.strftime("%Y%m%dT%H%M%SZ")
|
|
80
|
+
suffix = "dump" if self._driver.compressed else "dump.gz"
|
|
81
|
+
return f"{self._config.prefix}/{when:%Y/%m/%d}/{stamp}-{uuid7().hex[:8]}.{self._driver.name}.{suffix}.enc"
|
|
82
|
+
|
|
83
|
+
async def create_backup(self, source_dsn: str, *, when: datetime | None = None) -> BackupRecord:
|
|
84
|
+
"""Dump ``source_dsn``, compress+encrypt it, and store it.
|
|
85
|
+
|
|
86
|
+
:param source_dsn: connection string of the database to back up.
|
|
87
|
+
:param when: backup timestamp (defaults to now, UTC) — also the storage partition.
|
|
88
|
+
:return: the record for the written backup.
|
|
89
|
+
"""
|
|
90
|
+
moment = when or datetime.now(UTC)
|
|
91
|
+
key = self._key_for(moment)
|
|
92
|
+
stream = self._driver.dump(source_dsn, env=self._env, timeout=self._config.dump_timeout_seconds)
|
|
93
|
+
if not self._driver.compressed:
|
|
94
|
+
stream = gzip_stream(stream)
|
|
95
|
+
await self._store.put(key, stream, content_type=_ENCRYPTED_CONTENT_TYPE)
|
|
96
|
+
size = await self._size_of(key)
|
|
97
|
+
log.info("backup created", extra={"extra_data": {"key": key, "size_bytes": size, "driver": self._driver.name}})
|
|
98
|
+
return BackupRecord(key=key, created_at=moment, size_bytes=size)
|
|
99
|
+
|
|
100
|
+
async def restore_into(self, target_dsn: str, key: str) -> None:
|
|
101
|
+
"""Restore the backup at ``key`` into ``target_dsn`` (decrypt → gunzip → driver restore)."""
|
|
102
|
+
stream = self._store.open_read(key)
|
|
103
|
+
if not self._driver.compressed:
|
|
104
|
+
stream = gunzip_stream(stream)
|
|
105
|
+
await self._driver.restore(target_dsn, stream, env=self._env, timeout=self._config.dump_timeout_seconds)
|
|
106
|
+
log.info("backup restored", extra={"extra_data": {"key": key, "driver": self._driver.name}})
|
|
107
|
+
|
|
108
|
+
async def list_backups(self) -> list[BackupRecord]:
|
|
109
|
+
"""List stored backups (newest first), timed by the timestamp encoded in each key."""
|
|
110
|
+
records = [
|
|
111
|
+
BackupRecord(
|
|
112
|
+
key=entry.key,
|
|
113
|
+
created_at=_created_at_from_key(entry.key, fallback=entry.last_modified),
|
|
114
|
+
size_bytes=entry.size_bytes,
|
|
115
|
+
)
|
|
116
|
+
async for entry in self._store.list_entries(f"{self._config.prefix}/")
|
|
117
|
+
]
|
|
118
|
+
records.sort(key=lambda r: r.created_at, reverse=True)
|
|
119
|
+
return records
|
|
120
|
+
|
|
121
|
+
async def plan_retention(self) -> RetentionDecision:
|
|
122
|
+
"""Compute (without deleting) which backups GFS retention would keep vs prune."""
|
|
123
|
+
records = await self.list_backups()
|
|
124
|
+
return GfsRetention.from_config(self._config).select(records)
|
|
125
|
+
|
|
126
|
+
async def apply_retention(self) -> RetentionDecision:
|
|
127
|
+
"""Prune backups outside the GFS policy. Requires ``allow_delete``.
|
|
128
|
+
|
|
129
|
+
:raises DeleteNotAllowedError: when ``config.allow_delete`` is False.
|
|
130
|
+
"""
|
|
131
|
+
if not self._config.allow_delete:
|
|
132
|
+
raise DeleteNotAllowedError("retention prune requires config.allow_delete=True")
|
|
133
|
+
decision = await self.plan_retention()
|
|
134
|
+
if decision.delete:
|
|
135
|
+
await self._store.delete_many([r.key for r in decision.delete])
|
|
136
|
+
log.info(
|
|
137
|
+
"retention pruned", extra={"extra_data": {"deleted": len(decision.delete), "kept": len(decision.keep)}}
|
|
138
|
+
)
|
|
139
|
+
return decision
|
|
140
|
+
|
|
141
|
+
async def delete_backup(self, key: str) -> None:
|
|
142
|
+
"""Delete one backup. Requires ``allow_delete``.
|
|
143
|
+
|
|
144
|
+
:raises DeleteNotAllowedError: when ``config.allow_delete`` is False.
|
|
145
|
+
"""
|
|
146
|
+
if not self._config.allow_delete:
|
|
147
|
+
raise DeleteNotAllowedError("delete requires config.allow_delete=True")
|
|
148
|
+
await self._store.delete(key)
|
|
149
|
+
|
|
150
|
+
async def _size_of(self, key: str) -> int:
|
|
151
|
+
size = 0
|
|
152
|
+
async for entry in self._store.list_entries(key):
|
|
153
|
+
if entry.key == key:
|
|
154
|
+
size = entry.size_bytes
|
|
155
|
+
break
|
|
156
|
+
return size
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Streaming gzip (de)compression for dump bytes.
|
|
2
|
+
|
|
3
|
+
A driver whose dump format isn't already compressed (Yugabyte's plain SQL) is gzipped on the way
|
|
4
|
+
to the store and gunzipped on the way back. Both directions are incremental (``zlib`` objects), so
|
|
5
|
+
a multi-GB dump streams through without buffering.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import zlib
|
|
11
|
+
from collections.abc import AsyncIterator
|
|
12
|
+
|
|
13
|
+
__all__ = ["gunzip_stream", "gzip_stream"]
|
|
14
|
+
|
|
15
|
+
_GZIP_WBITS = 16 + zlib.MAX_WBITS # emit a gzip header/trailer
|
|
16
|
+
_GUNZIP_WBITS = 32 + zlib.MAX_WBITS # auto-detect gzip or zlib on the way back
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
async def gzip_stream(source: AsyncIterator[bytes]) -> AsyncIterator[bytes]:
|
|
20
|
+
"""Yield gzip-compressed chunks for ``source``."""
|
|
21
|
+
compressor = zlib.compressobj(wbits=_GZIP_WBITS)
|
|
22
|
+
async for chunk in source:
|
|
23
|
+
block = compressor.compress(chunk)
|
|
24
|
+
if block:
|
|
25
|
+
yield block
|
|
26
|
+
tail = compressor.flush()
|
|
27
|
+
if tail:
|
|
28
|
+
yield tail
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
async def gunzip_stream(source: AsyncIterator[bytes]) -> AsyncIterator[bytes]:
|
|
32
|
+
"""Yield decompressed chunks for a gzip stream ``source``.
|
|
33
|
+
|
|
34
|
+
:raises ValueError: if the stream ends before the gzip trailer (truncated / incomplete).
|
|
35
|
+
"""
|
|
36
|
+
decompressor = zlib.decompressobj(wbits=_GUNZIP_WBITS)
|
|
37
|
+
async for chunk in source:
|
|
38
|
+
block = decompressor.decompress(chunk)
|
|
39
|
+
if block:
|
|
40
|
+
yield block
|
|
41
|
+
tail = decompressor.flush()
|
|
42
|
+
if tail:
|
|
43
|
+
yield tail
|
|
44
|
+
# flush() does NOT raise on a truncated stream; eof is False until the gzip trailer is consumed.
|
|
45
|
+
if not decompressor.eof:
|
|
46
|
+
raise ValueError("truncated gzip stream: trailer not reached")
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Subprocess plumbing shared by every dump driver.
|
|
2
|
+
|
|
3
|
+
Two primitives: :func:`stream_stdout` runs a command and yields its stdout in chunks (the *dump*
|
|
4
|
+
side — pg_dump/ysql_dump write the archive to stdout), and :func:`feed_stdin` runs a command and
|
|
5
|
+
streams bytes into its stdin (the *restore* side — pg_restore/ysqlsh read the archive from stdin).
|
|
6
|
+
|
|
7
|
+
Both drain stderr concurrently (so a chatty tool can't deadlock on a full stderr pipe), enforce an
|
|
8
|
+
optional wall-clock ``timeout``, and — critically — **never leave a child running**: if the caller
|
|
9
|
+
aborts, the operation times out, or the input stream errors, the child is killed before the helper
|
|
10
|
+
returns (otherwise a child blocked on a full stdout pipe would wedge ``proc.wait()`` forever). A
|
|
11
|
+
non-zero exit raises :class:`BackupToolError` carrying the captured stderr; when a restore child
|
|
12
|
+
dies early and breaks the stdin pipe, the exit-code diagnosis wins over the raw ``BrokenPipeError``.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
import os
|
|
19
|
+
import signal
|
|
20
|
+
from collections.abc import AsyncIterator, Mapping
|
|
21
|
+
|
|
22
|
+
__all__ = ["BackupToolError", "feed_stdin", "stream_stdout"]
|
|
23
|
+
|
|
24
|
+
_READ_CHUNK = 1 << 16 # 64 KiB
|
|
25
|
+
_TIMED_OUT = -1 # synthetic returncode used in the timeout message
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class BackupToolError(RuntimeError):
|
|
29
|
+
"""A dump/restore subprocess exited non-zero (or timed out).
|
|
30
|
+
|
|
31
|
+
:param tool: the command that failed (argv[0]).
|
|
32
|
+
:param returncode: the process exit code (``-1`` for a timeout).
|
|
33
|
+
:param stderr: the captured standard error (decoded, best-effort).
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(self, tool: str, returncode: int, stderr: str) -> None:
|
|
37
|
+
super().__init__(f"{tool} failed (exit {returncode}): {stderr.strip()}")
|
|
38
|
+
self.tool = tool
|
|
39
|
+
self.returncode = returncode
|
|
40
|
+
self.stderr = stderr
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
async def _drain(stream: asyncio.StreamReader | None) -> bytes:
|
|
44
|
+
return b"" if stream is None else await stream.read()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _kill(proc: asyncio.subprocess.Process) -> None:
|
|
48
|
+
"""SIGKILL the whole child process group if it is still running.
|
|
49
|
+
|
|
50
|
+
Killing the *group* (children are spawned with ``start_new_session=True``) ensures no
|
|
51
|
+
grandchild is orphaned — and, critically, that no descendant keeps the stderr pipe's write end
|
|
52
|
+
open, which would otherwise wedge the concurrent stderr drain on an abort/timeout.
|
|
53
|
+
"""
|
|
54
|
+
if proc.returncode is None:
|
|
55
|
+
try:
|
|
56
|
+
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
|
|
57
|
+
except ProcessLookupError:
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
async def stream_stdout(
|
|
62
|
+
argv: list[str], *, env: Mapping[str, str] | None = None, timeout: float | None = None
|
|
63
|
+
) -> AsyncIterator[bytes]:
|
|
64
|
+
"""Run ``argv`` and yield its stdout in chunks; raise on a non-zero exit or timeout.
|
|
65
|
+
|
|
66
|
+
:param argv: the command and its arguments.
|
|
67
|
+
:param env: environment for the child (e.g. ``PGPASSWORD``); ``None`` inherits.
|
|
68
|
+
:param timeout: wall-clock ceiling in seconds; ``None`` disables it.
|
|
69
|
+
:raises BackupToolError: when the command exits non-zero or exceeds ``timeout``.
|
|
70
|
+
"""
|
|
71
|
+
proc = await asyncio.create_subprocess_exec(
|
|
72
|
+
*argv,
|
|
73
|
+
stdout=asyncio.subprocess.PIPE,
|
|
74
|
+
stderr=asyncio.subprocess.PIPE,
|
|
75
|
+
env=dict(env) if env is not None else None,
|
|
76
|
+
start_new_session=True, # own process group, so _kill can take down the whole tree
|
|
77
|
+
)
|
|
78
|
+
stderr_task = asyncio.ensure_future(_drain(proc.stderr))
|
|
79
|
+
deadline = None if timeout is None else asyncio.get_running_loop().time() + timeout
|
|
80
|
+
assert proc.stdout is not None
|
|
81
|
+
reached_eof = False
|
|
82
|
+
try:
|
|
83
|
+
while True:
|
|
84
|
+
try:
|
|
85
|
+
chunk = await _read_within(proc.stdout, deadline)
|
|
86
|
+
except TimeoutError:
|
|
87
|
+
_kill(proc)
|
|
88
|
+
stderr = (await stderr_task).decode(errors="replace")
|
|
89
|
+
raise BackupToolError(argv[0], _TIMED_OUT, f"timed out after {timeout}s. {stderr}") from None
|
|
90
|
+
if not chunk:
|
|
91
|
+
reached_eof = True
|
|
92
|
+
break
|
|
93
|
+
yield chunk
|
|
94
|
+
finally:
|
|
95
|
+
# if the consumer aborted (GeneratorExit) or we errored before EOF, the child may be
|
|
96
|
+
# blocked writing to a now-undrained stdout pipe — kill it so wait() can't hang.
|
|
97
|
+
if not reached_eof:
|
|
98
|
+
_kill(proc)
|
|
99
|
+
stderr_final = (await stderr_task).decode(errors="replace")
|
|
100
|
+
returncode = await proc.wait()
|
|
101
|
+
if returncode != 0:
|
|
102
|
+
raise BackupToolError(argv[0], returncode, stderr_final)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
async def _read_within(stream: asyncio.StreamReader, deadline: float | None) -> bytes:
|
|
106
|
+
if deadline is None:
|
|
107
|
+
return await stream.read(_READ_CHUNK)
|
|
108
|
+
remaining = deadline - asyncio.get_running_loop().time()
|
|
109
|
+
if remaining <= 0:
|
|
110
|
+
raise TimeoutError
|
|
111
|
+
return await asyncio.wait_for(stream.read(_READ_CHUNK), timeout=remaining)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
async def feed_stdin(
|
|
115
|
+
argv: list[str],
|
|
116
|
+
source: AsyncIterator[bytes],
|
|
117
|
+
*,
|
|
118
|
+
env: Mapping[str, str] | None = None,
|
|
119
|
+
timeout: float | None = None,
|
|
120
|
+
) -> None:
|
|
121
|
+
"""Run ``argv`` and stream ``source`` into its stdin; raise on a non-zero exit or timeout.
|
|
122
|
+
|
|
123
|
+
:param argv: the command and its arguments.
|
|
124
|
+
:param source: async iterator of bytes to write to the child's stdin.
|
|
125
|
+
:param env: environment for the child; ``None`` inherits.
|
|
126
|
+
:param timeout: wall-clock ceiling in seconds; ``None`` disables it.
|
|
127
|
+
:raises BackupToolError: when the command exits non-zero or exceeds ``timeout``.
|
|
128
|
+
"""
|
|
129
|
+
proc = await asyncio.create_subprocess_exec(
|
|
130
|
+
*argv,
|
|
131
|
+
stdin=asyncio.subprocess.PIPE,
|
|
132
|
+
stdout=asyncio.subprocess.DEVNULL,
|
|
133
|
+
stderr=asyncio.subprocess.PIPE,
|
|
134
|
+
env=dict(env) if env is not None else None,
|
|
135
|
+
start_new_session=True, # own process group, so _kill can take down the whole tree
|
|
136
|
+
)
|
|
137
|
+
stderr_task = asyncio.ensure_future(_drain(proc.stderr))
|
|
138
|
+
pumped = False
|
|
139
|
+
try:
|
|
140
|
+
if timeout is None:
|
|
141
|
+
await _pump_stdin(proc, source)
|
|
142
|
+
else:
|
|
143
|
+
await asyncio.wait_for(_pump_stdin(proc, source), timeout=timeout)
|
|
144
|
+
pumped = True
|
|
145
|
+
except TimeoutError:
|
|
146
|
+
_kill(proc)
|
|
147
|
+
stderr = (await stderr_task).decode(errors="replace")
|
|
148
|
+
raise BackupToolError(argv[0], _TIMED_OUT, f"timed out after {timeout}s. {stderr}") from None
|
|
149
|
+
finally:
|
|
150
|
+
# only kill on the abnormal path (source raised / timed out); on success let the child
|
|
151
|
+
# finish and exit cleanly rather than SIGKILLing it out from under a good run.
|
|
152
|
+
if not pumped:
|
|
153
|
+
_kill(proc)
|
|
154
|
+
stderr_final = (await stderr_task).decode(errors="replace")
|
|
155
|
+
returncode = await proc.wait()
|
|
156
|
+
if returncode != 0:
|
|
157
|
+
raise BackupToolError(argv[0], returncode, stderr_final)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
async def _pump_stdin(proc: asyncio.subprocess.Process, source: AsyncIterator[bytes]) -> None:
|
|
161
|
+
assert proc.stdin is not None
|
|
162
|
+
try:
|
|
163
|
+
async for chunk in source:
|
|
164
|
+
proc.stdin.write(chunk)
|
|
165
|
+
await proc.stdin.drain()
|
|
166
|
+
proc.stdin.close()
|
|
167
|
+
await proc.stdin.wait_closed()
|
|
168
|
+
except BrokenPipeError, ConnectionResetError:
|
|
169
|
+
# the child died early; it will have a non-zero exit + stderr, so let the caller's
|
|
170
|
+
# returncode check surface BackupToolError (the real diagnosis) rather than this pipe error.
|
|
171
|
+
pass
|
|
172
|
+
# wait for the child to finish INSIDE the timed region, so ``timeout`` bounds the whole
|
|
173
|
+
# restore (a child that reads its stdin fast but then processes for a long time is still capped).
|
|
174
|
+
await proc.wait()
|
|
File without changes
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Grandfather-father-son (GFS) retention for backup pruning.
|
|
2
|
+
|
|
3
|
+
The policy keeps, for each tier, the **newest backup of each of the most recent N periods**: the
|
|
4
|
+
newest per calendar *day* for ``daily`` days, the newest per ISO *week* for ``weekly`` weeks, and
|
|
5
|
+
the newest per calendar *month* for ``monthly`` months. A backup survives if it is the
|
|
6
|
+
newest-in-its-period for any tier still inside that tier's window; the kept sets are unioned.
|
|
7
|
+
|
|
8
|
+
This is deliberately *period-based*, not date-classification-based. An earlier model tagged a
|
|
9
|
+
backup as monthly/weekly only if it happened to fall on the 1st / a Sunday -- so a schedule that
|
|
10
|
+
ever missed those days left a month or week with no keeper. Promoting the newest backup *within*
|
|
11
|
+
each period instead means every populated day/week/month keeps a representative regardless of when
|
|
12
|
+
in the period the backup ran.
|
|
13
|
+
|
|
14
|
+
:meth:`GfsRetention.select` is pure (records in, keep/delete split out, nothing mutated) so the
|
|
15
|
+
engine can dry-run a prune before deleting anything. The record is a small generic value
|
|
16
|
+
(:class:`BackupRecord`), so this composes over an ``ObjectStore`` listing as readily as a DB table.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from collections.abc import Callable, Iterable
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from datetime import datetime
|
|
24
|
+
|
|
25
|
+
from threetears.observe import get_logger
|
|
26
|
+
|
|
27
|
+
from threetears.backup.config import BackupConfig
|
|
28
|
+
|
|
29
|
+
__all__ = ["BackupRecord", "GfsRetention", "RetentionDecision"]
|
|
30
|
+
|
|
31
|
+
log = get_logger(__name__)
|
|
32
|
+
|
|
33
|
+
#: a period identity derived from a timestamp (calendar day, ISO week, or calendar month).
|
|
34
|
+
_PeriodKey = tuple[int, ...]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True, slots=True)
|
|
38
|
+
class BackupRecord:
|
|
39
|
+
"""One backup as retention sees it: a key, when it was made, and its size."""
|
|
40
|
+
|
|
41
|
+
key: str
|
|
42
|
+
created_at: datetime
|
|
43
|
+
size_bytes: int = 0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True, slots=True)
|
|
47
|
+
class RetentionDecision:
|
|
48
|
+
"""The outcome of applying a policy: what to keep, what to prune."""
|
|
49
|
+
|
|
50
|
+
keep: tuple[BackupRecord, ...]
|
|
51
|
+
delete: tuple[BackupRecord, ...]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _day_key(when: datetime) -> _PeriodKey:
|
|
55
|
+
return (when.year, when.month, when.day)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _week_key(when: datetime) -> _PeriodKey:
|
|
59
|
+
iso = when.isocalendar() # ISO week is year-boundary safe (late Dec can be week 1 of next year)
|
|
60
|
+
return (iso.year, iso.week)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _month_key(when: datetime) -> _PeriodKey:
|
|
64
|
+
return (when.year, when.month)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class GfsRetention:
|
|
68
|
+
"""A grandfather-father-son retention policy (newest backup per recent day/week/month).
|
|
69
|
+
|
|
70
|
+
:param daily: number of recent days to keep a backup for (>= 1).
|
|
71
|
+
:param weekly: number of recent ISO weeks to keep a backup for (>= 1).
|
|
72
|
+
:param monthly: number of recent months to keep a backup for (>= 1).
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def __init__(self, *, daily: int = 7, weekly: int = 4, monthly: int = 3) -> None:
|
|
76
|
+
for label, value in (("daily", daily), ("weekly", weekly), ("monthly", monthly)):
|
|
77
|
+
if value < 1:
|
|
78
|
+
raise ValueError(f"{label} retention must be >= 1")
|
|
79
|
+
self._daily = daily
|
|
80
|
+
self._weekly = weekly
|
|
81
|
+
self._monthly = monthly
|
|
82
|
+
|
|
83
|
+
@classmethod
|
|
84
|
+
def from_config(cls, config: BackupConfig) -> GfsRetention:
|
|
85
|
+
"""Build the policy from a :class:`BackupConfig`."""
|
|
86
|
+
return cls(
|
|
87
|
+
daily=config.retention_daily,
|
|
88
|
+
weekly=config.retention_weekly,
|
|
89
|
+
monthly=config.retention_monthly,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
def select(self, records: Iterable[BackupRecord]) -> RetentionDecision:
|
|
93
|
+
"""Split records into keep/delete by the newest-per-period policy.
|
|
94
|
+
|
|
95
|
+
:param records: the candidate backups (any order).
|
|
96
|
+
:return: the keep/delete decision (input records preserved, never mutated).
|
|
97
|
+
"""
|
|
98
|
+
ordered = sorted(records, key=lambda r: (r.created_at, r.key), reverse=True)
|
|
99
|
+
daily = self._period_keepers(ordered, _day_key, self._daily)
|
|
100
|
+
weekly = self._period_keepers(ordered, _week_key, self._weekly)
|
|
101
|
+
monthly = self._period_keepers(ordered, _month_key, self._monthly)
|
|
102
|
+
keep_keys = daily | weekly | monthly
|
|
103
|
+
|
|
104
|
+
keep = tuple(r for r in ordered if r.key in keep_keys)
|
|
105
|
+
delete = tuple(r for r in ordered if r.key not in keep_keys)
|
|
106
|
+
log.info(
|
|
107
|
+
"gfs retention evaluated",
|
|
108
|
+
extra={
|
|
109
|
+
"extra_data": {
|
|
110
|
+
"keep": len(keep),
|
|
111
|
+
"delete": len(delete),
|
|
112
|
+
"daily_keepers": len(daily),
|
|
113
|
+
"weekly_keepers": len(weekly),
|
|
114
|
+
"monthly_keepers": len(monthly),
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
)
|
|
118
|
+
return RetentionDecision(keep=keep, delete=delete)
|
|
119
|
+
|
|
120
|
+
@staticmethod
|
|
121
|
+
def _period_keepers(
|
|
122
|
+
ordered: list[BackupRecord],
|
|
123
|
+
period_of: Callable[[datetime], _PeriodKey],
|
|
124
|
+
keep_periods: int,
|
|
125
|
+
) -> set[str]:
|
|
126
|
+
"""Keys of the newest backup in each of the ``keep_periods`` most recent populated periods.
|
|
127
|
+
|
|
128
|
+
``ordered`` is newest-first, so the first record seen for a period *is* that period's newest,
|
|
129
|
+
and periods are first-seen in most-recent-first order.
|
|
130
|
+
"""
|
|
131
|
+
newest_in_period: dict[_PeriodKey, str] = {}
|
|
132
|
+
for record in ordered:
|
|
133
|
+
newest_in_period.setdefault(period_of(record.created_at), record.key)
|
|
134
|
+
recent_periods = list(newest_in_period)[:keep_periods]
|
|
135
|
+
return {newest_in_period[period] for period in recent_periods}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""Restore verification — prove a backup actually restores.
|
|
2
|
+
|
|
3
|
+
A backup you can't restore is a false comfort. :class:`RestoreVerifier` restores a backup into a
|
|
4
|
+
**throwaway temporary database** (never the source, never a shared schema — a real separate database
|
|
5
|
+
so ``pg_restore`` is fully isolated), runs assertions against it, and optionally invokes a
|
|
6
|
+
``post_restore_hook`` — the extension point for "spin a test stack against the restored data". The
|
|
7
|
+
temp database is created and dropped around the check.
|
|
8
|
+
|
|
9
|
+
Everything the verifier touches is injected: the temp-database *provisioner* (an async context
|
|
10
|
+
manager yielding a dsn), the *assertions*, and the *hook*. That keeps the orchestration unit-testable
|
|
11
|
+
with fakes; :func:`make_temp_db_provisioner` and :func:`count_tables` are the real
|
|
12
|
+
asyncpg-backed defaults for integration use.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
from collections.abc import AsyncIterator, Awaitable, Callable, Mapping
|
|
19
|
+
from contextlib import AbstractAsyncContextManager, asynccontextmanager
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from typing import Any, Protocol, runtime_checkable
|
|
22
|
+
from urllib.parse import urlparse, urlunparse
|
|
23
|
+
from uuid import uuid7
|
|
24
|
+
|
|
25
|
+
from threetears.observe import get_logger
|
|
26
|
+
|
|
27
|
+
from threetears.backup.process import feed_stdin
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"Assertions",
|
|
31
|
+
"PostRestoreHook",
|
|
32
|
+
"RestoreVerifier",
|
|
33
|
+
"SupportsRestore",
|
|
34
|
+
"TempDbProvisioner",
|
|
35
|
+
"VerificationResult",
|
|
36
|
+
"count_tables",
|
|
37
|
+
"make_subprocess_hook",
|
|
38
|
+
"make_temp_db_provisioner",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
log = get_logger(__name__)
|
|
42
|
+
|
|
43
|
+
#: yields the dsn of a fresh, empty temporary database, and tears it down on exit.
|
|
44
|
+
TempDbProvisioner = Callable[[], AbstractAsyncContextManager[str]]
|
|
45
|
+
#: run against the restored temp dsn; returns named checks recorded on the result.
|
|
46
|
+
Assertions = Callable[[str], Awaitable[Mapping[str, Any]]]
|
|
47
|
+
#: the opt-in extension point (e.g. boot a stack against the restored dsn).
|
|
48
|
+
PostRestoreHook = Callable[[str], Awaitable[None]]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@runtime_checkable
|
|
52
|
+
class SupportsRestore(Protocol):
|
|
53
|
+
"""The only capability the verifier needs from the engine: restore a backup into a dsn.
|
|
54
|
+
|
|
55
|
+
:class:`~threetears.backup.engine.BackupEngine` satisfies this structurally; segregating it
|
|
56
|
+
keeps the verifier decoupled from the full engine surface.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
async def restore_into(self, target_dsn: str, key: str) -> None: ...
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True, slots=True)
|
|
63
|
+
class VerificationResult:
|
|
64
|
+
"""Outcome of verifying one backup."""
|
|
65
|
+
|
|
66
|
+
key: str
|
|
67
|
+
ok: bool
|
|
68
|
+
checks: dict[str, Any] = field(default_factory=dict)
|
|
69
|
+
hook_ran: bool = False
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class RestoreVerifier:
|
|
73
|
+
"""Verify backups by restoring them into a temporary database.
|
|
74
|
+
|
|
75
|
+
:param engine: anything that can :meth:`restore_into` a dsn (a :class:`BackupEngine`).
|
|
76
|
+
:param provision_temp_db: factory yielding an async context manager over a fresh temp dsn.
|
|
77
|
+
:param assertions: optional check run against the restored dsn (defaults to none).
|
|
78
|
+
:param post_restore_hook: optional subprocess/stack hook run against the restored dsn.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
def __init__(
|
|
82
|
+
self,
|
|
83
|
+
engine: SupportsRestore,
|
|
84
|
+
provision_temp_db: TempDbProvisioner,
|
|
85
|
+
*,
|
|
86
|
+
assertions: Assertions | None = None,
|
|
87
|
+
post_restore_hook: PostRestoreHook | None = None,
|
|
88
|
+
) -> None:
|
|
89
|
+
self._engine = engine
|
|
90
|
+
self._provision = provision_temp_db
|
|
91
|
+
self._assertions = assertions
|
|
92
|
+
self._hook = post_restore_hook
|
|
93
|
+
|
|
94
|
+
async def verify(self, key: str) -> VerificationResult:
|
|
95
|
+
"""Restore ``key`` into a temp database, run assertions + the hook, then tear it down."""
|
|
96
|
+
checks: dict[str, Any] = {}
|
|
97
|
+
hook_ran = False
|
|
98
|
+
async with self._provision() as temp_dsn:
|
|
99
|
+
await self._engine.restore_into(temp_dsn, key)
|
|
100
|
+
if self._assertions is not None:
|
|
101
|
+
checks = dict(await self._assertions(temp_dsn))
|
|
102
|
+
if self._hook is not None:
|
|
103
|
+
await self._hook(temp_dsn)
|
|
104
|
+
hook_ran = True
|
|
105
|
+
log.info("restore verified", extra={"extra_data": {"key": key, "checks": checks, "hook_ran": hook_ran}})
|
|
106
|
+
return VerificationResult(key=key, ok=True, checks=checks, hook_ran=hook_ran)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _swap_database(dsn: str, database: str) -> str:
|
|
110
|
+
parsed = urlparse(dsn)
|
|
111
|
+
return urlunparse(parsed._replace(path=f"/{database}"))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def make_temp_db_provisioner(
|
|
115
|
+
admin_dsn: str,
|
|
116
|
+
*,
|
|
117
|
+
connect: Callable[[str], Awaitable[Any]],
|
|
118
|
+
name_prefix: str = "verify_restore_",
|
|
119
|
+
) -> TempDbProvisioner:
|
|
120
|
+
"""Build a provisioner that CREATEs a temp database and DROPs it on exit (asyncpg).
|
|
121
|
+
|
|
122
|
+
:param admin_dsn: a dsn with rights to CREATE/DROP DATABASE (e.g. the maintenance db).
|
|
123
|
+
:param connect: an async connect callable (``asyncpg.connect``); injected for testability.
|
|
124
|
+
:param name_prefix: prefix for the generated temp database name.
|
|
125
|
+
:return: a :data:`TempDbProvisioner`.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
@asynccontextmanager
|
|
129
|
+
async def provision() -> AsyncIterator[str]:
|
|
130
|
+
database = f"{name_prefix}{uuid7().hex[:12]}"
|
|
131
|
+
admin = await connect(admin_dsn)
|
|
132
|
+
try:
|
|
133
|
+
await admin.execute(f'CREATE DATABASE "{database}"')
|
|
134
|
+
finally:
|
|
135
|
+
await admin.close()
|
|
136
|
+
try:
|
|
137
|
+
yield _swap_database(admin_dsn, database)
|
|
138
|
+
finally:
|
|
139
|
+
cleanup = await connect(admin_dsn)
|
|
140
|
+
try:
|
|
141
|
+
await cleanup.execute(f'DROP DATABASE IF EXISTS "{database}"')
|
|
142
|
+
finally:
|
|
143
|
+
await cleanup.close()
|
|
144
|
+
|
|
145
|
+
return provision
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def count_tables(*, connect: Callable[[str], Awaitable[Any]]) -> Assertions:
|
|
149
|
+
"""Default assertion: the restored database has at least one user table (asyncpg).
|
|
150
|
+
|
|
151
|
+
Counts base tables across every non-system schema (excluding ``pg_catalog`` /
|
|
152
|
+
``information_schema``), not just ``public`` — real apps put their tables in named schemas
|
|
153
|
+
(scriob's live in ``platform``), so a ``public``-only check would read zero and falsely fail.
|
|
154
|
+
|
|
155
|
+
:param connect: an async connect callable (``asyncpg.connect``); injected for testability.
|
|
156
|
+
:return: an :data:`Assertions` recording ``{"tables": n}`` and asserting ``n > 0``.
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
async def assertion(dsn: str) -> Mapping[str, Any]:
|
|
160
|
+
conn = await connect(dsn)
|
|
161
|
+
try:
|
|
162
|
+
count = await conn.fetchval(
|
|
163
|
+
"SELECT count(*) FROM information_schema.tables "
|
|
164
|
+
"WHERE table_type = 'BASE TABLE' "
|
|
165
|
+
"AND table_schema NOT IN ('pg_catalog', 'information_schema')"
|
|
166
|
+
)
|
|
167
|
+
finally:
|
|
168
|
+
await conn.close()
|
|
169
|
+
tables = int(count)
|
|
170
|
+
if tables < 1:
|
|
171
|
+
raise AssertionError("restored database has no user tables")
|
|
172
|
+
return {"tables": tables}
|
|
173
|
+
|
|
174
|
+
return assertion
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def make_subprocess_hook(argv: list[str], *, dsn_env: str = "RESTORED_DATABASE_URL") -> PostRestoreHook:
|
|
178
|
+
"""Build a hook that runs ``argv`` with the restored dsn exported in the environment.
|
|
179
|
+
|
|
180
|
+
The stubbed extension point for "start a test stack against the restored database": off unless a
|
|
181
|
+
caller wires it in. The restored dsn is passed to the child via ``dsn_env`` (default
|
|
182
|
+
``RESTORED_DATABASE_URL``).
|
|
183
|
+
|
|
184
|
+
:param argv: the command to run after a successful restore.
|
|
185
|
+
:param dsn_env: environment variable the restored dsn is exported under.
|
|
186
|
+
:return: a :data:`PostRestoreHook`.
|
|
187
|
+
"""
|
|
188
|
+
|
|
189
|
+
async def hook(dsn: str) -> None:
|
|
190
|
+
await feed_stdin(argv, _no_stdin(), env={**os.environ, dsn_env: dsn})
|
|
191
|
+
|
|
192
|
+
return hook
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
async def _no_stdin() -> AsyncIterator[bytes]:
|
|
196
|
+
"""An empty byte stream — the hook command takes its input from the environment, not stdin."""
|
|
197
|
+
return
|
|
198
|
+
yield b"" # pragma: no cover - the bare `yield` only makes this an async generator
|