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/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Backup CLI."""
|
cli_backup/cli.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Command-line entry point for backup and recovery."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
import uuid
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from .config import load_config, read_project_env, resolve
|
|
14
|
+
from .cpu_priority import lower_cpu_priority, validate_cpu_nice
|
|
15
|
+
from .files import capture_directory, extract_archive
|
|
16
|
+
from .mysql import MySQLConfig, capture_mysql, count_mysql_objects, restore_mysql
|
|
17
|
+
from .redis_backup import capture_redis
|
|
18
|
+
from .rehearsal import rehearse_mysql, rehearse_redis
|
|
19
|
+
from .storage import LocalStorage, S3Storage, local_get, make_manifest, prune_local, s3_get, s3_list, write_private_copy
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def parser_build() -> argparse.ArgumentParser:
|
|
23
|
+
parser = argparse.ArgumentParser(prog='cli-backup')
|
|
24
|
+
parser.add_argument('--project-env', type=Path)
|
|
25
|
+
parser.add_argument('--project')
|
|
26
|
+
parser.add_argument('--local-root')
|
|
27
|
+
parser.add_argument('--temp-root')
|
|
28
|
+
parser.add_argument('--s3-bucket')
|
|
29
|
+
parser.add_argument('--s3-endpoint')
|
|
30
|
+
parser.add_argument('--s3-region')
|
|
31
|
+
parser.add_argument('--s3-prefix')
|
|
32
|
+
parser.add_argument('--compose-env-file', action='append', default=[])
|
|
33
|
+
for option in ('db-mode', 'db-name', 'db-user', 'db-host', 'db-port', 'db-service',
|
|
34
|
+
'db-network', 'db-image', 'db-client', 'compose-dir', 'redis-host',
|
|
35
|
+
'redis-port', 'redis-timeout', 'local-days', 'cpu-nice', 'test-mysql-image', 'test-redis-image', 'test-redis-server', 'restore-timeout'):
|
|
36
|
+
parser.add_argument('--' + option)
|
|
37
|
+
sub = parser.add_subparsers(dest='command', required=True)
|
|
38
|
+
sub.add_parser('scheduled', help='scheduled database backups; run cli-backup scheduled --help')
|
|
39
|
+
sub.add_parser('config-check')
|
|
40
|
+
publish = sub.add_parser('publish-local')
|
|
41
|
+
publish.add_argument('source', choices=['files', 'mysql', 'redis'])
|
|
42
|
+
publish.add_argument('--file', required=True)
|
|
43
|
+
backup = sub.add_parser('backup')
|
|
44
|
+
backup.add_argument('source', choices=['files', 'mysql', 'redis'])
|
|
45
|
+
backup.add_argument('--directory', type=Path)
|
|
46
|
+
backup.add_argument('--exclude', action='append', default=[])
|
|
47
|
+
for name in ('list', 'verify', 'restore', 'restore-test', 'download'):
|
|
48
|
+
item = sub.add_parser(name)
|
|
49
|
+
item.add_argument('source', choices=['files', 'mysql', 'redis'])
|
|
50
|
+
item.add_argument('--storage', choices=['local', 's3'], default='local')
|
|
51
|
+
if name != 'list':
|
|
52
|
+
item.add_argument('--file', required=(name != 'restore-test'))
|
|
53
|
+
if name == 'download':
|
|
54
|
+
item.add_argument('--destination', type=Path, required=True)
|
|
55
|
+
if name == 'restore':
|
|
56
|
+
item.add_argument('--destination', type=Path)
|
|
57
|
+
item.add_argument('--target', help='explicit database name')
|
|
58
|
+
return parser
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def main(argv: list[str] | None = None) -> int:
|
|
62
|
+
argv = sys.argv[1:] if argv is None else argv
|
|
63
|
+
if argv and argv[0] == 'scheduled':
|
|
64
|
+
from .scheduled import main as scheduled_main
|
|
65
|
+
return scheduled_main(argv[1:])
|
|
66
|
+
args = parser_build().parse_args(argv)
|
|
67
|
+
try:
|
|
68
|
+
project_env = read_project_env(args.project_env) if args.project_env else {}
|
|
69
|
+
settings = vars(args)
|
|
70
|
+
config = load_config(os.environ, project_env, settings)
|
|
71
|
+
def setting(name: str, default: str = '') -> str:
|
|
72
|
+
return resolve(name, settings.get(name), project_env, os.environ) or default
|
|
73
|
+
cpu_nice = validate_cpu_nice(setting('cpu_nice', '10'))
|
|
74
|
+
if args.command == 'backup':
|
|
75
|
+
lower_cpu_priority(cpu_nice)
|
|
76
|
+
if args.command == 'config-check':
|
|
77
|
+
print(f'Configuration valid for project {config.project}')
|
|
78
|
+
return 0
|
|
79
|
+
stores = {}
|
|
80
|
+
if config.local_root:
|
|
81
|
+
stores['local'] = LocalStorage(Path(config.local_root).expanduser())
|
|
82
|
+
if config.s3_bucket and (args.command in ('backup', 'publish-local') or args.storage == 's3'):
|
|
83
|
+
def s3_factory():
|
|
84
|
+
import boto3
|
|
85
|
+
from botocore.config import Config
|
|
86
|
+
client = boto3.client('s3', endpoint_url=setting('s3_endpoint') or None,
|
|
87
|
+
region_name=setting('s3_region') or None, config=Config(s3={'addressing_style': 'path'}),
|
|
88
|
+
aws_access_key_id=setting('s3_key') or None,
|
|
89
|
+
aws_secret_access_key=setting('s3_secret') or None)
|
|
90
|
+
return S3Storage(client, config.s3_bucket, setting('s3_prefix'))
|
|
91
|
+
stores['s3'] = s3_factory
|
|
92
|
+
def mysql_config(database: str | None = None) -> MySQLConfig:
|
|
93
|
+
return MySQLConfig(mode=setting('db_mode', 'compose'), database=database or setting('db_name'),
|
|
94
|
+
user=setting('db_user'), password=setting('db_password'), host=setting('db_host', '127.0.0.1'),
|
|
95
|
+
port=int(setting('db_port', '3306')), service=setting('db_service', 'mariadb'),
|
|
96
|
+
network=setting('db_network'), image=setting('db_image'), compose_dir=setting('compose_dir', '.'),
|
|
97
|
+
client=setting('db_client', 'mariadb'), compose_env_files=tuple(args.compose_env_file), cpu_nice=cpu_nice)
|
|
98
|
+
if args.command == 'publish-local':
|
|
99
|
+
if 'local' not in stores or 's3' not in stores:
|
|
100
|
+
raise ValueError('local and S3 storage are required')
|
|
101
|
+
local = stores['local']
|
|
102
|
+
manifest = next((item for item in local.list(config.project, args.source) if item.artifact == args.file), None)
|
|
103
|
+
if manifest is None or not local.verify(manifest):
|
|
104
|
+
raise ValueError('local backup missing or checksum mismatch')
|
|
105
|
+
remote = stores['s3']()
|
|
106
|
+
if not remote.verify(manifest):
|
|
107
|
+
raise ValueError('remote backup checksum mismatch')
|
|
108
|
+
remote.client.put_object(Bucket=remote.bucket, Key=remote.key(manifest) + '.json',
|
|
109
|
+
Body=manifest.encode(), IfNoneMatch='*')
|
|
110
|
+
print(f's3: {manifest.artifact}')
|
|
111
|
+
return 0
|
|
112
|
+
temp_root = setting('temp_root')
|
|
113
|
+
with tempfile.TemporaryDirectory(prefix='cli-backup-', dir=str(Path(temp_root).expanduser().absolute()) if temp_root else None) as tmp:
|
|
114
|
+
artifact = Path(tmp) / 'artifact'
|
|
115
|
+
if args.command == 'backup':
|
|
116
|
+
retention = setting('local_days')
|
|
117
|
+
if retention and int(retention) < 1:
|
|
118
|
+
raise ValueError('local retention days must be positive')
|
|
119
|
+
object_counts = None
|
|
120
|
+
if args.source == 'files':
|
|
121
|
+
if not args.directory:
|
|
122
|
+
raise ValueError('--directory is required for file backup')
|
|
123
|
+
capture_directory(args.directory, args.exclude, artifact)
|
|
124
|
+
elif args.source == 'mysql':
|
|
125
|
+
database = mysql_config()
|
|
126
|
+
object_counts = count_mysql_objects(database)
|
|
127
|
+
with artifact.open('xb') as output:
|
|
128
|
+
capture_mysql(database, output)
|
|
129
|
+
if count_mysql_objects(database) != object_counts:
|
|
130
|
+
raise ValueError('source schema object counts changed during dump')
|
|
131
|
+
else:
|
|
132
|
+
capture_redis(artifact, setting('redis_host', '127.0.0.1'), int(setting('redis_port', '6379')), setting('redis_password') or None, timeout=int(setting('redis_timeout', '14400')))
|
|
133
|
+
if artifact.stat().st_size == 0:
|
|
134
|
+
raise ValueError('source produced an empty backup')
|
|
135
|
+
now = datetime.now(timezone.utc)
|
|
136
|
+
name = f'{now:%Y%m%dT%H%M%SZ}-{uuid.uuid4().hex}.bin'
|
|
137
|
+
manifest = make_manifest(artifact, config.project, args.source, name, now.isoformat(), object_counts)
|
|
138
|
+
failures = []
|
|
139
|
+
for label, store in stores.items():
|
|
140
|
+
try:
|
|
141
|
+
destination = store() if label == 's3' else store
|
|
142
|
+
destination.put(artifact, manifest)
|
|
143
|
+
print(f'{label}: {name}')
|
|
144
|
+
except Exception as exc:
|
|
145
|
+
print(f'{label}: backup failed ({type(exc).__name__})', file=sys.stderr)
|
|
146
|
+
failures.append(label)
|
|
147
|
+
if failures:
|
|
148
|
+
return 1
|
|
149
|
+
if retention and 'local' in stores:
|
|
150
|
+
removed = prune_local(stores['local'], config.project, args.source, int(retention), now)
|
|
151
|
+
print(f'local: removed {removed} expired backups')
|
|
152
|
+
return 0
|
|
153
|
+
if args.storage not in stores:
|
|
154
|
+
raise ValueError(f'{args.storage} storage is not configured')
|
|
155
|
+
store = stores[args.storage]
|
|
156
|
+
if args.storage == 's3':
|
|
157
|
+
store = store()
|
|
158
|
+
manifests = store.list(config.project, args.source) if args.storage == 'local' else s3_list(store, config.project, args.source)
|
|
159
|
+
if args.command == 'list':
|
|
160
|
+
for item in manifests:
|
|
161
|
+
print(f'{item.artifact}\t{item.timestamp}\t{item.size}\t{item.sha256}')
|
|
162
|
+
return 0
|
|
163
|
+
manifest = (next((item for item in manifests if item.artifact == args.file), None)
|
|
164
|
+
if args.file else (manifests[-1] if manifests else None))
|
|
165
|
+
if manifest is None:
|
|
166
|
+
raise ValueError('backup not found')
|
|
167
|
+
with artifact.open('xb') as output:
|
|
168
|
+
(local_get if args.storage == 'local' else s3_get)(store, manifest, output)
|
|
169
|
+
if args.command == 'verify':
|
|
170
|
+
print('SHA-256 verified against downloaded bytes' if args.storage == 's3' else 'SHA-256 verified')
|
|
171
|
+
elif args.command == 'download':
|
|
172
|
+
with artifact.open('rb') as source:
|
|
173
|
+
write_private_copy(source, args.destination)
|
|
174
|
+
elif args.command == 'restore-test':
|
|
175
|
+
if args.source == 'mysql':
|
|
176
|
+
restored = rehearse_mysql(artifact, setting('test_mysql_image'), setting('db_client', 'mariadb'), int(setting('restore_timeout', '14400')))
|
|
177
|
+
expected = manifest.object_counts or count_mysql_objects(mysql_config())
|
|
178
|
+
if expected != restored:
|
|
179
|
+
raise ValueError(f'object counts differ: expected={expected}, restored={restored}')
|
|
180
|
+
print(f'object counts match: {restored}')
|
|
181
|
+
elif args.source == 'redis':
|
|
182
|
+
rehearse_redis(artifact, setting('test_redis_image'), setting('test_redis_server', 'redis-server'))
|
|
183
|
+
else:
|
|
184
|
+
raise ValueError('restore-test supports mysql and redis')
|
|
185
|
+
elif args.source == 'files':
|
|
186
|
+
if not args.destination:
|
|
187
|
+
raise ValueError('--destination is required')
|
|
188
|
+
extract_archive(artifact, args.destination)
|
|
189
|
+
print('Extracted; original ownership is not restored', file=sys.stderr)
|
|
190
|
+
elif args.source == 'mysql':
|
|
191
|
+
if not args.target:
|
|
192
|
+
raise ValueError('--target is required')
|
|
193
|
+
if not sys.stdin.isatty() or input(f'Restore into database {args.target}? Type yes: ') != 'yes':
|
|
194
|
+
raise ValueError('restore not confirmed')
|
|
195
|
+
with artifact.open('rb') as source:
|
|
196
|
+
restore_mysql(mysql_config(args.target), source)
|
|
197
|
+
else:
|
|
198
|
+
raise ValueError('live Redis restore is manual; use a verified RDB snapshot')
|
|
199
|
+
return 0
|
|
200
|
+
except (OSError, ValueError, subprocess.SubprocessError) as exc:
|
|
201
|
+
print(f'cli-backup: {exc}', file=sys.stderr)
|
|
202
|
+
return 2
|
|
203
|
+
except Exception as exc:
|
|
204
|
+
print(f'cli-backup: operation failed ({type(exc).__name__})', file=sys.stderr)
|
|
205
|
+
return 2
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
if __name__ == '__main__':
|
|
209
|
+
raise SystemExit(main())
|
cli_backup/config.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Configuration resolution without executing project files."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import shlex
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
PREFIX = "CLI_BACKUP_"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def resolve(name: str, argument: str | None, project: Mapping[str, str], global_env: Mapping[str, str] | None = None) -> str | None:
|
|
15
|
+
key = f"{PREFIX}{name.upper()}"
|
|
16
|
+
env = os.environ if global_env is None else global_env
|
|
17
|
+
return argument if argument is not None else project.get(key, env.get(key))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def validate_project(project: str) -> str:
|
|
21
|
+
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]{0,63}", project):
|
|
22
|
+
raise ValueError("invalid project identifier")
|
|
23
|
+
return project
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def read_project_env(path: Path) -> dict[str, str]:
|
|
27
|
+
"""Read literal CLI_BACKUP assignments; no expansion or shell execution."""
|
|
28
|
+
result: dict[str, str] = {}
|
|
29
|
+
for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
|
|
30
|
+
line = line.strip()
|
|
31
|
+
if not line or line.startswith("#"):
|
|
32
|
+
continue
|
|
33
|
+
if line.startswith("export "):
|
|
34
|
+
line = line[7:].lstrip()
|
|
35
|
+
key, sep, value = line.partition("=")
|
|
36
|
+
key = key.strip()
|
|
37
|
+
if not key.startswith(PREFIX):
|
|
38
|
+
continue
|
|
39
|
+
if not sep or not re.fullmatch(r"CLI_BACKUP_[A-Z0-9_]+", key):
|
|
40
|
+
raise ValueError(f"invalid project setting at line {number}")
|
|
41
|
+
try:
|
|
42
|
+
tokens = shlex.split(value, comments=True, posix=True)
|
|
43
|
+
except ValueError as exc:
|
|
44
|
+
raise ValueError(f"invalid project setting at line {number}") from exc
|
|
45
|
+
if len(tokens) > 1:
|
|
46
|
+
raise ValueError(f"invalid project setting at line {number}")
|
|
47
|
+
if key in result:
|
|
48
|
+
raise ValueError(f"duplicate project setting at line {number}")
|
|
49
|
+
result[key] = tokens[0] if tokens else ""
|
|
50
|
+
return result
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class Config:
|
|
55
|
+
project: str
|
|
56
|
+
local_root: str | None
|
|
57
|
+
s3_bucket: str | None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def load_config(global_env: Mapping[str, str], project_env: Mapping[str, str], args: Mapping[str, str | None]) -> Config:
|
|
61
|
+
project = resolve("project", args.get("project"), project_env, global_env)
|
|
62
|
+
if not project:
|
|
63
|
+
raise ValueError("CLI_BACKUP_PROJECT is required")
|
|
64
|
+
root = resolve("local_root", args.get("local_root"), project_env, global_env) or None
|
|
65
|
+
bucket = resolve("s3_bucket", args.get("s3_bucket"), project_env, global_env) or None
|
|
66
|
+
if not root and not bucket:
|
|
67
|
+
raise ValueError("configure CLI_BACKUP_LOCAL_ROOT or CLI_BACKUP_S3_BUCKET")
|
|
68
|
+
return Config(validate_project(project), root, bucket)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Lower backup CPU scheduling priority without raising an inherited value."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def validate_cpu_nice(value: str | int) -> int:
|
|
8
|
+
try:
|
|
9
|
+
parsed = int(value)
|
|
10
|
+
except (TypeError, ValueError) as exc:
|
|
11
|
+
raise ValueError('CLI_BACKUP_CPU_NICE must be an integer from 0 to 19') from exc
|
|
12
|
+
if not 0 <= parsed <= 19:
|
|
13
|
+
raise ValueError('CLI_BACKUP_CPU_NICE must be an integer from 0 to 19')
|
|
14
|
+
return parsed
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def lower_cpu_priority(value: int) -> None:
|
|
18
|
+
priority = validate_cpu_nice(value)
|
|
19
|
+
current = os.getpriority(os.PRIO_PROCESS, 0)
|
|
20
|
+
if current < priority:
|
|
21
|
+
os.setpriority(os.PRIO_PROCESS, 0, priority)
|
cli_backup/files.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""Full directory archives and guarded POSIX extraction."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import fnmatch
|
|
5
|
+
import os
|
|
6
|
+
import posixpath
|
|
7
|
+
import shutil
|
|
8
|
+
import stat
|
|
9
|
+
import tarfile
|
|
10
|
+
from contextlib import contextmanager
|
|
11
|
+
from collections import deque
|
|
12
|
+
from pathlib import Path, PurePosixPath
|
|
13
|
+
from typing import Iterator, BinaryIO
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _validate_links(links: dict[str, str]) -> None:
|
|
17
|
+
"""Resolve every archive link as the kernel would, without leaving its root."""
|
|
18
|
+
for name, target in links.items():
|
|
19
|
+
if not target or target.startswith('/'):
|
|
20
|
+
raise ValueError(f'unsafe archive link: {name}')
|
|
21
|
+
parent = PurePosixPath(name).parent
|
|
22
|
+
stack = [] if str(parent) == '.' else list(parent.parts)
|
|
23
|
+
pending = deque(target.split('/'))
|
|
24
|
+
traversals = 0
|
|
25
|
+
while pending:
|
|
26
|
+
part = pending.popleft()
|
|
27
|
+
if not part or part == '.':
|
|
28
|
+
continue
|
|
29
|
+
if part == '..':
|
|
30
|
+
if not stack:
|
|
31
|
+
raise ValueError(f'unsafe archive link: {name}')
|
|
32
|
+
stack.pop()
|
|
33
|
+
continue
|
|
34
|
+
stack.append(part)
|
|
35
|
+
candidate = '/'.join(stack)
|
|
36
|
+
if candidate in links:
|
|
37
|
+
traversals += 1
|
|
38
|
+
if traversals > 40:
|
|
39
|
+
raise ValueError(f'cyclic archive link: {name}')
|
|
40
|
+
next_target = links[candidate]
|
|
41
|
+
if not next_target or next_target.startswith('/'):
|
|
42
|
+
raise ValueError(f'unsafe archive link: {name}')
|
|
43
|
+
stack.pop()
|
|
44
|
+
pending.extendleft(reversed(next_target.split('/')))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def capture_directory(path: Path, patterns: list[str], output: Path) -> None:
|
|
48
|
+
root = path.resolve(strict=True)
|
|
49
|
+
if not root.is_dir():
|
|
50
|
+
raise ValueError('source must be a directory')
|
|
51
|
+
if output.resolve().is_relative_to(root):
|
|
52
|
+
raise ValueError('archive must be outside source')
|
|
53
|
+
def excluded(name: str) -> bool:
|
|
54
|
+
return any(fnmatch.fnmatchcase(name, pattern) for pattern in patterns)
|
|
55
|
+
def walk_error(error: OSError) -> None:
|
|
56
|
+
raise error
|
|
57
|
+
# tarfile silently skips sockets: reject them and any unsupported metadata
|
|
58
|
+
# before creating an archive that our extractor could not restore.
|
|
59
|
+
source_links: dict[str, str] = {}
|
|
60
|
+
for current, directories, filenames in os.walk(root, followlinks=False, onerror=walk_error):
|
|
61
|
+
kept_dirs = []
|
|
62
|
+
for name in directories + filenames:
|
|
63
|
+
item = Path(current) / name
|
|
64
|
+
relative = item.relative_to(root).as_posix()
|
|
65
|
+
if excluded(relative):
|
|
66
|
+
continue
|
|
67
|
+
mode = item.lstat().st_mode
|
|
68
|
+
if stat.S_ISLNK(mode):
|
|
69
|
+
source_links[relative] = os.readlink(item)
|
|
70
|
+
elif not (stat.S_ISREG(mode) or stat.S_ISDIR(mode)):
|
|
71
|
+
raise ValueError(f'unsupported source file type: {relative}')
|
|
72
|
+
if name in directories:
|
|
73
|
+
kept_dirs.append(name)
|
|
74
|
+
directories[:] = kept_dirs
|
|
75
|
+
_validate_links(source_links)
|
|
76
|
+
archived_links: dict[str, str] = {}
|
|
77
|
+
def include(info: tarfile.TarInfo) -> tarfile.TarInfo | None:
|
|
78
|
+
name = info.name.removeprefix('./')
|
|
79
|
+
if excluded(name):
|
|
80
|
+
return None
|
|
81
|
+
if info.islnk():
|
|
82
|
+
# Preserve file contents without creating archive hard links.
|
|
83
|
+
info.type = tarfile.REGTYPE
|
|
84
|
+
info.size = (root / name).stat(follow_symlinks=False).st_size
|
|
85
|
+
info.linkname = ''
|
|
86
|
+
if info.issym():
|
|
87
|
+
archived_links[posixpath.normpath(name)] = info.linkname
|
|
88
|
+
elif not (info.isfile() or info.isdir()):
|
|
89
|
+
raise ValueError(f'unsupported source file type: {name}')
|
|
90
|
+
return info
|
|
91
|
+
with tarfile.open(output, 'x:gz', dereference=False) as archive:
|
|
92
|
+
archive.add(root, arcname='.', recursive=True, filter=include)
|
|
93
|
+
_validate_links(archived_links)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@contextmanager
|
|
97
|
+
def open_archive(path: Path) -> Iterator[BinaryIO]:
|
|
98
|
+
"""Open an absolute regular file by no-follow descriptor traversal."""
|
|
99
|
+
text = os.fspath(path)
|
|
100
|
+
if not text.startswith('/') or text.startswith('//') or '\x00' in text or '//' in text:
|
|
101
|
+
raise ValueError('archive path must be an absolute local path')
|
|
102
|
+
parts = text.split('/')[1:]
|
|
103
|
+
if not parts or any(part in ('', '.', '..') for part in parts):
|
|
104
|
+
raise ValueError('invalid archive path')
|
|
105
|
+
if not hasattr(os, 'O_NOFOLLOW'):
|
|
106
|
+
raise ValueError('safe archive opening requires O_NOFOLLOW')
|
|
107
|
+
fd = os.open('/', os.O_RDONLY | os.O_DIRECTORY)
|
|
108
|
+
try:
|
|
109
|
+
for part in parts[:-1]:
|
|
110
|
+
next_fd = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=fd)
|
|
111
|
+
os.close(fd)
|
|
112
|
+
fd = next_fd
|
|
113
|
+
archive_fd = os.open(parts[-1], os.O_RDONLY | os.O_NOFOLLOW, dir_fd=fd)
|
|
114
|
+
try:
|
|
115
|
+
if not stat.S_ISREG(os.fstat(archive_fd).st_mode):
|
|
116
|
+
raise ValueError('archive must be a regular file')
|
|
117
|
+
with os.fdopen(archive_fd, 'rb', closefd=False) as stream:
|
|
118
|
+
yield stream
|
|
119
|
+
finally:
|
|
120
|
+
os.close(archive_fd)
|
|
121
|
+
finally:
|
|
122
|
+
os.close(fd)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def extract_archive(artifact: Path, destination: Path) -> None:
|
|
126
|
+
"""Extract into a new directory, using no-follow file descriptors throughout."""
|
|
127
|
+
if not destination.is_absolute() or destination.name in ('', '.', '..'):
|
|
128
|
+
raise ValueError('destination must be an absolute new directory')
|
|
129
|
+
with open_archive(artifact) as stream, tarfile.open(fileobj=stream, mode='r:gz') as archive:
|
|
130
|
+
members = archive.getmembers()
|
|
131
|
+
names: set[str] = set()
|
|
132
|
+
links: dict[str, str] = {}
|
|
133
|
+
for item in members:
|
|
134
|
+
raw = item.name
|
|
135
|
+
name = posixpath.normpath(raw)
|
|
136
|
+
if raw.startswith('/') or '..' in PurePosixPath(raw).parts or name == '..' or name.startswith('../') or not (item.isfile() or item.isdir() or item.issym()):
|
|
137
|
+
raise ValueError('unsafe archive member')
|
|
138
|
+
if name in names:
|
|
139
|
+
raise ValueError('duplicate archive member')
|
|
140
|
+
names.add(name)
|
|
141
|
+
if item.issym():
|
|
142
|
+
links[name] = item.linkname
|
|
143
|
+
_validate_links(links)
|
|
144
|
+
for name in names:
|
|
145
|
+
if any(str(parent) in links for parent in PurePosixPath(name).parents):
|
|
146
|
+
raise ValueError('member beneath symlink')
|
|
147
|
+
|
|
148
|
+
parent_fd = os.open('/', os.O_RDONLY | os.O_DIRECTORY)
|
|
149
|
+
try:
|
|
150
|
+
for part in destination.parent.parts[1:]:
|
|
151
|
+
child = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=parent_fd)
|
|
152
|
+
os.close(parent_fd)
|
|
153
|
+
parent_fd = child
|
|
154
|
+
os.mkdir(destination.name, 0o700, dir_fd=parent_fd)
|
|
155
|
+
root_fd = os.open(destination.name, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=parent_fd)
|
|
156
|
+
finally:
|
|
157
|
+
os.close(parent_fd)
|
|
158
|
+
try:
|
|
159
|
+
def parent_for(name: str) -> tuple[int, str]:
|
|
160
|
+
parts = PurePosixPath(name).parts
|
|
161
|
+
fd = os.dup(root_fd)
|
|
162
|
+
try:
|
|
163
|
+
for part in parts[:-1]:
|
|
164
|
+
try:
|
|
165
|
+
os.mkdir(part, 0o700, dir_fd=fd)
|
|
166
|
+
except FileExistsError:
|
|
167
|
+
pass
|
|
168
|
+
child = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=fd)
|
|
169
|
+
os.close(fd)
|
|
170
|
+
fd = child
|
|
171
|
+
return fd, parts[-1]
|
|
172
|
+
except Exception:
|
|
173
|
+
os.close(fd)
|
|
174
|
+
raise
|
|
175
|
+
|
|
176
|
+
directories: list[tuple[str, int]] = []
|
|
177
|
+
for item in members:
|
|
178
|
+
name = posixpath.normpath(item.name)
|
|
179
|
+
if name == '.':
|
|
180
|
+
if not item.isdir():
|
|
181
|
+
raise ValueError('invalid archive root')
|
|
182
|
+
continue
|
|
183
|
+
fd, basename = parent_for(name)
|
|
184
|
+
try:
|
|
185
|
+
if item.isdir():
|
|
186
|
+
try:
|
|
187
|
+
os.mkdir(basename, 0o700, dir_fd=fd)
|
|
188
|
+
except FileExistsError:
|
|
189
|
+
check = os.open(basename, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=fd)
|
|
190
|
+
os.close(check)
|
|
191
|
+
directories.append((name, item.mode & 0o777))
|
|
192
|
+
elif item.issym():
|
|
193
|
+
os.symlink(item.linkname, basename, dir_fd=fd)
|
|
194
|
+
else:
|
|
195
|
+
target = os.open(basename, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600, dir_fd=fd)
|
|
196
|
+
with os.fdopen(target, 'wb') as out:
|
|
197
|
+
source = archive.extractfile(item)
|
|
198
|
+
if source is None:
|
|
199
|
+
raise ValueError('cannot read archive member')
|
|
200
|
+
with source:
|
|
201
|
+
shutil.copyfileobj(source, out)
|
|
202
|
+
os.fchmod(out.fileno(), item.mode & 0o777)
|
|
203
|
+
finally:
|
|
204
|
+
os.close(fd)
|
|
205
|
+
for name, mode in reversed(directories):
|
|
206
|
+
fd, basename = parent_for(name)
|
|
207
|
+
try:
|
|
208
|
+
child = os.open(basename, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=fd)
|
|
209
|
+
try:
|
|
210
|
+
os.fchmod(child, mode)
|
|
211
|
+
finally:
|
|
212
|
+
os.close(child)
|
|
213
|
+
finally:
|
|
214
|
+
os.close(fd)
|
|
215
|
+
finally:
|
|
216
|
+
os.close(root_fd)
|
cli_backup/mysql.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""MariaDB/MySQL dump subprocesses."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import BinaryIO
|
|
10
|
+
|
|
11
|
+
_IDENTIFIER = re.compile(r"[A-Za-z0-9_][A-Za-z0-9_$-]{0,63}\Z")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class MySQLConfig:
|
|
16
|
+
mode: str
|
|
17
|
+
database: str
|
|
18
|
+
user: str
|
|
19
|
+
password: str
|
|
20
|
+
host: str = "127.0.0.1"
|
|
21
|
+
port: int = 3306
|
|
22
|
+
service: str = "mariadb"
|
|
23
|
+
network: str = ""
|
|
24
|
+
image: str = ""
|
|
25
|
+
compose_dir: str = "."
|
|
26
|
+
client: str = "mariadb"
|
|
27
|
+
compose_env_files: tuple[str, ...] = ()
|
|
28
|
+
cpu_nice: int = 10
|
|
29
|
+
container_runtime: str = "docker"
|
|
30
|
+
|
|
31
|
+
def validate(self) -> None:
|
|
32
|
+
if type(self.cpu_nice) is not int or not 0 <= self.cpu_nice <= 19:
|
|
33
|
+
raise ValueError("invalid CPU nice value")
|
|
34
|
+
if self.container_runtime not in {"docker", "podman"}:
|
|
35
|
+
raise ValueError("invalid container runtime")
|
|
36
|
+
if self.client not in {"mariadb", "mysql"}:
|
|
37
|
+
raise ValueError("invalid database client")
|
|
38
|
+
if self.mode not in {"compose", "container", "tcp", "exec"}:
|
|
39
|
+
raise ValueError("unknown connection mode")
|
|
40
|
+
if not _IDENTIFIER.fullmatch(self.database) or not self.user or self.user.startswith('-'):
|
|
41
|
+
raise ValueError("invalid database or user")
|
|
42
|
+
if not 1 <= self.port <= 65535:
|
|
43
|
+
raise ValueError("invalid port")
|
|
44
|
+
if self.mode not in ("compose", "exec") and (not self.host or self.host.startswith('-') or not re.fullmatch(r"[A-Za-z0-9_.:-]+", self.host)):
|
|
45
|
+
raise ValueError("invalid host")
|
|
46
|
+
if self.mode == "compose" and not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]*", self.service):
|
|
47
|
+
raise ValueError("invalid service")
|
|
48
|
+
if self.mode == "exec" and not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]*", self.service):
|
|
49
|
+
raise ValueError("invalid container identifier")
|
|
50
|
+
if self.mode == "container" and (not self.network or not self.image or self.network.startswith('-') or self.image.startswith('-')):
|
|
51
|
+
raise ValueError("network and image are required")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def command(config: MySQLConfig, restore: bool = False) -> list[str]:
|
|
55
|
+
config.validate()
|
|
56
|
+
executable = config.client if restore else ("mariadb-dump" if config.client == "mariadb" else "mysqldump")
|
|
57
|
+
if config.mode == "compose":
|
|
58
|
+
prefix = [config.container_runtime, "compose"]
|
|
59
|
+
for env_file in config.compose_env_files:
|
|
60
|
+
if not env_file or env_file.startswith('-'):
|
|
61
|
+
raise ValueError("invalid Compose env file")
|
|
62
|
+
prefix.extend(["--env-file", env_file])
|
|
63
|
+
prefix.extend(["exec", "-T", "-e", "MYSQL_PWD", config.service])
|
|
64
|
+
elif config.mode == "exec":
|
|
65
|
+
prefix = [config.container_runtime, "exec", "-i", "-e", "MYSQL_PWD", config.service]
|
|
66
|
+
elif config.mode == "container":
|
|
67
|
+
prefix = [config.container_runtime, "run", "--rm", "-i", "--network", config.network, "-e", "MYSQL_PWD", config.image]
|
|
68
|
+
else:
|
|
69
|
+
prefix = []
|
|
70
|
+
args = [executable, "--user", config.user]
|
|
71
|
+
if not restore and config.mode in ("exec", "compose", "container") and config.cpu_nice:
|
|
72
|
+
args = ["nice", "-n", str(config.cpu_nice)] + args
|
|
73
|
+
if config.mode not in ("compose", "exec"):
|
|
74
|
+
args.extend(["--host", config.host, "--port", str(config.port), "--protocol", "tcp"])
|
|
75
|
+
if not restore:
|
|
76
|
+
args.extend(["--single-transaction", "--routines", "--triggers", "--events", "--hex-blob"])
|
|
77
|
+
return prefix + args + [config.database]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def capture_mysql(config: MySQLConfig, output: BinaryIO) -> None:
|
|
81
|
+
env = dict(os.environ, MYSQL_PWD=config.password)
|
|
82
|
+
subprocess.run(command(config), cwd=config.compose_dir if config.mode == 'compose' else None,
|
|
83
|
+
env=env, stdin=subprocess.DEVNULL, stdout=output, check=True)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def restore_mysql(config: MySQLConfig, source: BinaryIO) -> None:
|
|
87
|
+
env = dict(os.environ, MYSQL_PWD=config.password)
|
|
88
|
+
subprocess.run(command(config, restore=True), cwd=config.compose_dir if config.mode == 'compose' else None,
|
|
89
|
+
env=env, stdin=source, check=True)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
OBJECT_COUNTS_SQL = (
|
|
93
|
+
"SELECT (SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_TYPE='BASE TABLE'), "
|
|
94
|
+
"(SELECT COUNT(*) FROM information_schema.VIEWS WHERE TABLE_SCHEMA=DATABASE()), "
|
|
95
|
+
"(SELECT COUNT(*) FROM information_schema.ROUTINES WHERE ROUTINE_SCHEMA=DATABASE()), "
|
|
96
|
+
"(SELECT COUNT(*) FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE()), "
|
|
97
|
+
"(SELECT COUNT(*) FROM information_schema.EVENTS WHERE EVENT_SCHEMA=DATABASE())"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def count_mysql_objects(config: MySQLConfig) -> str:
|
|
102
|
+
env = dict(os.environ, MYSQL_PWD=config.password)
|
|
103
|
+
result = subprocess.run(command(config, restore=True) + ['-N', '-B', '-e', OBJECT_COUNTS_SQL],
|
|
104
|
+
cwd=config.compose_dir if config.mode == 'compose' else None,
|
|
105
|
+
env=env, stdin=subprocess.DEVNULL, capture_output=True, text=True, check=True)
|
|
106
|
+
count = result.stdout.strip()
|
|
107
|
+
if not re.fullmatch(r'\d+(?:\s+\d+){4}', count):
|
|
108
|
+
raise ValueError('invalid object counts from source database')
|
|
109
|
+
return count
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Redis RDB capture via the official redis-cli replication client."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def capture_redis(output: Path, host: str, port: int, password: str | None = None, *, timeout: int = 14400) -> None:
|
|
10
|
+
if not host or host.startswith('-') or not 1 <= port <= 65535:
|
|
11
|
+
raise ValueError('invalid Redis address')
|
|
12
|
+
if timeout < 1:
|
|
13
|
+
raise ValueError('Redis timeout must be positive')
|
|
14
|
+
env = os.environ.copy()
|
|
15
|
+
if password is not None:
|
|
16
|
+
env['REDISCLI_AUTH'] = password
|
|
17
|
+
else:
|
|
18
|
+
env.pop('REDISCLI_AUTH', None)
|
|
19
|
+
subprocess.run(['redis-cli', '-h', host, '-p', str(port), '--rdb', str(output)], env=env,
|
|
20
|
+
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, check=True, timeout=timeout)
|
|
21
|
+
if not output.is_file() or output.stat().st_size == 0:
|
|
22
|
+
raise ValueError('Redis produced an empty snapshot')
|
|
23
|
+
subprocess.run(['redis-check-rdb', str(output)], stdin=subprocess.DEVNULL,
|
|
24
|
+
stdout=subprocess.DEVNULL, check=True, timeout=timeout)
|