declarative-data-backup 0.2.1__tar.gz → 0.3.1__tar.gz
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.
- {declarative_data_backup-0.2.1 → declarative_data_backup-0.3.1}/PKG-INFO +22 -11
- {declarative_data_backup-0.2.1 → declarative_data_backup-0.3.1}/README.md +21 -10
- {declarative_data_backup-0.2.1 → declarative_data_backup-0.3.1}/pyproject.toml +1 -1
- {declarative_data_backup-0.2.1 → declarative_data_backup-0.3.1}/src/declarative_data_backup/backup.py +7 -0
- {declarative_data_backup-0.2.1 → declarative_data_backup-0.3.1}/src/declarative_data_backup/borg/borg.py +13 -7
- declarative_data_backup-0.3.1/src/declarative_data_backup/borg/borg_cli.py +164 -0
- {declarative_data_backup-0.2.1 → declarative_data_backup-0.3.1}/src/declarative_data_backup/environment.py +10 -0
- {declarative_data_backup-0.2.1 → declarative_data_backup-0.3.1}/src/declarative_data_backup.egg-info/PKG-INFO +22 -11
- {declarative_data_backup-0.2.1 → declarative_data_backup-0.3.1}/tests/test_backup.py +4 -0
- declarative_data_backup-0.2.1/src/declarative_data_backup/borg/borg_cli.py +0 -69
- {declarative_data_backup-0.2.1 → declarative_data_backup-0.3.1}/setup.cfg +0 -0
- {declarative_data_backup-0.2.1 → declarative_data_backup-0.3.1}/src/declarative_data_backup/__init__.py +0 -0
- {declarative_data_backup-0.2.1 → declarative_data_backup-0.3.1}/src/declarative_data_backup/__main__.py +0 -0
- {declarative_data_backup-0.2.1 → declarative_data_backup-0.3.1}/src/declarative_data_backup/borg/__init__.py +0 -0
- {declarative_data_backup-0.2.1 → declarative_data_backup-0.3.1}/src/declarative_data_backup/borg/archive_naming.py +0 -0
- {declarative_data_backup-0.2.1 → declarative_data_backup-0.3.1}/src/declarative_data_backup/config/__init__.py +0 -0
- {declarative_data_backup-0.2.1 → declarative_data_backup-0.3.1}/src/declarative_data_backup/config/backup_config/__init__.py +0 -0
- {declarative_data_backup-0.2.1 → declarative_data_backup-0.3.1}/src/declarative_data_backup/config/backup_config/models.py +0 -0
- {declarative_data_backup-0.2.1 → declarative_data_backup-0.3.1}/src/declarative_data_backup/config/backup_config/utils.py +0 -0
- {declarative_data_backup-0.2.1 → declarative_data_backup-0.3.1}/src/declarative_data_backup/config/backup_config/yaml_parser.py +0 -0
- {declarative_data_backup-0.2.1 → declarative_data_backup-0.3.1}/src/declarative_data_backup/config/backup_config/yaml_raw_loader.py +0 -0
- {declarative_data_backup-0.2.1 → declarative_data_backup-0.3.1}/src/declarative_data_backup/config/logging.py +0 -0
- {declarative_data_backup-0.2.1 → declarative_data_backup-0.3.1}/src/declarative_data_backup/errors.py +0 -0
- {declarative_data_backup-0.2.1 → declarative_data_backup-0.3.1}/src/declarative_data_backup.egg-info/SOURCES.txt +0 -0
- {declarative_data_backup-0.2.1 → declarative_data_backup-0.3.1}/src/declarative_data_backup.egg-info/dependency_links.txt +0 -0
- {declarative_data_backup-0.2.1 → declarative_data_backup-0.3.1}/src/declarative_data_backup.egg-info/requires.txt +0 -0
- {declarative_data_backup-0.2.1 → declarative_data_backup-0.3.1}/src/declarative_data_backup.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: declarative-data-backup
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: Declarative Borg-based data backups
|
|
5
5
|
Requires-Python: >=3.11
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -149,6 +149,8 @@ Optional variables:
|
|
|
149
149
|
| --- | --- |
|
|
150
150
|
| `BORG_REMOTE_PATH` | Remote Borg executable path, passed as Borg's `--remote-path`. Leave unset to use Borg's default. |
|
|
151
151
|
| `BORG_EXIT_CODES` | Borg exit-code mode: `legacy` or `modern`. |
|
|
152
|
+
| `BACKUP_PROGRESS` | Set to `true` to log the latest Borg progress every 30 seconds during archive creation. Defaults to `false`. |
|
|
153
|
+
| `BACKUP_VERBOSE` | Set to `true` to show Borg's per-file create and per-archive prune output. Defaults to `false` for concise logs. |
|
|
152
154
|
|
|
153
155
|
Provide credentials through a secret manager or protected files; do not put
|
|
154
156
|
passphrases, private keys, or credentials in the YAML file, source control, or
|
|
@@ -161,7 +163,15 @@ python -m declarative_data_backup
|
|
|
161
163
|
|
|
162
164
|
The command exits with status `0` on success, nonzero on configuration or Borg
|
|
163
165
|
failure, and `130` when interrupted. Borg's exit code `1` indicates warnings:
|
|
164
|
-
the run fails and pruning is skipped.
|
|
166
|
+
the run fails and pruning is skipped. Logs include group and source start/end
|
|
167
|
+
messages, while Borg's per-file and per-archive output is disabled by default.
|
|
168
|
+
Set `BACKUP_VERBOSE=true` for detailed Borg output or `BACKUP_PROGRESS=true`
|
|
169
|
+
for live progress during archive creation; either can increase log volume.
|
|
170
|
+
Progress is logged as plain status lines, not a terminal progress bar. Each
|
|
171
|
+
line includes the group, source, elapsed time, and latest reported file/byte
|
|
172
|
+
counters or Borg phase. Unchanged counters do not prove forward progress;
|
|
173
|
+
before Borg reports a phase, the status only indicates that the process is
|
|
174
|
+
running. Warnings and statistics are not throttled.
|
|
165
175
|
|
|
166
176
|
## Configuration reference
|
|
167
177
|
|
|
@@ -289,23 +299,24 @@ container integration test.
|
|
|
289
299
|
|
|
290
300
|
## Testing
|
|
291
301
|
|
|
292
|
-
Run the unit tests:
|
|
302
|
+
Run the unit tests, integration tests, or both:
|
|
293
303
|
|
|
294
304
|
```shell
|
|
295
|
-
|
|
305
|
+
make test-unit
|
|
306
|
+
make test-integration
|
|
307
|
+
make test
|
|
296
308
|
```
|
|
297
309
|
|
|
298
|
-
The
|
|
310
|
+
The integration target opts in to both local Borg tests (which use temporary
|
|
311
|
+
encrypted repositories) and the Docker SSH test (which uses disposable
|
|
312
|
+
containers and a temporary repository). It does not use production credentials
|
|
313
|
+
or BorgBase. Docker must be installed and its daemon available.
|
|
314
|
+
|
|
315
|
+
To run either integration suite individually:
|
|
299
316
|
|
|
300
317
|
```shell
|
|
301
318
|
RUN_BORG_LOCAL_INTEGRATION=1 uv run pytest -m integration \
|
|
302
319
|
tests/integration/test_local_borg_integration.py
|
|
303
|
-
```
|
|
304
|
-
|
|
305
|
-
The opt-in Docker SSH integration test uses disposable containers and a
|
|
306
|
-
temporary repository; it does not use production credentials or BorgBase:
|
|
307
|
-
|
|
308
|
-
```shell
|
|
309
320
|
RUN_BORG_DOCKER_INTEGRATION=1 uv run pytest -m integration \
|
|
310
321
|
tests/integration/test_docker_integration.py
|
|
311
322
|
```
|
|
@@ -141,6 +141,8 @@ Optional variables:
|
|
|
141
141
|
| --- | --- |
|
|
142
142
|
| `BORG_REMOTE_PATH` | Remote Borg executable path, passed as Borg's `--remote-path`. Leave unset to use Borg's default. |
|
|
143
143
|
| `BORG_EXIT_CODES` | Borg exit-code mode: `legacy` or `modern`. |
|
|
144
|
+
| `BACKUP_PROGRESS` | Set to `true` to log the latest Borg progress every 30 seconds during archive creation. Defaults to `false`. |
|
|
145
|
+
| `BACKUP_VERBOSE` | Set to `true` to show Borg's per-file create and per-archive prune output. Defaults to `false` for concise logs. |
|
|
144
146
|
|
|
145
147
|
Provide credentials through a secret manager or protected files; do not put
|
|
146
148
|
passphrases, private keys, or credentials in the YAML file, source control, or
|
|
@@ -153,7 +155,15 @@ python -m declarative_data_backup
|
|
|
153
155
|
|
|
154
156
|
The command exits with status `0` on success, nonzero on configuration or Borg
|
|
155
157
|
failure, and `130` when interrupted. Borg's exit code `1` indicates warnings:
|
|
156
|
-
the run fails and pruning is skipped.
|
|
158
|
+
the run fails and pruning is skipped. Logs include group and source start/end
|
|
159
|
+
messages, while Borg's per-file and per-archive output is disabled by default.
|
|
160
|
+
Set `BACKUP_VERBOSE=true` for detailed Borg output or `BACKUP_PROGRESS=true`
|
|
161
|
+
for live progress during archive creation; either can increase log volume.
|
|
162
|
+
Progress is logged as plain status lines, not a terminal progress bar. Each
|
|
163
|
+
line includes the group, source, elapsed time, and latest reported file/byte
|
|
164
|
+
counters or Borg phase. Unchanged counters do not prove forward progress;
|
|
165
|
+
before Borg reports a phase, the status only indicates that the process is
|
|
166
|
+
running. Warnings and statistics are not throttled.
|
|
157
167
|
|
|
158
168
|
## Configuration reference
|
|
159
169
|
|
|
@@ -281,23 +291,24 @@ container integration test.
|
|
|
281
291
|
|
|
282
292
|
## Testing
|
|
283
293
|
|
|
284
|
-
Run the unit tests:
|
|
294
|
+
Run the unit tests, integration tests, or both:
|
|
285
295
|
|
|
286
296
|
```shell
|
|
287
|
-
|
|
297
|
+
make test-unit
|
|
298
|
+
make test-integration
|
|
299
|
+
make test
|
|
288
300
|
```
|
|
289
301
|
|
|
290
|
-
The
|
|
302
|
+
The integration target opts in to both local Borg tests (which use temporary
|
|
303
|
+
encrypted repositories) and the Docker SSH test (which uses disposable
|
|
304
|
+
containers and a temporary repository). It does not use production credentials
|
|
305
|
+
or BorgBase. Docker must be installed and its daemon available.
|
|
306
|
+
|
|
307
|
+
To run either integration suite individually:
|
|
291
308
|
|
|
292
309
|
```shell
|
|
293
310
|
RUN_BORG_LOCAL_INTEGRATION=1 uv run pytest -m integration \
|
|
294
311
|
tests/integration/test_local_borg_integration.py
|
|
295
|
-
```
|
|
296
|
-
|
|
297
|
-
The opt-in Docker SSH integration test uses disposable containers and a
|
|
298
|
-
temporary repository; it does not use production credentials or BorgBase:
|
|
299
|
-
|
|
300
|
-
```shell
|
|
301
312
|
RUN_BORG_DOCKER_INTEGRATION=1 uv run pytest -m integration \
|
|
302
313
|
tests/integration/test_docker_integration.py
|
|
303
314
|
```
|
|
@@ -70,8 +70,14 @@ def run_backup(
|
|
|
70
70
|
run_id,
|
|
71
71
|
definition.excludes,
|
|
72
72
|
)
|
|
73
|
+
logger.info("Backup group '%s' completed.", definition.name)
|
|
73
74
|
|
|
74
75
|
for definition in definitions:
|
|
76
|
+
logger.info(
|
|
77
|
+
"Starting retention pruning for group '%s' (%d sources).",
|
|
78
|
+
definition.name,
|
|
79
|
+
len(definition.sources),
|
|
80
|
+
)
|
|
75
81
|
for folder in definition.sources:
|
|
76
82
|
prune(
|
|
77
83
|
folder,
|
|
@@ -79,6 +85,7 @@ def run_backup(
|
|
|
79
85
|
definition.name,
|
|
80
86
|
settings,
|
|
81
87
|
)
|
|
88
|
+
logger.info("Retention pruning for group '%s' completed.", definition.name)
|
|
82
89
|
|
|
83
90
|
|
|
84
91
|
def main(environ: Mapping[str, str] | None = None) -> int:
|
|
@@ -41,11 +41,14 @@ def backup_folder(
|
|
|
41
41
|
logger.info("Starting backup for %s as archive %s.", context, name)
|
|
42
42
|
args = [
|
|
43
43
|
"create",
|
|
44
|
-
"--verbose",
|
|
45
44
|
"--stats",
|
|
46
45
|
"--compression",
|
|
47
46
|
compression,
|
|
48
47
|
]
|
|
48
|
+
if settings.backup_verbose:
|
|
49
|
+
args.append("--verbose")
|
|
50
|
+
if settings.backup_progress:
|
|
51
|
+
args.append("--progress")
|
|
49
52
|
for pattern in excludes:
|
|
50
53
|
args.extend(("--exclude", pattern))
|
|
51
54
|
args.extend((f"{settings.borg_repo}::{name}", str(folder)))
|
|
@@ -85,17 +88,20 @@ def prune(
|
|
|
85
88
|
retention_days,
|
|
86
89
|
)
|
|
87
90
|
try:
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
91
|
+
args = ["prune"]
|
|
92
|
+
if settings.backup_verbose:
|
|
93
|
+
args.extend(("--verbose", "--list"))
|
|
94
|
+
args.extend(
|
|
95
|
+
(
|
|
93
96
|
"--glob-archives",
|
|
94
97
|
selector,
|
|
95
98
|
"--keep-daily",
|
|
96
99
|
str(retention_days),
|
|
97
100
|
settings.borg_repo,
|
|
98
|
-
|
|
101
|
+
)
|
|
102
|
+
)
|
|
103
|
+
run_borg(
|
|
104
|
+
args,
|
|
99
105
|
settings,
|
|
100
106
|
"prune",
|
|
101
107
|
context,
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import selectors
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
|
|
10
|
+
from ..environment import Environment
|
|
11
|
+
from ..errors import BorgWarningError, fail
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
PROGRESS_INTERVAL_SECONDS = 30
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def run_with_progress(
|
|
18
|
+
command: list[str], environment: dict[str, str], context: str
|
|
19
|
+
) -> int:
|
|
20
|
+
started_at = time.monotonic()
|
|
21
|
+
next_report = started_at + PROGRESS_INTERVAL_SECONDS
|
|
22
|
+
latest_progress = "Waiting for Borg progress (process running)."
|
|
23
|
+
|
|
24
|
+
def handle_line(line: bytes) -> None:
|
|
25
|
+
nonlocal latest_progress
|
|
26
|
+
text = line.decode("utf-8", errors="replace")
|
|
27
|
+
try:
|
|
28
|
+
event = json.loads(text)
|
|
29
|
+
except json.JSONDecodeError:
|
|
30
|
+
print(text, file=sys.stderr, flush=True)
|
|
31
|
+
return
|
|
32
|
+
if not isinstance(event, dict):
|
|
33
|
+
print(text, file=sys.stderr, flush=True)
|
|
34
|
+
return
|
|
35
|
+
kind = event.get("type")
|
|
36
|
+
if kind in ("archive_progress", "progress_message", "progress_percent"):
|
|
37
|
+
if event.get("finished"):
|
|
38
|
+
return
|
|
39
|
+
if kind == "archive_progress":
|
|
40
|
+
latest_progress = (
|
|
41
|
+
f"files={event.get('nfiles')} "
|
|
42
|
+
f"original_bytes={event.get('original_size')} "
|
|
43
|
+
f"compressed_bytes={event.get('compressed_size')} "
|
|
44
|
+
f"deduplicated_bytes={event.get('deduplicated_size')}"
|
|
45
|
+
)
|
|
46
|
+
else:
|
|
47
|
+
latest_progress = str(event.get("message", text))
|
|
48
|
+
return
|
|
49
|
+
if kind == "log_message":
|
|
50
|
+
print(event.get("message", text), file=sys.stderr, flush=True)
|
|
51
|
+
else:
|
|
52
|
+
print(text, file=sys.stderr, flush=True)
|
|
53
|
+
|
|
54
|
+
with subprocess.Popen(
|
|
55
|
+
command,
|
|
56
|
+
env=environment,
|
|
57
|
+
stderr=subprocess.PIPE,
|
|
58
|
+
) as process:
|
|
59
|
+
assert process.stderr is not None
|
|
60
|
+
try:
|
|
61
|
+
with selectors.DefaultSelector() as selector:
|
|
62
|
+
selector.register(process.stderr, selectors.EVENT_READ)
|
|
63
|
+
pending = b""
|
|
64
|
+
while selector.get_map():
|
|
65
|
+
for key, _ in selector.select(
|
|
66
|
+
max(0, next_report - time.monotonic())
|
|
67
|
+
):
|
|
68
|
+
chunk = os.read(key.fd, 65536)
|
|
69
|
+
if not chunk:
|
|
70
|
+
selector.unregister(key.fileobj)
|
|
71
|
+
if pending:
|
|
72
|
+
handle_line(pending)
|
|
73
|
+
break
|
|
74
|
+
pending += chunk
|
|
75
|
+
while b"\n" in pending:
|
|
76
|
+
line, pending = pending.split(b"\n", 1)
|
|
77
|
+
handle_line(line)
|
|
78
|
+
now = time.monotonic()
|
|
79
|
+
if now >= next_report and selector.get_map():
|
|
80
|
+
logger.info(
|
|
81
|
+
"Borg progress for %s (elapsed_seconds=%.0f; "
|
|
82
|
+
"latest reported: %s).",
|
|
83
|
+
context,
|
|
84
|
+
now - started_at,
|
|
85
|
+
latest_progress,
|
|
86
|
+
)
|
|
87
|
+
next_report = now + PROGRESS_INTERVAL_SECONDS
|
|
88
|
+
return process.wait()
|
|
89
|
+
except BaseException:
|
|
90
|
+
process.terminate()
|
|
91
|
+
try:
|
|
92
|
+
process.wait(timeout=10)
|
|
93
|
+
except subprocess.TimeoutExpired:
|
|
94
|
+
process.kill()
|
|
95
|
+
process.wait()
|
|
96
|
+
raise
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def ensure_borg_binary() -> None:
|
|
100
|
+
if shutil.which("borg") is None:
|
|
101
|
+
fail("Required executable 'borg' was not found on PATH.")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def borg_environment(settings: Environment) -> dict[str, str]:
|
|
105
|
+
environment = os.environ.copy()
|
|
106
|
+
environment.update(
|
|
107
|
+
{
|
|
108
|
+
"BORG_REPO": settings.borg_repo,
|
|
109
|
+
"BORG_BASE_DIR": str(settings.borg_base_dir),
|
|
110
|
+
"BORG_KEY_FILE": str(settings.key_file),
|
|
111
|
+
"BORG_RSH": settings.borg_rsh,
|
|
112
|
+
"BORG_PASSCOMMAND": settings.borg_passcommand,
|
|
113
|
+
}
|
|
114
|
+
)
|
|
115
|
+
if settings.borg_exit_codes is not None:
|
|
116
|
+
environment["BORG_EXIT_CODES"] = settings.borg_exit_codes
|
|
117
|
+
return environment
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def run_borg(
|
|
121
|
+
args: list[str],
|
|
122
|
+
settings: Environment,
|
|
123
|
+
operation: str,
|
|
124
|
+
context: str,
|
|
125
|
+
) -> None:
|
|
126
|
+
logger.info("Running Borg %s for %s.", operation, context)
|
|
127
|
+
command = ["borg"]
|
|
128
|
+
remote_path = settings.borg_remote_path
|
|
129
|
+
if remote_path is not None:
|
|
130
|
+
if not remote_path.strip() or "\0" in remote_path:
|
|
131
|
+
fail("BORG_REMOTE_PATH must be a nonempty Borg executable path.")
|
|
132
|
+
command.extend(("--remote-path", remote_path))
|
|
133
|
+
command.extend(args)
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
if settings.backup_progress and operation == "create":
|
|
137
|
+
command.insert(1, "--log-json")
|
|
138
|
+
returncode = run_with_progress(
|
|
139
|
+
command,
|
|
140
|
+
borg_environment(settings),
|
|
141
|
+
context,
|
|
142
|
+
)
|
|
143
|
+
else:
|
|
144
|
+
returncode = subprocess.run(
|
|
145
|
+
command,
|
|
146
|
+
env=borg_environment(settings),
|
|
147
|
+
check=False,
|
|
148
|
+
).returncode
|
|
149
|
+
except FileNotFoundError:
|
|
150
|
+
fail("Required executable 'borg' could not be started.")
|
|
151
|
+
except OSError as exc:
|
|
152
|
+
fail(
|
|
153
|
+
f"Unable to start Borg {operation} for {context}: {exc.strerror or 'I/O error'}."
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
if returncode == 0:
|
|
157
|
+
return
|
|
158
|
+
|
|
159
|
+
if returncode == 1:
|
|
160
|
+
message = f"Borg {operation} reported warnings for {context} (exit code 1)."
|
|
161
|
+
logger.warning("%s", message)
|
|
162
|
+
raise BorgWarningError(message)
|
|
163
|
+
|
|
164
|
+
fail(f"Borg {operation} failed for {context} (exit code {returncode}).")
|
|
@@ -17,6 +17,8 @@ class Environment:
|
|
|
17
17
|
borg_passcommand: str
|
|
18
18
|
borg_remote_path: str | None
|
|
19
19
|
borg_exit_codes: str | None
|
|
20
|
+
backup_progress: bool
|
|
21
|
+
backup_verbose: bool
|
|
20
22
|
|
|
21
23
|
@classmethod
|
|
22
24
|
def from_env(
|
|
@@ -46,6 +48,12 @@ class Environment:
|
|
|
46
48
|
borg_exit_codes = source.get("BORG_EXIT_CODES")
|
|
47
49
|
if borg_exit_codes not in (None, "legacy", "modern"):
|
|
48
50
|
fail("BORG_EXIT_CODES must be either 'legacy' or 'modern'.")
|
|
51
|
+
backup_progress_value = source.get("BACKUP_PROGRESS", "false").lower()
|
|
52
|
+
if backup_progress_value not in ("true", "false"):
|
|
53
|
+
fail("BACKUP_PROGRESS must be either 'true' or 'false'.")
|
|
54
|
+
backup_verbose_value = source.get("BACKUP_VERBOSE", "false").lower()
|
|
55
|
+
if backup_verbose_value not in ("true", "false"):
|
|
56
|
+
fail("BACKUP_VERBOSE must be either 'true' or 'false'.")
|
|
49
57
|
|
|
50
58
|
try:
|
|
51
59
|
server_root = required_path("SERVER_ROOT").resolve(strict=True)
|
|
@@ -62,6 +70,8 @@ class Environment:
|
|
|
62
70
|
borg_passcommand=required("BORG_PASSCOMMAND"),
|
|
63
71
|
borg_remote_path=source.get("BORG_REMOTE_PATH"),
|
|
64
72
|
borg_exit_codes=borg_exit_codes,
|
|
73
|
+
backup_progress=backup_progress_value == "true",
|
|
74
|
+
backup_verbose=backup_verbose_value == "true",
|
|
65
75
|
)
|
|
66
76
|
|
|
67
77
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: declarative-data-backup
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: Declarative Borg-based data backups
|
|
5
5
|
Requires-Python: >=3.11
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -149,6 +149,8 @@ Optional variables:
|
|
|
149
149
|
| --- | --- |
|
|
150
150
|
| `BORG_REMOTE_PATH` | Remote Borg executable path, passed as Borg's `--remote-path`. Leave unset to use Borg's default. |
|
|
151
151
|
| `BORG_EXIT_CODES` | Borg exit-code mode: `legacy` or `modern`. |
|
|
152
|
+
| `BACKUP_PROGRESS` | Set to `true` to log the latest Borg progress every 30 seconds during archive creation. Defaults to `false`. |
|
|
153
|
+
| `BACKUP_VERBOSE` | Set to `true` to show Borg's per-file create and per-archive prune output. Defaults to `false` for concise logs. |
|
|
152
154
|
|
|
153
155
|
Provide credentials through a secret manager or protected files; do not put
|
|
154
156
|
passphrases, private keys, or credentials in the YAML file, source control, or
|
|
@@ -161,7 +163,15 @@ python -m declarative_data_backup
|
|
|
161
163
|
|
|
162
164
|
The command exits with status `0` on success, nonzero on configuration or Borg
|
|
163
165
|
failure, and `130` when interrupted. Borg's exit code `1` indicates warnings:
|
|
164
|
-
the run fails and pruning is skipped.
|
|
166
|
+
the run fails and pruning is skipped. Logs include group and source start/end
|
|
167
|
+
messages, while Borg's per-file and per-archive output is disabled by default.
|
|
168
|
+
Set `BACKUP_VERBOSE=true` for detailed Borg output or `BACKUP_PROGRESS=true`
|
|
169
|
+
for live progress during archive creation; either can increase log volume.
|
|
170
|
+
Progress is logged as plain status lines, not a terminal progress bar. Each
|
|
171
|
+
line includes the group, source, elapsed time, and latest reported file/byte
|
|
172
|
+
counters or Borg phase. Unchanged counters do not prove forward progress;
|
|
173
|
+
before Borg reports a phase, the status only indicates that the process is
|
|
174
|
+
running. Warnings and statistics are not throttled.
|
|
165
175
|
|
|
166
176
|
## Configuration reference
|
|
167
177
|
|
|
@@ -289,23 +299,24 @@ container integration test.
|
|
|
289
299
|
|
|
290
300
|
## Testing
|
|
291
301
|
|
|
292
|
-
Run the unit tests:
|
|
302
|
+
Run the unit tests, integration tests, or both:
|
|
293
303
|
|
|
294
304
|
```shell
|
|
295
|
-
|
|
305
|
+
make test-unit
|
|
306
|
+
make test-integration
|
|
307
|
+
make test
|
|
296
308
|
```
|
|
297
309
|
|
|
298
|
-
The
|
|
310
|
+
The integration target opts in to both local Borg tests (which use temporary
|
|
311
|
+
encrypted repositories) and the Docker SSH test (which uses disposable
|
|
312
|
+
containers and a temporary repository). It does not use production credentials
|
|
313
|
+
or BorgBase. Docker must be installed and its daemon available.
|
|
314
|
+
|
|
315
|
+
To run either integration suite individually:
|
|
299
316
|
|
|
300
317
|
```shell
|
|
301
318
|
RUN_BORG_LOCAL_INTEGRATION=1 uv run pytest -m integration \
|
|
302
319
|
tests/integration/test_local_borg_integration.py
|
|
303
|
-
```
|
|
304
|
-
|
|
305
|
-
The opt-in Docker SSH integration test uses disposable containers and a
|
|
306
|
-
temporary repository; it does not use production credentials or BorgBase:
|
|
307
|
-
|
|
308
|
-
```shell
|
|
309
320
|
RUN_BORG_DOCKER_INTEGRATION=1 uv run pytest -m integration \
|
|
310
321
|
tests/integration/test_docker_integration.py
|
|
311
322
|
```
|
|
@@ -29,6 +29,7 @@ def _configure_preflight(monkeypatch):
|
|
|
29
29
|
def test_main_runs_all_creates_before_any_prune(
|
|
30
30
|
settings_factory,
|
|
31
31
|
monkeypatch,
|
|
32
|
+
caplog,
|
|
32
33
|
):
|
|
33
34
|
settings = settings_factory()
|
|
34
35
|
media = settings.server_root / "media"
|
|
@@ -62,6 +63,7 @@ def test_main_runs_all_creates_before_any_prune(
|
|
|
62
63
|
monkeypatch.setattr(
|
|
63
64
|
backup.uuid, "uuid4", lambda: type("UUID", (), {"hex": "a" * 32})()
|
|
64
65
|
)
|
|
66
|
+
caplog.set_level(logging.INFO)
|
|
65
67
|
|
|
66
68
|
assert backup.main(_settings_environment(settings)) == 0
|
|
67
69
|
assert [event[0] for event in events] == [
|
|
@@ -73,6 +75,8 @@ def test_main_runs_all_creates_before_any_prune(
|
|
|
73
75
|
assert events[0][3] == "zstd,5"
|
|
74
76
|
assert events[1][3] == "auto,zstd,22"
|
|
75
77
|
assert events[2][3] == 30
|
|
78
|
+
assert "Backup group 'media_backup' completed." in caplog.text
|
|
79
|
+
assert "Retention pruning for group 'media_backup' completed." in caplog.text
|
|
76
80
|
|
|
77
81
|
|
|
78
82
|
def test_main_warning_aborts_remaining_creates_and_all_pruning(
|
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
import logging
|
|
2
|
-
import os
|
|
3
|
-
import shutil
|
|
4
|
-
import subprocess
|
|
5
|
-
|
|
6
|
-
from ..environment import Environment
|
|
7
|
-
from ..errors import BorgWarningError, fail
|
|
8
|
-
|
|
9
|
-
logger = logging.getLogger(__name__)
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
def ensure_borg_binary() -> None:
|
|
13
|
-
if shutil.which("borg") is None:
|
|
14
|
-
fail("Required executable 'borg' was not found on PATH.")
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
def borg_environment(settings: Environment) -> dict[str, str]:
|
|
18
|
-
environment = os.environ.copy()
|
|
19
|
-
environment.update(
|
|
20
|
-
{
|
|
21
|
-
"BORG_REPO": settings.borg_repo,
|
|
22
|
-
"BORG_BASE_DIR": str(settings.borg_base_dir),
|
|
23
|
-
"BORG_KEY_FILE": str(settings.key_file),
|
|
24
|
-
"BORG_RSH": settings.borg_rsh,
|
|
25
|
-
"BORG_PASSCOMMAND": settings.borg_passcommand,
|
|
26
|
-
}
|
|
27
|
-
)
|
|
28
|
-
if settings.borg_exit_codes is not None:
|
|
29
|
-
environment["BORG_EXIT_CODES"] = settings.borg_exit_codes
|
|
30
|
-
return environment
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
def run_borg(
|
|
34
|
-
args: list[str],
|
|
35
|
-
settings: Environment,
|
|
36
|
-
operation: str,
|
|
37
|
-
context: str,
|
|
38
|
-
) -> None:
|
|
39
|
-
logger.info("Running Borg %s for %s.", operation, context)
|
|
40
|
-
command = ["borg"]
|
|
41
|
-
remote_path = settings.borg_remote_path
|
|
42
|
-
if remote_path is not None:
|
|
43
|
-
if not remote_path.strip() or "\0" in remote_path:
|
|
44
|
-
fail("BORG_REMOTE_PATH must be a nonempty Borg executable path.")
|
|
45
|
-
command.extend(("--remote-path", remote_path))
|
|
46
|
-
command.extend(args)
|
|
47
|
-
|
|
48
|
-
try:
|
|
49
|
-
result = subprocess.run(
|
|
50
|
-
command,
|
|
51
|
-
env=borg_environment(settings),
|
|
52
|
-
check=False,
|
|
53
|
-
)
|
|
54
|
-
except FileNotFoundError:
|
|
55
|
-
fail("Required executable 'borg' could not be started.")
|
|
56
|
-
except OSError as exc:
|
|
57
|
-
fail(
|
|
58
|
-
f"Unable to start Borg {operation} for {context}: {exc.strerror or 'I/O error'}."
|
|
59
|
-
)
|
|
60
|
-
|
|
61
|
-
if result.returncode == 0:
|
|
62
|
-
return
|
|
63
|
-
|
|
64
|
-
if result.returncode == 1:
|
|
65
|
-
message = f"Borg {operation} reported warnings for {context} (exit code 1)."
|
|
66
|
-
logger.warning("%s", message)
|
|
67
|
-
raise BorgWarningError(message)
|
|
68
|
-
|
|
69
|
-
fail(f"Borg {operation} failed for {context} (exit code {result.returncode}).")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|