dbara 20260714__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.
- dbara/__init__.py +1 -0
- dbara/__main__.py +5 -0
- dbara/archive.py +101 -0
- dbara/checksum.py +111 -0
- dbara/cli.py +237 -0
- dbara/config.py +74 -0
- dbara/docker_ops.py +55 -0
- dbara/engine.py +431 -0
- dbara/hooks.py +23 -0
- dbara/locks.py +30 -0
- dbara/log.py +71 -0
- dbara/runner.py +74 -0
- dbara/sqlite_backup.py +59 -0
- dbara-20260714.dist-info/METADATA +420 -0
- dbara-20260714.dist-info/RECORD +19 -0
- dbara-20260714.dist-info/WHEEL +5 -0
- dbara-20260714.dist-info/entry_points.txt +2 -0
- dbara-20260714.dist-info/licenses/LICENSE +21 -0
- dbara-20260714.dist-info/top_level.txt +1 -0
dbara/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "20260714"
|
dbara/__main__.py
ADDED
dbara/archive.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from dbara.log import Logger
|
|
8
|
+
|
|
9
|
+
_GLOB_PATTERNS = (
|
|
10
|
+
"*_{app}_*.bkup.tar.zst",
|
|
11
|
+
"*_{app}_*.bkup.tar.tgz",
|
|
12
|
+
"*_{app}_*.bkup.tar.gz",
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
_ARCHIVE_DATE_RE = re.compile(r"^(.+)_(\d{8}_\d{6})\.bkup\.tar\.(zst|tgz|gz)$")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def backup_ext(compressor: str) -> str:
|
|
19
|
+
return "zst" if compressor == "zstd" else "tgz"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def build_archive_name(
|
|
23
|
+
app: str,
|
|
24
|
+
compressor: str,
|
|
25
|
+
optional_prefix: str = "",
|
|
26
|
+
hostname: str = "",
|
|
27
|
+
timestamp: datetime | None = None,
|
|
28
|
+
) -> str:
|
|
29
|
+
"""Return the backup archive filename (no directory component)."""
|
|
30
|
+
if timestamp is None:
|
|
31
|
+
timestamp = datetime.now()
|
|
32
|
+
date_str = timestamp.strftime("%Y%m%d_%H%M%S")
|
|
33
|
+
prefix_part = f"{optional_prefix}_" if optional_prefix else ""
|
|
34
|
+
ext = backup_ext(compressor)
|
|
35
|
+
return f"{hostname}_{prefix_part}{app}_{date_str}.bkup.tar.{ext}"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def select_newest_archive(directory: Path, app: str) -> Path | None:
|
|
39
|
+
"""Return the most-recently-modified backup archive for app in directory, or None."""
|
|
40
|
+
candidates: list[tuple[float, Path]] = []
|
|
41
|
+
for pattern_tmpl in _GLOB_PATTERNS:
|
|
42
|
+
for p in directory.glob(pattern_tmpl.format(app=app)):
|
|
43
|
+
if p.is_file():
|
|
44
|
+
candidates.append((p.stat().st_mtime, p))
|
|
45
|
+
if not candidates:
|
|
46
|
+
return None
|
|
47
|
+
return max(candidates, key=lambda t: t[0])[1]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def prune_old_backups(
|
|
51
|
+
backup_dir: Path,
|
|
52
|
+
app: str,
|
|
53
|
+
keep_last: int,
|
|
54
|
+
logger: Logger,
|
|
55
|
+
) -> None:
|
|
56
|
+
"""Delete the oldest archives for app, keeping only the last keep_last.
|
|
57
|
+
|
|
58
|
+
Also removes associated .sha256sum and .xxh128 checksum sidecar files.
|
|
59
|
+
No-op when keep_last <= 0.
|
|
60
|
+
"""
|
|
61
|
+
if keep_last <= 0:
|
|
62
|
+
return
|
|
63
|
+
logger.info(f"Pruning old backups for {app!r} (keeping last {keep_last})...")
|
|
64
|
+
archives: list[tuple[float, Path]] = []
|
|
65
|
+
for pattern_tmpl in _GLOB_PATTERNS:
|
|
66
|
+
for p in backup_dir.glob(pattern_tmpl.format(app=app)):
|
|
67
|
+
if p.is_file():
|
|
68
|
+
archives.append((p.stat().st_mtime, p))
|
|
69
|
+
archives.sort(key=lambda t: t[0]) # oldest first
|
|
70
|
+
count = len(archives)
|
|
71
|
+
if count <= keep_last:
|
|
72
|
+
logger.debug(f"Only {count} archive(s) for {app!r}; nothing to prune.")
|
|
73
|
+
return
|
|
74
|
+
for _, old in archives[: count - keep_last]:
|
|
75
|
+
logger.info(f"Pruning old backup: {old}")
|
|
76
|
+
old.unlink(missing_ok=True)
|
|
77
|
+
Path(str(old) + ".sha256sum").unlink(missing_ok=True)
|
|
78
|
+
Path(str(old) + ".xxh128").unlink(missing_ok=True)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def list_apps_in_backup_dir(backup_dir: Path) -> list[str]:
|
|
82
|
+
"""Return sorted unique app names inferred from archive filenames in backup_dir.
|
|
83
|
+
|
|
84
|
+
Parses the archive naming convention: {hostname}[_{prefix}]{app}_{YYYYMMDD_HHMMSS}.bkup.tar.EXT
|
|
85
|
+
The app name is the last underscore-delimited segment before the timestamp.
|
|
86
|
+
App names containing underscores are not supported by this auto-discovery.
|
|
87
|
+
"""
|
|
88
|
+
if not backup_dir.is_dir():
|
|
89
|
+
return []
|
|
90
|
+
apps: set[str] = set()
|
|
91
|
+
for f in backup_dir.iterdir():
|
|
92
|
+
if not f.is_file():
|
|
93
|
+
continue
|
|
94
|
+
m = _ARCHIVE_DATE_RE.match(f.name)
|
|
95
|
+
if not m:
|
|
96
|
+
continue
|
|
97
|
+
stem = m.group(1) # e.g. "hostname_prefix_appname"
|
|
98
|
+
app = stem.rsplit("_", 1)[-1]
|
|
99
|
+
if app:
|
|
100
|
+
apps.add(app)
|
|
101
|
+
return sorted(apps)
|
dbara/checksum.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from dbara.log import Logger
|
|
8
|
+
from dbara.runner import CommandRunner
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def hash_program(fast_hash: bool, runner: CommandRunner) -> str:
|
|
12
|
+
"""Return 'xxh128sum' if fast_hash is enabled and the tool is available, else 'sha256sum'."""
|
|
13
|
+
if fast_hash and runner.tool_available("xxh128sum"):
|
|
14
|
+
return "xxh128sum"
|
|
15
|
+
return "sha256sum"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def checksum_suffix(program: str) -> str:
|
|
19
|
+
return "xxh128" if program == "xxh128sum" else "sha256sum"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def create_checksum_file(
|
|
23
|
+
app: str,
|
|
24
|
+
backup_path: Path,
|
|
25
|
+
base_dir: Path,
|
|
26
|
+
runner: CommandRunner,
|
|
27
|
+
logger: Logger,
|
|
28
|
+
fast_hash: bool,
|
|
29
|
+
) -> Path:
|
|
30
|
+
"""Generate a checksum file for all files under base_dir/app.
|
|
31
|
+
|
|
32
|
+
The file is named {backup_path}.sha256sum or {backup_path}.xxh128 and uses
|
|
33
|
+
the same format as sha256sum/xxh128sum so it can be verified externally.
|
|
34
|
+
"""
|
|
35
|
+
import shlex
|
|
36
|
+
|
|
37
|
+
hasher = hash_program(fast_hash, runner)
|
|
38
|
+
suffix = checksum_suffix(hasher)
|
|
39
|
+
out_path = Path(str(backup_path) + f".{suffix}")
|
|
40
|
+
logger.info(f"Generating {hasher} checksums -> {out_path.name}")
|
|
41
|
+
# Pipe find → xargs → hasher, with output redirect — requires a shell for the pipeline.
|
|
42
|
+
# All dynamic values are shlex-quoted; none come from user input at this point.
|
|
43
|
+
shell_cmd = (
|
|
44
|
+
f"find {shlex.quote(app)} -type f -print0 "
|
|
45
|
+
f"| xargs -0 {shlex.quote(hasher)} "
|
|
46
|
+
f"> {shlex.quote(str(out_path))}"
|
|
47
|
+
)
|
|
48
|
+
runner.run_io_niced(["bash", "-c", shell_cmd], cwd=base_dir)
|
|
49
|
+
return out_path
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def verify_checksum_file(
|
|
53
|
+
base_dir: Path,
|
|
54
|
+
checksum_file: Path,
|
|
55
|
+
runner: CommandRunner,
|
|
56
|
+
logger: Logger,
|
|
57
|
+
fast_hash: bool,
|
|
58
|
+
) -> bool:
|
|
59
|
+
"""Verify checksum_file from base_dir.
|
|
60
|
+
|
|
61
|
+
Manifest lines are app-prefixed relative paths (see create_checksum_file),
|
|
62
|
+
so base_dir must be the directory CONTAINING the restored app directory.
|
|
63
|
+
|
|
64
|
+
Returns True if all checksums pass (or file missing — non-fatal).
|
|
65
|
+
Returns False and logs warnings on any mismatch.
|
|
66
|
+
"""
|
|
67
|
+
if not checksum_file.is_file():
|
|
68
|
+
logger.warn(f"Checksum file {checksum_file} missing, skipping verification.")
|
|
69
|
+
return True
|
|
70
|
+
|
|
71
|
+
hasher = hash_program(fast_hash, runner)
|
|
72
|
+
logger.info(f"Verifying checksums with {hasher}...")
|
|
73
|
+
result = runner.run(
|
|
74
|
+
[hasher, "-c", str(checksum_file)],
|
|
75
|
+
cwd=base_dir,
|
|
76
|
+
capture_output=True,
|
|
77
|
+
check=False,
|
|
78
|
+
)
|
|
79
|
+
failed = False
|
|
80
|
+
for line in (result.stdout + result.stderr).splitlines():
|
|
81
|
+
if "FAILED" in line or "No such file" in line:
|
|
82
|
+
logger.warn(f"Checksum issue: {line}")
|
|
83
|
+
failed = True
|
|
84
|
+
return not failed
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def signature_of_dir(directory: Path) -> str:
|
|
88
|
+
"""Pure-Python change-detection signature.
|
|
89
|
+
|
|
90
|
+
Walks directory, collects (relative_path, size, mtime_ns) for every file
|
|
91
|
+
sorted by path, then returns a hex SHA-256 digest. Fully unit-testable
|
|
92
|
+
without subprocess.
|
|
93
|
+
|
|
94
|
+
NOTE: Incompatible with the bash version's .sig files (which used
|
|
95
|
+
find -printf | xxh128sum). On first Python run, every app will appear
|
|
96
|
+
changed and receive one unconditional backup — safe by design.
|
|
97
|
+
"""
|
|
98
|
+
h = hashlib.sha256()
|
|
99
|
+
entries: list[tuple[str, int, int]] = []
|
|
100
|
+
for dirpath, _dirs, filenames in os.walk(directory):
|
|
101
|
+
for fname in filenames:
|
|
102
|
+
full = Path(dirpath) / fname
|
|
103
|
+
try:
|
|
104
|
+
st = full.stat()
|
|
105
|
+
except OSError:
|
|
106
|
+
continue
|
|
107
|
+
rel = str(full.relative_to(directory))
|
|
108
|
+
entries.append((rel, st.st_size, st.st_mtime_ns))
|
|
109
|
+
for rel, size, mtime_ns in sorted(entries):
|
|
110
|
+
h.update(f"{rel}\x00{size}\x00{mtime_ns}\n".encode())
|
|
111
|
+
return h.hexdigest()
|
dbara/cli.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import shutil
|
|
5
|
+
import sys
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from dbara import __version__
|
|
10
|
+
from dbara.config import Config
|
|
11
|
+
from dbara.docker_ops import DockerClient
|
|
12
|
+
from dbara.engine import BackupEngine, RestoreEngine
|
|
13
|
+
from dbara.log import Logger
|
|
14
|
+
from dbara.runner import CommandRunner
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
18
|
+
p = argparse.ArgumentParser(
|
|
19
|
+
prog="dbara",
|
|
20
|
+
description="Docker Backup and Restore Application",
|
|
21
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
22
|
+
epilog="""
|
|
23
|
+
Examples:
|
|
24
|
+
Backup one app:
|
|
25
|
+
dbara -m backup -a jellyfin -d /srv/apps -b /srv/backups -p prod -v
|
|
26
|
+
|
|
27
|
+
Backup all apps, keep last 7 archives each:
|
|
28
|
+
dbara -m backup -d /srv/apps -b /srv/backups -A --keep-last 7
|
|
29
|
+
|
|
30
|
+
Restore latest backup for one app:
|
|
31
|
+
dbara -m restore -a jellyfin -d /srv/apps -b /srv/backups -r /srv/apps -o 1000:1000
|
|
32
|
+
|
|
33
|
+
Hooks:
|
|
34
|
+
Place executable scripts at: HOOKS_DIR/<app>/{pre-backup,post-backup,pre-restore,post-restore}
|
|
35
|
+
""",
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
# Required
|
|
39
|
+
req = p.add_argument_group("required")
|
|
40
|
+
req.add_argument(
|
|
41
|
+
"-m", "--mode", required=True, choices=["backup", "restore"], help="Operation mode"
|
|
42
|
+
)
|
|
43
|
+
req.add_argument(
|
|
44
|
+
"-d", "--app-folder-dir", required=True, type=Path,
|
|
45
|
+
help="Parent directory containing per-app folders",
|
|
46
|
+
)
|
|
47
|
+
req.add_argument(
|
|
48
|
+
"-b", "--backup-dest-dir", required=True, type=Path,
|
|
49
|
+
help="Backup destination (directory for backup; file or directory for restore)",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# Common
|
|
53
|
+
com = p.add_argument_group("common options")
|
|
54
|
+
com.add_argument("-a", "--app-name", default="", help="Single app name")
|
|
55
|
+
com.add_argument(
|
|
56
|
+
"--apps", nargs="+", metavar="APP", default=None,
|
|
57
|
+
help="Restore a specific list of apps (restore mode only)",
|
|
58
|
+
)
|
|
59
|
+
com.add_argument(
|
|
60
|
+
"-A", "--all-apps", action="store_true",
|
|
61
|
+
help="Backup/restore all apps (backup: scans --app-folder-dir; restore: scans --backup-dest-dir)", # noqa: E501
|
|
62
|
+
)
|
|
63
|
+
com.add_argument("-r", "--restore-dest-dir", type=Path, default=None)
|
|
64
|
+
com.add_argument(
|
|
65
|
+
"-o", "--owner-group", default="",
|
|
66
|
+
help="Set ownership after restore as user:group (overrides --ownership-map)",
|
|
67
|
+
)
|
|
68
|
+
com.add_argument("-p", "--optional-prefix", default="", help="Prefix in backup filename")
|
|
69
|
+
com.add_argument(
|
|
70
|
+
"-x", "--remove-after-backup", action="store_true",
|
|
71
|
+
help="Remove app directory after successful backup (archive integrity verified first)",
|
|
72
|
+
)
|
|
73
|
+
com.add_argument(
|
|
74
|
+
"-s", "--no-start-container", action="store_true",
|
|
75
|
+
help="Do NOT start the container after the operation completes",
|
|
76
|
+
)
|
|
77
|
+
com.add_argument(
|
|
78
|
+
"-f", "--force", action="store_true",
|
|
79
|
+
help="Force backup even when no changes are detected",
|
|
80
|
+
)
|
|
81
|
+
com.add_argument("--strict", action="store_true", help="Treat warnings as fatal errors")
|
|
82
|
+
com.add_argument(
|
|
83
|
+
"--skip-checksum", action="store_true", help="Skip checksum generation/verification"
|
|
84
|
+
)
|
|
85
|
+
com.add_argument(
|
|
86
|
+
"-v", "--verbose", action="count", default=0,
|
|
87
|
+
help="-v enables debug output; -vv additionally writes a logfile",
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
# Performance / behaviour
|
|
91
|
+
perf = p.add_argument_group("performance / behaviour")
|
|
92
|
+
perf.add_argument("--compress", choices=["zstd", "gzip"], default="zstd")
|
|
93
|
+
perf.add_argument(
|
|
94
|
+
"--zstd-opts", default="-T0 -3", help='Options forwarded to zstd (default: "-T0 -3")'
|
|
95
|
+
)
|
|
96
|
+
perf.add_argument(
|
|
97
|
+
"--fast-hash", action=argparse.BooleanOptionalAction, default=True,
|
|
98
|
+
help="Use xxh128sum when available (default: enabled)",
|
|
99
|
+
)
|
|
100
|
+
perf.add_argument(
|
|
101
|
+
"--io-nice", action=argparse.BooleanOptionalAction, default=True,
|
|
102
|
+
help="Wrap heavy operations with ionice+nice (default: enabled)",
|
|
103
|
+
)
|
|
104
|
+
perf.add_argument(
|
|
105
|
+
"--keep-last", type=int, default=0, metavar="N",
|
|
106
|
+
help="Keep last N backups per app and prune older ones (0 = disabled)",
|
|
107
|
+
)
|
|
108
|
+
perf.add_argument(
|
|
109
|
+
"--state-dir", type=Path, default=Path("/var/lib/app-backup/state"),
|
|
110
|
+
help="Directory for change-detection state files",
|
|
111
|
+
)
|
|
112
|
+
perf.add_argument(
|
|
113
|
+
"--hooks-dir", type=Path, default=Path("/etc/app-backup/hooks"),
|
|
114
|
+
help="Root directory for per-app hook scripts",
|
|
115
|
+
)
|
|
116
|
+
perf.add_argument(
|
|
117
|
+
"--lock-dir", type=Path, default=Path("/var/lock"),
|
|
118
|
+
help="Directory for per-app lock files (default: /var/lock)",
|
|
119
|
+
)
|
|
120
|
+
perf.add_argument("--ownership-map", type=Path, default=None, dest="ownership_map_file")
|
|
121
|
+
perf.add_argument("--stop-timeout", type=int, default=30, metavar="SEC")
|
|
122
|
+
perf.add_argument("--retry-max", type=int, default=5, metavar="N")
|
|
123
|
+
perf.add_argument("--retry-base-sleep", type=int, default=2, metavar="SEC")
|
|
124
|
+
perf.add_argument(
|
|
125
|
+
"--logfile", type=Path, default=Path("/var/log/app-backup-restore.log"),
|
|
126
|
+
help="Log file path (written when -vv is used)",
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
130
|
+
return p
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _build_config(args: argparse.Namespace) -> Config:
|
|
134
|
+
return Config(
|
|
135
|
+
mode=args.mode,
|
|
136
|
+
app_name=args.app_name,
|
|
137
|
+
app_names=args.apps or [],
|
|
138
|
+
app_folder_dir=args.app_folder_dir,
|
|
139
|
+
backup_dest_dir=args.backup_dest_dir,
|
|
140
|
+
restore_dest_dir=args.restore_dest_dir,
|
|
141
|
+
remove_after_backup=args.remove_after_backup,
|
|
142
|
+
start_container=not args.no_start_container,
|
|
143
|
+
all_apps=args.all_apps,
|
|
144
|
+
force=args.force,
|
|
145
|
+
strict=args.strict,
|
|
146
|
+
skip_checksum=args.skip_checksum,
|
|
147
|
+
compressor=args.compress,
|
|
148
|
+
zstd_opts=args.zstd_opts,
|
|
149
|
+
fast_hash=args.fast_hash,
|
|
150
|
+
io_nice=args.io_nice,
|
|
151
|
+
keep_last=args.keep_last,
|
|
152
|
+
state_dir=args.state_dir,
|
|
153
|
+
hooks_dir=args.hooks_dir,
|
|
154
|
+
lock_dir=args.lock_dir,
|
|
155
|
+
ownership_map_file=args.ownership_map_file,
|
|
156
|
+
owner_group=args.owner_group,
|
|
157
|
+
optional_prefix=args.optional_prefix,
|
|
158
|
+
stop_timeout=args.stop_timeout,
|
|
159
|
+
retry_max=args.retry_max,
|
|
160
|
+
retry_base_sleep=args.retry_base_sleep,
|
|
161
|
+
verbosity=args.verbose,
|
|
162
|
+
logfile=args.logfile,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
167
|
+
parser = build_parser()
|
|
168
|
+
args = parser.parse_args(list(argv) if argv is not None else None)
|
|
169
|
+
|
|
170
|
+
try:
|
|
171
|
+
config = _build_config(args)
|
|
172
|
+
config.validate()
|
|
173
|
+
except ValueError as exc:
|
|
174
|
+
print(f"[ERROR] {exc}", file=sys.stderr)
|
|
175
|
+
return 1
|
|
176
|
+
|
|
177
|
+
# Initialise logger
|
|
178
|
+
logfile: Path | None = config.logfile if config.verbosity >= 2 else None
|
|
179
|
+
if logfile is not None:
|
|
180
|
+
logfile.parent.mkdir(parents=True, exist_ok=True)
|
|
181
|
+
logger = Logger(verbosity=config.verbosity, strict=config.strict, logfile=logfile)
|
|
182
|
+
|
|
183
|
+
# Prerequisites
|
|
184
|
+
if not shutil.which("docker"):
|
|
185
|
+
logger.fatal("Docker is not installed or not in PATH.")
|
|
186
|
+
if config.compressor == "zstd" and not shutil.which("zstd"):
|
|
187
|
+
logger.warn("zstd not found; please install it for best performance.")
|
|
188
|
+
if not shutil.which("xxh128sum"):
|
|
189
|
+
logger.warn("xxh128sum not found; falling back to sha256sum for checksums.")
|
|
190
|
+
|
|
191
|
+
# Shared dependencies
|
|
192
|
+
try:
|
|
193
|
+
config.state_dir.mkdir(parents=True, exist_ok=True)
|
|
194
|
+
except PermissionError:
|
|
195
|
+
logger.fatal(
|
|
196
|
+
f"Cannot create state directory {config.state_dir}: permission denied. "
|
|
197
|
+
f"Run with sudo or use --state-dir to specify a writable location."
|
|
198
|
+
)
|
|
199
|
+
runner = CommandRunner(config=config, logger=logger)
|
|
200
|
+
docker = DockerClient(
|
|
201
|
+
runner=runner,
|
|
202
|
+
logger=logger,
|
|
203
|
+
stop_timeout=config.stop_timeout,
|
|
204
|
+
start_container=config.start_container,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
# Dispatch
|
|
208
|
+
if config.mode == "backup":
|
|
209
|
+
engine = BackupEngine(config=config, runner=runner, docker=docker, logger=logger)
|
|
210
|
+
if config.all_apps:
|
|
211
|
+
engine.backup_all_apps()
|
|
212
|
+
else:
|
|
213
|
+
# Match backup_all_apps: only restart containers that were running
|
|
214
|
+
# before the backup, so a deliberately stopped container stays stopped.
|
|
215
|
+
was_running = docker.container_exists(config.app_name) and docker.container_running(
|
|
216
|
+
config.app_name
|
|
217
|
+
)
|
|
218
|
+
docker.stop(config.app_name)
|
|
219
|
+
engine.backup_single_app(config.app_name)
|
|
220
|
+
if was_running:
|
|
221
|
+
docker.start(config.app_name)
|
|
222
|
+
else:
|
|
223
|
+
engine_r = RestoreEngine(config=config, runner=runner, docker=docker, logger=logger)
|
|
224
|
+
if config.all_apps:
|
|
225
|
+
engine_r.restore_all_apps()
|
|
226
|
+
elif config.app_names:
|
|
227
|
+
engine_r.restore_apps(config.app_names)
|
|
228
|
+
else:
|
|
229
|
+
engine_r.restore_app()
|
|
230
|
+
|
|
231
|
+
logger.info("✅ Script completed successfully!")
|
|
232
|
+
logger.print_summary()
|
|
233
|
+
return 0
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
if __name__ == "__main__":
|
|
237
|
+
sys.exit(main())
|
dbara/config.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class Config:
|
|
9
|
+
mode: str
|
|
10
|
+
app_name: str
|
|
11
|
+
app_folder_dir: Path
|
|
12
|
+
backup_dest_dir: Path
|
|
13
|
+
restore_dest_dir: Path | None
|
|
14
|
+
remove_after_backup: bool
|
|
15
|
+
start_container: bool
|
|
16
|
+
all_apps: bool
|
|
17
|
+
force: bool
|
|
18
|
+
strict: bool
|
|
19
|
+
skip_checksum: bool
|
|
20
|
+
compressor: str
|
|
21
|
+
zstd_opts: str
|
|
22
|
+
fast_hash: bool
|
|
23
|
+
io_nice: bool
|
|
24
|
+
keep_last: int
|
|
25
|
+
state_dir: Path
|
|
26
|
+
hooks_dir: Path
|
|
27
|
+
lock_dir: Path
|
|
28
|
+
ownership_map_file: Path | None
|
|
29
|
+
owner_group: str
|
|
30
|
+
optional_prefix: str
|
|
31
|
+
stop_timeout: int
|
|
32
|
+
retry_max: int
|
|
33
|
+
retry_base_sleep: int
|
|
34
|
+
verbosity: int
|
|
35
|
+
logfile: Path
|
|
36
|
+
app_names: list[str] = field(default_factory=list)
|
|
37
|
+
|
|
38
|
+
def validate(self) -> None:
|
|
39
|
+
if self.mode not in ("backup", "restore"):
|
|
40
|
+
raise ValueError(f"Invalid mode {self.mode!r}. Must be 'backup' or 'restore'.")
|
|
41
|
+
if self.compressor not in ("zstd", "gzip"):
|
|
42
|
+
raise ValueError(f"Invalid compressor {self.compressor!r}. Must be 'zstd' or 'gzip'.")
|
|
43
|
+
if self.keep_last < 0:
|
|
44
|
+
raise ValueError(f"--keep-last must be >= 0, got {self.keep_last}.")
|
|
45
|
+
if self.stop_timeout <= 0:
|
|
46
|
+
raise ValueError(f"--stop-timeout must be > 0, got {self.stop_timeout}.")
|
|
47
|
+
if self.retry_max < 1:
|
|
48
|
+
raise ValueError(f"--retry-max must be >= 1, got {self.retry_max}.")
|
|
49
|
+
if self.retry_base_sleep < 1:
|
|
50
|
+
raise ValueError(f"--retry-base-sleep must be >= 1, got {self.retry_base_sleep}.")
|
|
51
|
+
if self.mode == "restore":
|
|
52
|
+
has_app = bool(self.app_name or self.app_names or self.all_apps)
|
|
53
|
+
if not has_app:
|
|
54
|
+
raise ValueError(
|
|
55
|
+
"Restore mode requires --app-name (-a), --apps, or --all-apps (-A)."
|
|
56
|
+
)
|
|
57
|
+
if self.restore_dest_dir is None:
|
|
58
|
+
raise ValueError("Restore mode requires --restore-dest-dir (-r).")
|
|
59
|
+
if self.mode == "backup" and not self.all_apps and not self.app_name:
|
|
60
|
+
raise ValueError("Backup mode requires --app-name (-a) unless --all-apps is set.")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def parse_ownership_map(map_file: Path, app: str) -> str | None:
|
|
64
|
+
"""Return 'user:group' for app from an ownership map file, or None if not found."""
|
|
65
|
+
for line in map_file.read_text().splitlines():
|
|
66
|
+
line = line.strip()
|
|
67
|
+
if not line or line.startswith("#"):
|
|
68
|
+
continue
|
|
69
|
+
if "=" not in line:
|
|
70
|
+
continue
|
|
71
|
+
key, _, val = line.partition("=")
|
|
72
|
+
if key.strip() == app and val.strip():
|
|
73
|
+
return val.strip()
|
|
74
|
+
return None
|
dbara/docker_ops.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dbara.log import Logger
|
|
4
|
+
from dbara.runner import CommandRunner
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class DockerClient:
|
|
8
|
+
def __init__(
|
|
9
|
+
self,
|
|
10
|
+
runner: CommandRunner,
|
|
11
|
+
logger: Logger,
|
|
12
|
+
stop_timeout: int,
|
|
13
|
+
start_container: bool,
|
|
14
|
+
) -> None:
|
|
15
|
+
self._runner = runner
|
|
16
|
+
self._logger = logger
|
|
17
|
+
self._stop_timeout = stop_timeout
|
|
18
|
+
self._start_container = start_container
|
|
19
|
+
|
|
20
|
+
def container_exists(self, name: str) -> bool:
|
|
21
|
+
result = self._runner.run(
|
|
22
|
+
["docker", "ps", "-a", "-q", "--filter", f"name=^/{name}$"],
|
|
23
|
+
capture_output=True,
|
|
24
|
+
)
|
|
25
|
+
return bool(result.stdout.strip())
|
|
26
|
+
|
|
27
|
+
def container_running(self, name: str) -> bool:
|
|
28
|
+
result = self._runner.run(
|
|
29
|
+
["docker", "ps", "-q", "--filter", f"name=^/{name}$"],
|
|
30
|
+
capture_output=True,
|
|
31
|
+
)
|
|
32
|
+
return bool(result.stdout.strip())
|
|
33
|
+
|
|
34
|
+
def stop(self, name: str) -> None:
|
|
35
|
+
if not self.container_exists(name):
|
|
36
|
+
self._logger.info(f"No container named {name!r} exists; skipping stop.")
|
|
37
|
+
return
|
|
38
|
+
if not self.container_running(name):
|
|
39
|
+
self._logger.info(f"Container {name!r} exists but is not running; nothing to stop.")
|
|
40
|
+
return
|
|
41
|
+
self._logger.info(f"Stopping container {name!r} (timeout {self._stop_timeout}s)...")
|
|
42
|
+
self._runner.run_with_retry(["docker", "stop", "-t", str(self._stop_timeout), name])
|
|
43
|
+
|
|
44
|
+
def start(self, name: str) -> None:
|
|
45
|
+
if not self._start_container:
|
|
46
|
+
self._logger.info("Start container disabled by user.")
|
|
47
|
+
return
|
|
48
|
+
if not self.container_exists(name):
|
|
49
|
+
self._logger.info(f"No container named {name!r} exists; skipping start.")
|
|
50
|
+
return
|
|
51
|
+
if self.container_running(name):
|
|
52
|
+
self._logger.info(f"Container {name!r} already running; no start needed.")
|
|
53
|
+
return
|
|
54
|
+
self._logger.info(f"Starting container {name!r}...")
|
|
55
|
+
self._runner.run_with_retry(["docker", "start", name])
|