cli-backup 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.
- cli_backup/__init__.py +1 -0
- cli_backup/cli.py +209 -0
- cli_backup/config.py +68 -0
- cli_backup/cpu_priority.py +21 -0
- cli_backup/files.py +216 -0
- cli_backup/mysql.py +109 -0
- cli_backup/redis_backup.py +24 -0
- cli_backup/rehearsal.py +79 -0
- cli_backup/scheduled.py +616 -0
- cli_backup/storage.py +386 -0
- cli_backup-0.1.0.dist-info/METADATA +119 -0
- cli_backup-0.1.0.dist-info/RECORD +16 -0
- cli_backup-0.1.0.dist-info/WHEEL +5 -0
- cli_backup-0.1.0.dist-info/entry_points.txt +2 -0
- cli_backup-0.1.0.dist-info/licenses/LICENSE +21 -0
- cli_backup-0.1.0.dist-info/top_level.txt +1 -0
cli_backup/scheduled.py
ADDED
|
@@ -0,0 +1,616 @@
|
|
|
1
|
+
"""Validated daily SQL backups and idempotent period copies."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import gzip
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import shutil
|
|
10
|
+
import sqlite3
|
|
11
|
+
import stat
|
|
12
|
+
from contextlib import closing
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
import tempfile
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from datetime import date, datetime, timedelta, timezone
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from .config import read_project_env, validate_project
|
|
21
|
+
from .cpu_priority import lower_cpu_priority, validate_cpu_nice
|
|
22
|
+
from .mysql import MySQLConfig, count_mysql_objects, restore_mysql
|
|
23
|
+
from .rehearsal import rehearse_mysql
|
|
24
|
+
from .storage import LocalStorage, S3Storage, local_get, make_manifest, prune_local, s3_get, s3_list
|
|
25
|
+
|
|
26
|
+
NAME = re.compile(r'[A-Za-z0-9_][A-Za-z0-9_-]{0,63}\Z')
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def flag(value: str, name: str) -> bool:
|
|
30
|
+
if value.lower() not in ('true', 'false'):
|
|
31
|
+
raise ValueError(f'{name} must be true or false')
|
|
32
|
+
return value.lower() == 'true'
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def databases(value: str) -> list[str]:
|
|
36
|
+
names = value.split(',')
|
|
37
|
+
if not names or any(not NAME.fullmatch(n) for n in names) or len(set(names)) != len(names):
|
|
38
|
+
raise ValueError('CLI_BACKUP_DB_NAME must be a comma-separated list of distinct database names')
|
|
39
|
+
return names
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class Settings:
|
|
44
|
+
project: str
|
|
45
|
+
compose: str
|
|
46
|
+
container: str
|
|
47
|
+
service: str
|
|
48
|
+
host: str
|
|
49
|
+
port: int
|
|
50
|
+
names: str
|
|
51
|
+
user: str
|
|
52
|
+
password: str
|
|
53
|
+
daily: bool
|
|
54
|
+
weekly: bool
|
|
55
|
+
monthly: bool
|
|
56
|
+
local_root: str
|
|
57
|
+
bucket: str
|
|
58
|
+
s3_endpoint: str
|
|
59
|
+
s3_region: str
|
|
60
|
+
s3_key: str
|
|
61
|
+
s3_secret: str
|
|
62
|
+
local_days: str
|
|
63
|
+
temp_root: str
|
|
64
|
+
test_image: str
|
|
65
|
+
cpu_nice: int
|
|
66
|
+
container_runtime: str
|
|
67
|
+
db_path: str
|
|
68
|
+
s3_prefix: str
|
|
69
|
+
|
|
70
|
+
@classmethod
|
|
71
|
+
def from_env(cls, env: dict[str, str]) -> Settings:
|
|
72
|
+
compose = env.get('CLI_BACKUP_COMPOSE_PROJECT_NAME', '')
|
|
73
|
+
project = env.get('CLI_BACKUP_PROJECT_NAME') or compose
|
|
74
|
+
if not project:
|
|
75
|
+
raise ValueError('CLI_BACKUP_PROJECT_NAME is required unless CLI_BACKUP_COMPOSE_PROJECT_NAME is set')
|
|
76
|
+
validate_project(project)
|
|
77
|
+
if compose:
|
|
78
|
+
validate_project(compose)
|
|
79
|
+
container = env.get('CLI_BACKUP_DB_CONTAINER', '')
|
|
80
|
+
host = env.get('CLI_BACKUP_DB_HOST', '')
|
|
81
|
+
db_path = env.get('CLI_BACKUP_DB_PATH', '')
|
|
82
|
+
runtime = env.get('CLI_BACKUP_CONTAINER_RUNTIME', 'docker')
|
|
83
|
+
if runtime not in ('docker', 'podman'):
|
|
84
|
+
raise ValueError('CLI_BACKUP_CONTAINER_RUNTIME must be docker or podman')
|
|
85
|
+
service = env.get('CLI_BACKUP_DB_SERVICE_NAME', 'mariadb').lower()
|
|
86
|
+
if service not in ('mariadb', 'mysql', 'sqlite'):
|
|
87
|
+
raise ValueError('unsupported CLI_BACKUP_DB_SERVICE_NAME; supported: mariadb, mysql, sqlite')
|
|
88
|
+
if service == 'sqlite':
|
|
89
|
+
if not db_path or not Path(db_path).is_absolute() or container or host or compose:
|
|
90
|
+
raise ValueError('SQLite requires an absolute CLI_BACKUP_DB_PATH and no container or host')
|
|
91
|
+
else:
|
|
92
|
+
if db_path or bool(container) == bool(host):
|
|
93
|
+
raise ValueError('set exactly one of CLI_BACKUP_DB_CONTAINER or CLI_BACKUP_DB_HOST')
|
|
94
|
+
if compose and not container:
|
|
95
|
+
raise ValueError('Compose project requires CLI_BACKUP_DB_CONTAINER')
|
|
96
|
+
if container and not NAME.fullmatch(container):
|
|
97
|
+
raise ValueError('invalid CLI_BACKUP_DB_CONTAINER')
|
|
98
|
+
if host and (host.startswith('-') or not re.fullmatch(r'[A-Za-z0-9_.:-]+', host)):
|
|
99
|
+
raise ValueError('invalid CLI_BACKUP_DB_HOST')
|
|
100
|
+
port = int(env.get('CLI_BACKUP_DB_PORT', '3306'))
|
|
101
|
+
if not 1 <= port <= 65535:
|
|
102
|
+
raise ValueError('invalid CLI_BACKUP_DB_PORT')
|
|
103
|
+
names = env.get('CLI_BACKUP_DB_NAME', '')
|
|
104
|
+
if names:
|
|
105
|
+
parsed_names = databases(names)
|
|
106
|
+
if service == 'sqlite' and len(parsed_names) != 1:
|
|
107
|
+
raise ValueError('SQLite accepts exactly one database name')
|
|
108
|
+
if host and not names:
|
|
109
|
+
raise ValueError('CLI_BACKUP_DB_NAME is required for TCP')
|
|
110
|
+
user = env.get('CLI_BACKUP_DB_USER', '')
|
|
111
|
+
password = env.get('CLI_BACKUP_DB_PASSWORD', '')
|
|
112
|
+
if bool(user) != bool(password):
|
|
113
|
+
raise ValueError('CLI_BACKUP_DB_USER and CLI_BACKUP_DB_PASSWORD must be supplied together')
|
|
114
|
+
if host and not user:
|
|
115
|
+
raise ValueError('TCP requires CLI_BACKUP_DB_USER and CLI_BACKUP_DB_PASSWORD')
|
|
116
|
+
root, bucket = env.get('CLI_BACKUP_LOCAL_ROOT', ''), env.get('CLI_BACKUP_S3_BUCKET', '')
|
|
117
|
+
if not root and not bucket:
|
|
118
|
+
raise ValueError('configure CLI_BACKUP_LOCAL_ROOT or CLI_BACKUP_S3_BUCKET')
|
|
119
|
+
if bucket and any(not env.get('CLI_BACKUP_S3_' + key) for key in ('KEY', 'SECRET', 'ENDPOINT', 'REGION')):
|
|
120
|
+
raise ValueError('S3 configuration is incomplete')
|
|
121
|
+
if env.get('CLI_BACKUP_LOCAL_DAYS') and int(env['CLI_BACKUP_LOCAL_DAYS']) < 1:
|
|
122
|
+
raise ValueError('CLI_BACKUP_LOCAL_DAYS must be positive')
|
|
123
|
+
daily = flag(env.get('CLI_BACKUP_PERIOD_DAILY', 'true'), 'CLI_BACKUP_PERIOD_DAILY')
|
|
124
|
+
weekly = flag(env.get('CLI_BACKUP_PERIOD_WEEKLY', 'false'), 'CLI_BACKUP_PERIOD_WEEKLY')
|
|
125
|
+
monthly = flag(env.get('CLI_BACKUP_PERIOD_MONTHLY', 'true'), 'CLI_BACKUP_PERIOD_MONTHLY')
|
|
126
|
+
if not any((daily, weekly, monthly)):
|
|
127
|
+
raise ValueError('at least one period must be enabled')
|
|
128
|
+
return cls(project, compose, container, service, host, port, names, user, password,
|
|
129
|
+
daily, weekly, monthly, root, bucket, env.get("CLI_BACKUP_S3_ENDPOINT", ""),
|
|
130
|
+
env.get("CLI_BACKUP_S3_REGION", ""), env.get("CLI_BACKUP_S3_KEY", ""),
|
|
131
|
+
env.get("CLI_BACKUP_S3_SECRET", ""), env.get("CLI_BACKUP_LOCAL_DAYS", ""),
|
|
132
|
+
env.get("CLI_BACKUP_TEMP_ROOT", ""), env.get("CLI_BACKUP_TEST_MYSQL_IMAGE", ""),
|
|
133
|
+
validate_cpu_nice(env.get("CLI_BACKUP_CPU_NICE", "10")), runtime, db_path,
|
|
134
|
+
env.get("CLI_BACKUP_S3_PREFIX", "").strip("/"))
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def container_info(settings: Settings) -> tuple[str, dict[str, str], str]:
|
|
138
|
+
if settings.compose:
|
|
139
|
+
result = subprocess.run([settings.container_runtime, 'ps', '--filter', 'label=com.docker.compose.project=' + settings.compose,
|
|
140
|
+
'--filter', 'label=com.docker.compose.service=' + settings.container,
|
|
141
|
+
'--quiet', '--no-trunc'],
|
|
142
|
+
check=True, capture_output=True, text=True, timeout=30)
|
|
143
|
+
ids = result.stdout.split()
|
|
144
|
+
if len(ids) != 1:
|
|
145
|
+
raise ValueError('expected exactly one running Compose database container')
|
|
146
|
+
target = ids[0]
|
|
147
|
+
else:
|
|
148
|
+
target = settings.container
|
|
149
|
+
result = subprocess.run([settings.container_runtime, 'inspect', target], check=True, capture_output=True, text=True, timeout=30)
|
|
150
|
+
info = json.loads(result.stdout)
|
|
151
|
+
if len(info) != 1 or not info[0].get('State', {}).get('Running'):
|
|
152
|
+
raise ValueError('database container is not running')
|
|
153
|
+
if settings.compose and (info[0].get('Config', {}).get('Labels') or {}).get('com.docker.compose.oneoff', '').lower() == 'true':
|
|
154
|
+
raise ValueError('one-off containers are not backup targets')
|
|
155
|
+
environment = {}
|
|
156
|
+
for item in info[0].get('Config', {}).get('Env') or []:
|
|
157
|
+
key, sep, value = item.partition('=')
|
|
158
|
+
if sep:
|
|
159
|
+
environment[key] = value
|
|
160
|
+
return info[0]['Id'], environment, info[0]['Config']['Image']
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def connection(settings: Settings) -> tuple[MySQLConfig | None, list[str], str]:
|
|
164
|
+
if settings.service == 'sqlite':
|
|
165
|
+
names = databases(settings.names or Path(settings.db_path).stem)
|
|
166
|
+
return None, names, ''
|
|
167
|
+
target, env, image = container_info(settings) if settings.container else ('', {}, '')
|
|
168
|
+
names = databases(settings.names or env.get('MARIADB_DATABASE') or env.get('MYSQL_DATABASE') or '')
|
|
169
|
+
user = settings.user or 'root'
|
|
170
|
+
password = settings.password or env.get('MARIADB_ROOT_PASSWORD') or env.get('MYSQL_ROOT_PASSWORD') or ''
|
|
171
|
+
if not password:
|
|
172
|
+
raise ValueError('database password is missing; set CLI_BACKUP_DB_USER and CLI_BACKUP_DB_PASSWORD')
|
|
173
|
+
return (MySQLConfig('exec' if target else 'tcp', names[0], user, password,
|
|
174
|
+
host=settings.host or '127.0.0.1', port=settings.port,
|
|
175
|
+
service=target, client=settings.service, cpu_nice=settings.cpu_nice,
|
|
176
|
+
container_runtime=settings.container_runtime), names, image)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def stores(settings: Settings, period: str) -> list[tuple[str, object]]:
|
|
180
|
+
result: list[tuple[str, object]] = []
|
|
181
|
+
if settings.local_root:
|
|
182
|
+
result.append(('local', LocalStorage(Path(settings.local_root).expanduser() / period)))
|
|
183
|
+
if settings.bucket:
|
|
184
|
+
import boto3
|
|
185
|
+
from botocore.config import Config
|
|
186
|
+
client = boto3.client('s3', endpoint_url=settings.s3_endpoint,
|
|
187
|
+
region_name=settings.s3_region,
|
|
188
|
+
aws_access_key_id=settings.s3_key,
|
|
189
|
+
aws_secret_access_key=settings.s3_secret,
|
|
190
|
+
config=Config(s3={'addressing_style': 'path'}))
|
|
191
|
+
result.append(('s3', S3Storage(client, settings.bucket, '/'.join(filter(None, (settings.s3_prefix, period))))))
|
|
192
|
+
return result
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def list_store(store, project: str, source: str):
|
|
196
|
+
return store.list(project, source) if isinstance(store, LocalStorage) else s3_list(store, project, source)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def get(store, manifest, path: Path) -> None:
|
|
200
|
+
with path.open('xb') as target:
|
|
201
|
+
(local_get if isinstance(store, LocalStorage) else s3_get)(store, manifest, target)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def capture_gzip(config: MySQLConfig, path: Path) -> None:
|
|
205
|
+
from .mysql import command
|
|
206
|
+
process = subprocess.Popen(command(config), env=dict(os.environ, MYSQL_PWD=config.password), stdout=subprocess.PIPE)
|
|
207
|
+
assert process.stdout is not None
|
|
208
|
+
try:
|
|
209
|
+
size = 0
|
|
210
|
+
with gzip.open(path, 'xb') as out:
|
|
211
|
+
while chunk := process.stdout.read(1024 * 1024):
|
|
212
|
+
out.write(chunk)
|
|
213
|
+
size += len(chunk)
|
|
214
|
+
process.stdout.close()
|
|
215
|
+
if process.wait() != 0 or not size:
|
|
216
|
+
raise ValueError('database dump failed or is empty')
|
|
217
|
+
except BaseException:
|
|
218
|
+
process.kill()
|
|
219
|
+
process.wait()
|
|
220
|
+
raise
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def capture_source(settings: Settings, cfg: MySQLConfig | None, name: str, artifact: Path, tmp: Path) -> str | None:
|
|
224
|
+
if settings.service != 'sqlite':
|
|
225
|
+
if cfg is None:
|
|
226
|
+
raise ValueError('missing database connection')
|
|
227
|
+
database = MySQLConfig(**{**vars(cfg), 'database': name})
|
|
228
|
+
before = count_mysql_objects(database)
|
|
229
|
+
capture_gzip(database, artifact)
|
|
230
|
+
if count_mysql_objects(database) != before:
|
|
231
|
+
raise ValueError('schema object counts changed during dump')
|
|
232
|
+
return before
|
|
233
|
+
source = Path(settings.db_path)
|
|
234
|
+
if source.resolve(strict=True) != source or not stat.S_ISREG(source.lstat().st_mode):
|
|
235
|
+
raise ValueError('SQLite source must be an existing canonical regular file')
|
|
236
|
+
output = tmp / (name + '.sqlite')
|
|
237
|
+
fd = os.open(output, os.O_CREAT | os.O_EXCL | os.O_RDWR | os.O_NOFOLLOW, 0o600)
|
|
238
|
+
os.close(fd)
|
|
239
|
+
with closing(sqlite3.connect(source.as_uri() + '?mode=ro', uri=True)) as reader:
|
|
240
|
+
with closing(sqlite3.connect(output)) as writer:
|
|
241
|
+
reader.backup(writer)
|
|
242
|
+
writer.execute('PRAGMA journal_mode=DELETE')
|
|
243
|
+
with output.open('rb') as source_file, gzip.open(artifact, 'xb') as target:
|
|
244
|
+
shutil.copyfileobj(source_file, target, 1024 * 1024)
|
|
245
|
+
return None
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def backup_suffix(settings: Settings, name: str) -> str:
|
|
249
|
+
extension = 'sqlite.gz' if settings.service == 'sqlite' else 'sql.gz'
|
|
250
|
+
return '.' + name + '.' + extension
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def period_start(store, project: str, source: str, name: str, available) -> date:
|
|
254
|
+
"""Persist when this database joined the schedule, to avoid inventing pre-adoption gaps."""
|
|
255
|
+
key = f'.cli-backup-start-{name}'
|
|
256
|
+
if isinstance(store, LocalStorage):
|
|
257
|
+
folder = store.root.parent / '.state' / project / source
|
|
258
|
+
folder.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
259
|
+
path = folder / key
|
|
260
|
+
try:
|
|
261
|
+
value = path.read_text(encoding='ascii')
|
|
262
|
+
except FileNotFoundError:
|
|
263
|
+
if not available:
|
|
264
|
+
raise ValueError('no daily backup from which to initialize period tracking')
|
|
265
|
+
value = min(m.timestamp[:10] for m in available)
|
|
266
|
+
try:
|
|
267
|
+
with path.open('x', encoding='ascii') as file:
|
|
268
|
+
os.chmod(path, 0o600)
|
|
269
|
+
file.write(value)
|
|
270
|
+
file.flush()
|
|
271
|
+
os.fsync(file.fileno())
|
|
272
|
+
except FileExistsError:
|
|
273
|
+
value = path.read_text(encoding='ascii')
|
|
274
|
+
else:
|
|
275
|
+
from botocore.exceptions import ClientError
|
|
276
|
+
object_key = '/'.join(filter(None, (store.prefix.rsplit('/', 1)[0] if '/' in store.prefix else '', '.state', project, source, key)))
|
|
277
|
+
try:
|
|
278
|
+
body = store.client.get_object(Bucket=store.bucket, Key=object_key)['Body']
|
|
279
|
+
try:
|
|
280
|
+
value = body.read(32).decode('ascii')
|
|
281
|
+
finally:
|
|
282
|
+
body.close()
|
|
283
|
+
except ClientError as exc:
|
|
284
|
+
if exc.response.get('Error', {}).get('Code') not in ('404', 'NoSuchKey', 'NotFound'):
|
|
285
|
+
raise
|
|
286
|
+
if not available:
|
|
287
|
+
raise ValueError('no daily backup from which to initialize period tracking')
|
|
288
|
+
value = min(m.timestamp[:10] for m in available)
|
|
289
|
+
try:
|
|
290
|
+
store.client.put_object(Bucket=store.bucket, Key=object_key, Body=value.encode('ascii'), IfNoneMatch='*')
|
|
291
|
+
except ClientError as race:
|
|
292
|
+
if race.response.get('Error', {}).get('Code') not in ('409', '412', 'PreconditionFailed', 'ConditionalRequestConflict'):
|
|
293
|
+
raise
|
|
294
|
+
body = store.client.get_object(Bucket=store.bucket, Key=object_key)['Body']
|
|
295
|
+
try:
|
|
296
|
+
value = body.read(32).decode('ascii')
|
|
297
|
+
finally:
|
|
298
|
+
body.close()
|
|
299
|
+
if not re.fullmatch(r'\d{4}-\d{2}-\d{2}', value):
|
|
300
|
+
raise ValueError('invalid period tracking date')
|
|
301
|
+
return date.fromisoformat(value)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def period_copies(settings: Settings, name: str, today: date, tmp: Path) -> None:
|
|
305
|
+
previous_month_last = today.replace(day=1) - timedelta(days=1)
|
|
306
|
+
previous_monday = today - timedelta(days=today.weekday() + 7)
|
|
307
|
+
previous_sunday = previous_monday + timedelta(days=6)
|
|
308
|
+
failures = []
|
|
309
|
+
for label, daily in stores(settings, 'daily'):
|
|
310
|
+
try:
|
|
311
|
+
sources = [m for m in list_store(daily, settings.project, settings.service)
|
|
312
|
+
if m.artifact.endswith(backup_suffix(settings, name))]
|
|
313
|
+
start = period_start(daily, settings.project, settings.service, name, sources)
|
|
314
|
+
monthly = dict(stores(settings, 'monthly')).get(label)
|
|
315
|
+
weekly = dict(stores(settings, 'weekly')).get(label)
|
|
316
|
+
monthly_selected = []
|
|
317
|
+
if settings.monthly:
|
|
318
|
+
monthly_selected = [m for m in list_store(monthly, settings.project, settings.service)
|
|
319
|
+
if m.artifact.endswith(backup_suffix(settings, name))
|
|
320
|
+
and m.timestamp[:7] == previous_month_last.isoformat()[:7]]
|
|
321
|
+
if settings.monthly and previous_month_last >= start and today.day >= 3 and not monthly_selected:
|
|
322
|
+
candidates = [m for m in sources if m.timestamp[:7] == previous_month_last.isoformat()[:7]]
|
|
323
|
+
if not candidates:
|
|
324
|
+
raise ValueError(f'no daily backup for previous month: {name}')
|
|
325
|
+
chosen = candidates[-1]
|
|
326
|
+
file = tmp / (label + '-monthly-' + chosen.artifact)
|
|
327
|
+
get(daily, chosen, file)
|
|
328
|
+
put_with_recovery(monthly, file, chosen)
|
|
329
|
+
monthly_selected = [chosen]
|
|
330
|
+
print(f'{label}: monthly/{chosen.artifact}')
|
|
331
|
+
if settings.weekly and previous_sunday >= start and today.weekday() >= 2:
|
|
332
|
+
existing = [m for m in list_store(weekly, settings.project, settings.service)
|
|
333
|
+
if m.artifact.endswith(backup_suffix(settings, name))
|
|
334
|
+
and previous_monday <= date.fromisoformat(m.timestamp[:10]) <= previous_sunday]
|
|
335
|
+
if not existing:
|
|
336
|
+
reserved = {m.artifact for m in monthly_selected}
|
|
337
|
+
candidates = [m for m in sources if previous_monday <= date.fromisoformat(m.timestamp[:10]) <= previous_sunday
|
|
338
|
+
and m.artifact not in reserved]
|
|
339
|
+
if not candidates:
|
|
340
|
+
raise ValueError(f'no daily backup for previous week: {name}')
|
|
341
|
+
chosen = candidates[-1]
|
|
342
|
+
file = tmp / (label + '-weekly-' + chosen.artifact)
|
|
343
|
+
get(daily, chosen, file)
|
|
344
|
+
put_with_recovery(weekly, file, chosen)
|
|
345
|
+
print(f'{label}: weekly/{chosen.artifact}')
|
|
346
|
+
except Exception as exc:
|
|
347
|
+
print(f'{label}: period copy failed ({type(exc).__name__}: {exc})', file=sys.stderr)
|
|
348
|
+
failures.append(label)
|
|
349
|
+
if failures:
|
|
350
|
+
raise ValueError('period copy incomplete')
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def put_with_recovery(store, artifact: Path, manifest) -> None:
|
|
354
|
+
"""Complete a previously interrupted publication only when bytes match exactly."""
|
|
355
|
+
try:
|
|
356
|
+
store.put(artifact, manifest)
|
|
357
|
+
return
|
|
358
|
+
except Exception as original:
|
|
359
|
+
if isinstance(store, LocalStorage):
|
|
360
|
+
metadata = store.directory(manifest) / (manifest.artifact + '.json')
|
|
361
|
+
if metadata.exists():
|
|
362
|
+
raise original
|
|
363
|
+
try:
|
|
364
|
+
if not store.verify(manifest):
|
|
365
|
+
raise original
|
|
366
|
+
fd, temporary = tempfile.mkstemp(prefix='.manifest-', dir=metadata.parent)
|
|
367
|
+
try:
|
|
368
|
+
with os.fdopen(fd, 'wb') as stream:
|
|
369
|
+
stream.write(manifest.encode())
|
|
370
|
+
stream.flush()
|
|
371
|
+
os.fsync(stream.fileno())
|
|
372
|
+
os.link(temporary, metadata)
|
|
373
|
+
finally:
|
|
374
|
+
os.unlink(temporary)
|
|
375
|
+
return
|
|
376
|
+
except (FileNotFoundError, FileExistsError):
|
|
377
|
+
raise original
|
|
378
|
+
try:
|
|
379
|
+
if not store.verify(manifest):
|
|
380
|
+
raise original
|
|
381
|
+
store.client.put_object(Bucket=store.bucket, Key=store.key(manifest) + '.json',
|
|
382
|
+
Body=manifest.encode(), IfNoneMatch='*')
|
|
383
|
+
return
|
|
384
|
+
except Exception:
|
|
385
|
+
raise original
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def backup_database(settings: Settings, cfg: MySQLConfig, name: str, tmp: Path, now: datetime) -> None:
|
|
389
|
+
destinations = stores(settings, 'daily')
|
|
390
|
+
artifact_name = f'{now:%Y-%m-%d}' + backup_suffix(settings, name)
|
|
391
|
+
existing = []
|
|
392
|
+
missing = []
|
|
393
|
+
failed = []
|
|
394
|
+
for label, store in destinations:
|
|
395
|
+
try:
|
|
396
|
+
found = next((m for m in list_store(store, settings.project, settings.service)
|
|
397
|
+
if m.artifact == artifact_name), None)
|
|
398
|
+
if found:
|
|
399
|
+
existing.append((label, store, found))
|
|
400
|
+
else:
|
|
401
|
+
missing.append((label, store))
|
|
402
|
+
except Exception as exc:
|
|
403
|
+
print(f'{label}: daily listing failed ({type(exc).__name__})', file=sys.stderr)
|
|
404
|
+
failed.append(label)
|
|
405
|
+
if not existing and not missing:
|
|
406
|
+
raise ValueError('daily backup incomplete')
|
|
407
|
+
artifact = tmp / (name + '.sql.gz')
|
|
408
|
+
if existing:
|
|
409
|
+
manifest = existing[0][2]
|
|
410
|
+
if any(item[2] != manifest for item in existing):
|
|
411
|
+
raise ValueError('daily destinations disagree about the existing backup')
|
|
412
|
+
for label, store, current in existing:
|
|
413
|
+
checked = tmp / (label + '-existing-' + name + '.sql.gz')
|
|
414
|
+
get(store, current, checked)
|
|
415
|
+
if label == existing[0][0]:
|
|
416
|
+
artifact = checked
|
|
417
|
+
else:
|
|
418
|
+
before = capture_source(settings, cfg, name, artifact, tmp)
|
|
419
|
+
manifest = make_manifest(artifact, settings.project, settings.service, artifact_name, now.isoformat(), before)
|
|
420
|
+
for label, store in missing:
|
|
421
|
+
try:
|
|
422
|
+
put_with_recovery(store, artifact, manifest)
|
|
423
|
+
print(f'{label}: daily/{manifest.artifact}')
|
|
424
|
+
except Exception as exc:
|
|
425
|
+
print(f'{label}: backup failed ({type(exc).__name__})', file=sys.stderr)
|
|
426
|
+
failed.append(label)
|
|
427
|
+
if failed:
|
|
428
|
+
raise ValueError('daily backup incomplete')
|
|
429
|
+
period_copies(settings, name, now.date(), tmp)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def direct_period_backups(settings: Settings, cfg: MySQLConfig, name: str, tmp: Path, now: datetime) -> None:
|
|
433
|
+
"""Without daily, create one new dump for any due current-period copies."""
|
|
434
|
+
periods = []
|
|
435
|
+
if settings.monthly and now.day >= 3:
|
|
436
|
+
periods.append('monthly')
|
|
437
|
+
if settings.weekly and now.weekday() >= 2:
|
|
438
|
+
periods.append('weekly')
|
|
439
|
+
week_start = now.date() - timedelta(days=now.weekday())
|
|
440
|
+
new_artifact = None
|
|
441
|
+
new_manifest = None
|
|
442
|
+
failures = []
|
|
443
|
+
for period in periods:
|
|
444
|
+
destinations = stores(settings, period)
|
|
445
|
+
existing = []
|
|
446
|
+
missing = []
|
|
447
|
+
for label, store in destinations:
|
|
448
|
+
try:
|
|
449
|
+
candidates = [m for m in list_store(store, settings.project, settings.service)
|
|
450
|
+
if m.artifact.endswith(backup_suffix(settings, name)) and
|
|
451
|
+
(m.timestamp[:7] == now.strftime('%Y-%m') if period == 'monthly'
|
|
452
|
+
else week_start <= date.fromisoformat(m.timestamp[:10]) <= now.date())]
|
|
453
|
+
if candidates:
|
|
454
|
+
existing.append((label, store, candidates[-1]))
|
|
455
|
+
else:
|
|
456
|
+
missing.append((label, store))
|
|
457
|
+
except Exception as exc:
|
|
458
|
+
print(f'{label}: {period} listing failed ({type(exc).__name__})', file=sys.stderr)
|
|
459
|
+
failures.append((period, label))
|
|
460
|
+
if not existing and not missing:
|
|
461
|
+
continue
|
|
462
|
+
if existing:
|
|
463
|
+
manifest = existing[0][2]
|
|
464
|
+
if any(current != manifest for _, _, current in existing):
|
|
465
|
+
raise ValueError(f'{period} destinations disagree about the existing backup')
|
|
466
|
+
artifact = tmp / (period + '-existing-' + name + '.sql.gz')
|
|
467
|
+
get(existing[0][1], manifest, artifact)
|
|
468
|
+
for label, store, current in existing[1:]:
|
|
469
|
+
check = tmp / (period + '-' + label + '-' + name + '.sql.gz')
|
|
470
|
+
get(store, current, check)
|
|
471
|
+
else:
|
|
472
|
+
if new_artifact is None:
|
|
473
|
+
new_artifact = tmp / ('direct-' + name + '.gz')
|
|
474
|
+
before = capture_source(settings, cfg, name, new_artifact, tmp)
|
|
475
|
+
new_manifest = make_manifest(new_artifact, settings.project, settings.service,
|
|
476
|
+
f'{now:%Y-%m-%d}' + backup_suffix(settings, name), now.isoformat(), before)
|
|
477
|
+
artifact, manifest = new_artifact, new_manifest
|
|
478
|
+
for label, store in missing:
|
|
479
|
+
try:
|
|
480
|
+
put_with_recovery(store, artifact, manifest)
|
|
481
|
+
print(f'{label}: {period}/{manifest.artifact}')
|
|
482
|
+
except Exception as exc:
|
|
483
|
+
print(f'{label}: {period} backup failed ({type(exc).__name__})', file=sys.stderr)
|
|
484
|
+
failures.append((period, label))
|
|
485
|
+
if failures:
|
|
486
|
+
raise ValueError('period backup incomplete')
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def prune_local_periods(settings: Settings, now: datetime) -> None:
|
|
490
|
+
retention = settings.local_days
|
|
491
|
+
if not retention or not settings.local_root:
|
|
492
|
+
return
|
|
493
|
+
days = int(retention)
|
|
494
|
+
if days < 1:
|
|
495
|
+
raise ValueError('CLI_BACKUP_LOCAL_DAYS must be positive')
|
|
496
|
+
for period in ('daily', 'weekly', 'monthly'):
|
|
497
|
+
minimum = (14 if settings.weekly or settings.monthly else 0) if period == 'daily' else (8 if period == 'weekly' else 35)
|
|
498
|
+
period_days = max(days, minimum)
|
|
499
|
+
folder = Path(settings.local_root).expanduser() / period / settings.project / settings.service
|
|
500
|
+
if folder.is_dir():
|
|
501
|
+
prune_local(LocalStorage(Path(settings.local_root).expanduser() / period), settings.project, settings.service, period_days, now)
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def main(argv=None) -> int:
|
|
505
|
+
parser = argparse.ArgumentParser(prog='cli-backup scheduled')
|
|
506
|
+
parser.add_argument('action', choices=('config-check', 'backup', 'list', 'verify', 'restore-test', 'restore'))
|
|
507
|
+
parser.add_argument('--period', choices=('daily', 'weekly', 'monthly'), default='daily')
|
|
508
|
+
parser.add_argument('--storage', choices=('local', 's3'), default='local')
|
|
509
|
+
parser.add_argument('--file')
|
|
510
|
+
parser.add_argument('--database')
|
|
511
|
+
parser.add_argument('--project-env', type=Path)
|
|
512
|
+
for option in ('project-name', 'compose-project-name', 'db-container', 'db-service-name',
|
|
513
|
+
'db-host', 'db-port', 'db-name', 'db-user', 'db-path', 'local-root', 's3-bucket', 'cpu-nice', 'container-runtime'):
|
|
514
|
+
parser.add_argument('--' + option)
|
|
515
|
+
args = parser.parse_args(argv)
|
|
516
|
+
try:
|
|
517
|
+
environment = dict(os.environ)
|
|
518
|
+
if args.project_env:
|
|
519
|
+
environment.update(read_project_env(args.project_env))
|
|
520
|
+
for option in ('project_name', 'compose_project_name', 'db_container', 'db_service_name',
|
|
521
|
+
'db_host', 'db_port', 'db_name', 'db_user', 'db_path', 'local_root', 's3_bucket', 'cpu_nice', 'container_runtime'):
|
|
522
|
+
value = getattr(args, option)
|
|
523
|
+
if value is not None:
|
|
524
|
+
environment['CLI_BACKUP_' + option.upper()] = value
|
|
525
|
+
settings = Settings.from_env(environment)
|
|
526
|
+
if args.action == 'config-check':
|
|
527
|
+
print(f'Configuration valid for project {settings.project}')
|
|
528
|
+
return 0
|
|
529
|
+
if args.action == 'backup':
|
|
530
|
+
lower_cpu_priority(settings.cpu_nice)
|
|
531
|
+
if args.action in ('verify', 'restore') and not args.file:
|
|
532
|
+
raise ValueError('--file is required')
|
|
533
|
+
cfg, names, image = (connection(settings) if args.action in ('backup', 'restore')
|
|
534
|
+
else (None, databases(settings.names) if settings.names else [], ''))
|
|
535
|
+
if args.database and names and args.database not in names:
|
|
536
|
+
raise ValueError('selected database is not configured')
|
|
537
|
+
if args.database:
|
|
538
|
+
databases(args.database)
|
|
539
|
+
with tempfile.TemporaryDirectory(prefix='cli-backup-', dir=settings.temp_root or None) as directory:
|
|
540
|
+
tmp = Path(directory)
|
|
541
|
+
if args.action == 'backup':
|
|
542
|
+
failures = []
|
|
543
|
+
for name in names:
|
|
544
|
+
try:
|
|
545
|
+
now = datetime.now().astimezone()
|
|
546
|
+
if settings.daily:
|
|
547
|
+
backup_database(settings, cfg, name, tmp, now)
|
|
548
|
+
else:
|
|
549
|
+
direct_period_backups(settings, cfg, name, tmp, now)
|
|
550
|
+
except Exception as exc:
|
|
551
|
+
print(f'{name}: backup failed ({type(exc).__name__}: {exc})', file=sys.stderr)
|
|
552
|
+
failures.append(name)
|
|
553
|
+
if not failures:
|
|
554
|
+
prune_local_periods(settings, datetime.now().astimezone())
|
|
555
|
+
return 1 if failures else 0
|
|
556
|
+
chosen = dict(stores(settings, args.period)).get(args.storage)
|
|
557
|
+
if chosen is None:
|
|
558
|
+
raise ValueError('storage is not configured')
|
|
559
|
+
items = list_store(chosen, settings.project, settings.service)
|
|
560
|
+
if args.database:
|
|
561
|
+
items = [m for m in items if m.artifact.endswith(backup_suffix(settings, args.database))]
|
|
562
|
+
if args.action == 'list':
|
|
563
|
+
for item in items:
|
|
564
|
+
print(f'{item.artifact}\t{item.timestamp}\t{item.size}\t{item.sha256}')
|
|
565
|
+
return 0
|
|
566
|
+
if args.file:
|
|
567
|
+
manifest = next((m for m in items if m.artifact == args.file), None)
|
|
568
|
+
else:
|
|
569
|
+
if not args.database:
|
|
570
|
+
suffix = '.sqlite.gz' if settings.service == 'sqlite' else '.sql.gz'
|
|
571
|
+
available_names = {match.group(1) for item in items
|
|
572
|
+
if (match := re.fullmatch(r'\d{4}-\d{2}-\d{2}\.([A-Za-z0-9_][A-Za-z0-9_-]{0,63})' + re.escape(suffix), item.artifact))}
|
|
573
|
+
if len(names) > 1 or (not names and len(available_names) > 1):
|
|
574
|
+
raise ValueError('--database is required when multiple databases are available')
|
|
575
|
+
if len(names) == 1:
|
|
576
|
+
items = [item for item in items if item.artifact.endswith(backup_suffix(settings, names[0]))]
|
|
577
|
+
manifest = items[-1] if items else None
|
|
578
|
+
if manifest is None:
|
|
579
|
+
raise ValueError('backup not found')
|
|
580
|
+
suffix = '.sqlite.gz' if settings.service == 'sqlite' else '.sql.gz'
|
|
581
|
+
match = re.fullmatch(r'\d{4}-\d{2}-\d{2}\.([A-Za-z0-9_][A-Za-z0-9_-]{0,63})' + re.escape(suffix), manifest.artifact)
|
|
582
|
+
if not match:
|
|
583
|
+
raise ValueError('invalid scheduled backup filename')
|
|
584
|
+
selected = match.group(1)
|
|
585
|
+
if names and selected not in names:
|
|
586
|
+
raise ValueError('selected database is not configured')
|
|
587
|
+
artifact = tmp / manifest.artifact
|
|
588
|
+
get(chosen, manifest, artifact)
|
|
589
|
+
if args.action == 'verify':
|
|
590
|
+
print('SHA-256 verified')
|
|
591
|
+
return 0
|
|
592
|
+
if settings.service == 'sqlite':
|
|
593
|
+
raise ValueError('SQLite restoration is manual; use the verified backup file')
|
|
594
|
+
plain = tmp / 'restore.sql'
|
|
595
|
+
with gzip.open(artifact, 'rb') as source, plain.open('xb') as output:
|
|
596
|
+
shutil.copyfileobj(source, output, 1024 * 1024)
|
|
597
|
+
if args.action == 'restore-test':
|
|
598
|
+
if not settings.test_image and settings.container:
|
|
599
|
+
_, _, image = container_info(settings)
|
|
600
|
+
restored = rehearse_mysql(plain, settings.test_image or image, settings.service,
|
|
601
|
+
runtime=settings.container_runtime)
|
|
602
|
+
if restored != manifest.object_counts:
|
|
603
|
+
raise ValueError('restored object counts do not match manifest')
|
|
604
|
+
print(f'object counts match: {restored}')
|
|
605
|
+
else:
|
|
606
|
+
if not args.file:
|
|
607
|
+
raise ValueError('--file is required for live restore')
|
|
608
|
+
if not sys.stdin.isatty() or input(f'Restore {selected} in {settings.project}? Type yes: ') != 'yes':
|
|
609
|
+
raise ValueError('restore not confirmed')
|
|
610
|
+
database = MySQLConfig(**{**vars(cfg), 'database': selected})
|
|
611
|
+
with plain.open('rb') as source:
|
|
612
|
+
restore_mysql(database, source)
|
|
613
|
+
return 0
|
|
614
|
+
except (ValueError, OSError, subprocess.SubprocessError, KeyError, TypeError) as exc:
|
|
615
|
+
print(f'cli-backup: {exc}', file=sys.stderr)
|
|
616
|
+
return 2
|