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/rehearsal.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Disposable database restore rehearsals; no published ports or shared networks."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import secrets
|
|
6
|
+
import subprocess
|
|
7
|
+
import time
|
|
8
|
+
import uuid
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from .mysql import OBJECT_COUNTS_SQL
|
|
11
|
+
import re
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def run(args: list[str], *, timeout: int = 30, **kwargs):
|
|
15
|
+
return subprocess.run(args, check=True, timeout=timeout, **kwargs)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def wait_ready(command: list[str], env: dict[str, str], timeout: int = 90) -> None:
|
|
19
|
+
deadline = time.monotonic() + timeout
|
|
20
|
+
while time.monotonic() < deadline:
|
|
21
|
+
try:
|
|
22
|
+
run(command, env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
23
|
+
return
|
|
24
|
+
except subprocess.CalledProcessError:
|
|
25
|
+
time.sleep(1)
|
|
26
|
+
raise TimeoutError('disposable database did not become ready')
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def rehearse_mysql(artifact: Path, image: str, client: str = 'mariadb', import_timeout: int = 14400, *, runtime: str = 'docker') -> str:
|
|
30
|
+
if import_timeout < 1 or not image or image.startswith('-') or client not in ('mysql', 'mariadb') or runtime not in ('docker', 'podman'):
|
|
31
|
+
raise ValueError('test image and client are required')
|
|
32
|
+
name = 'cli-backup-rehearsal-' + uuid.uuid4().hex
|
|
33
|
+
password = secrets.token_hex(24)
|
|
34
|
+
env = dict(os.environ, MYSQL_ROOT_PASSWORD=password, MYSQL_DATABASE='backup_rehearsal', MYSQL_PWD=password)
|
|
35
|
+
started = False
|
|
36
|
+
try:
|
|
37
|
+
run([runtime, 'create', '--rm', '--cpus', os.environ.get('CLI_BACKUP_TEST_CPUS', '2'),
|
|
38
|
+
'--memory', os.environ.get('CLI_BACKUP_TEST_MEMORY', '3g'), '--name', name, '--network', 'none',
|
|
39
|
+
'-e', 'MYSQL_ROOT_PASSWORD', '-e', 'MYSQL_DATABASE', image], env=env, stdout=subprocess.DEVNULL)
|
|
40
|
+
started = True
|
|
41
|
+
run([runtime, 'start', name], env=env, stdout=subprocess.DEVNULL)
|
|
42
|
+
wait_ready([runtime, 'exec', '-e', 'MYSQL_PWD', name, client, '-u', 'root', '-e', 'SELECT 1'], env)
|
|
43
|
+
with artifact.open('rb') as source:
|
|
44
|
+
run([runtime, 'exec', '-i', '-e', 'MYSQL_PWD', name, client, '-u', 'root', 'backup_rehearsal'],
|
|
45
|
+
env=env, stdin=source, timeout=import_timeout)
|
|
46
|
+
result = run([runtime, 'exec', '-e', 'MYSQL_PWD', name, client, '-u', 'root', 'backup_rehearsal',
|
|
47
|
+
'-N', '-B', '-e', OBJECT_COUNTS_SQL], env=env, capture_output=True, text=True)
|
|
48
|
+
counts = result.stdout.strip()
|
|
49
|
+
if not re.fullmatch(r'\d+(?:\s+\d+){4}', counts):
|
|
50
|
+
raise ValueError('invalid object counts from restored database')
|
|
51
|
+
return counts
|
|
52
|
+
finally:
|
|
53
|
+
if started:
|
|
54
|
+
run([runtime, 'rm', '-f', '-v', name], env=env, stdout=subprocess.DEVNULL)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def rehearse_redis(artifact: Path, image: str, server: str = 'redis-server') -> None:
|
|
58
|
+
if server not in ('redis-server', 'keydb-server'):
|
|
59
|
+
raise ValueError('unsupported Redis server')
|
|
60
|
+
if not image or image.startswith('-'):
|
|
61
|
+
raise ValueError('test image is required')
|
|
62
|
+
name = 'cli-backup-rehearsal-' + uuid.uuid4().hex
|
|
63
|
+
started = False
|
|
64
|
+
info = subprocess.run(['docker', 'info', '--format', '{{json .SecurityOptions}}'], check=True, capture_output=True, text=True, timeout=30).stdout
|
|
65
|
+
user = '0:0' if 'name=rootless' in info else f'{os.getuid()}:{os.getgid()}'
|
|
66
|
+
try:
|
|
67
|
+
run(['docker', 'create', '--rm', '--cpus', os.environ.get('CLI_BACKUP_TEST_CPUS', '2'),
|
|
68
|
+
'--memory', os.environ.get('CLI_BACKUP_TEST_MEMORY', '3g'), '--name', name, '--network', 'none',
|
|
69
|
+
'--user', user,
|
|
70
|
+
'--mount', f'type=bind,src={artifact.resolve()},dst=/data/dump.rdb,readonly',
|
|
71
|
+
'--entrypoint', server, image, '--dir', '/data', '--dbfilename', 'dump.rdb',
|
|
72
|
+
'--save', '', '--appendonly', 'no'], stdout=subprocess.DEVNULL)
|
|
73
|
+
started = True
|
|
74
|
+
run(['docker', 'start', name], stdout=subprocess.DEVNULL)
|
|
75
|
+
wait_ready(['docker', 'exec', name, 'redis-cli', 'ping'], dict(os.environ))
|
|
76
|
+
run(['docker', 'exec', name, 'redis-cli', 'DBSIZE'])
|
|
77
|
+
finally:
|
|
78
|
+
if started:
|
|
79
|
+
run(['docker', 'rm', '-f', '-v', name], stdout=subprocess.DEVNULL)
|