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,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)