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