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