declarative-data-backup 0.3.1__tar.gz → 0.4.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.3.1/src/declarative_data_backup.egg-info → declarative_data_backup-0.4.1}/PKG-INFO +17 -5
- declarative_data_backup-0.3.1/PKG-INFO → declarative_data_backup-0.4.1/README.md +16 -12
- {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.1}/pyproject.toml +1 -1
- {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.1}/src/declarative_data_backup/backup.py +73 -18
- {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.1}/src/declarative_data_backup/borg/borg_cli.py +76 -47
- {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.1}/src/declarative_data_backup/errors.py +6 -0
- declarative_data_backup-0.3.1/README.md → declarative_data_backup-0.4.1/src/declarative_data_backup.egg-info/PKG-INFO +24 -4
- {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.1}/tests/test_backup.py +10 -4
- {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.1}/setup.cfg +0 -0
- {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.1}/src/declarative_data_backup/__init__.py +0 -0
- {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.1}/src/declarative_data_backup/__main__.py +0 -0
- {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.1}/src/declarative_data_backup/borg/__init__.py +0 -0
- {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.1}/src/declarative_data_backup/borg/archive_naming.py +0 -0
- {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.1}/src/declarative_data_backup/borg/borg.py +0 -0
- {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.1}/src/declarative_data_backup/config/__init__.py +0 -0
- {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.1}/src/declarative_data_backup/config/backup_config/__init__.py +0 -0
- {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.1}/src/declarative_data_backup/config/backup_config/models.py +0 -0
- {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.1}/src/declarative_data_backup/config/backup_config/utils.py +0 -0
- {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.1}/src/declarative_data_backup/config/backup_config/yaml_parser.py +0 -0
- {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.1}/src/declarative_data_backup/config/backup_config/yaml_raw_loader.py +0 -0
- {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.1}/src/declarative_data_backup/config/logging.py +0 -0
- {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.1}/src/declarative_data_backup/environment.py +0 -0
- {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.1}/src/declarative_data_backup.egg-info/SOURCES.txt +0 -0
- {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.1}/src/declarative_data_backup.egg-info/dependency_links.txt +0 -0
- {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.1}/src/declarative_data_backup.egg-info/requires.txt +0 -0
- {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.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.4.1
|
|
4
4
|
Summary: Declarative Borg-based data backups
|
|
5
5
|
Requires-Python: >=3.11
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -173,6 +173,16 @@ counters or Borg phase. Unchanged counters do not prove forward progress;
|
|
|
173
173
|
before Borg reports a phase, the status only indicates that the process is
|
|
174
174
|
running. Warnings and statistics are not throttled.
|
|
175
175
|
|
|
176
|
+
On SIGTERM (including normal Kubernetes pod termination) or Ctrl+C, the
|
|
177
|
+
wrapper asks Borg to stop and waits up to 20 seconds for cleanup before
|
|
178
|
+
releasing its coordination lock. No further sources or pruning are started.
|
|
179
|
+
The exit status is 143 for SIGTERM or 130 for Ctrl+C. Repeated termination
|
|
180
|
+
signals are ignored during cleanup. If Borg does not stop within the timeout,
|
|
181
|
+
its process group is killed and an error warns that Borg locks may remain.
|
|
182
|
+
Use a pod termination grace period of at least 30 seconds (60 recommended);
|
|
183
|
+
forced deletion or SIGKILL cannot be handled gracefully. Never break Borg
|
|
184
|
+
locks without first confirming that no repository operation is still running.
|
|
185
|
+
|
|
176
186
|
## Configuration reference
|
|
177
187
|
|
|
178
188
|
The top level must be a nonempty YAML mapping of backup group names to group
|
|
@@ -236,10 +246,12 @@ The stable source series is derived from the canonical source path and includes
|
|
|
236
246
|
a SHA-256 suffix. Borg receives the canonical absolute path as the source, so
|
|
237
247
|
the original directory hierarchy is retained inside the archive.
|
|
238
248
|
|
|
239
|
-
The application
|
|
240
|
-
If a create
|
|
241
|
-
|
|
242
|
-
|
|
249
|
+
The application attempts every configured backup group before pruning any
|
|
250
|
+
source. If a group has a create failure or Borg reports warnings, pruning is
|
|
251
|
+
skipped for that group, but other groups that completed successfully are still
|
|
252
|
+
pruned. Failures are reported and make the overall run fail. Retries use a new
|
|
253
|
+
run UUID and may create another archive for that day; archives are never
|
|
254
|
+
overwritten to handle a collision.
|
|
243
255
|
|
|
244
256
|
`--keep-daily N` retains the latest archive in up to `N` daily buckets. It does
|
|
245
257
|
not guarantee `N` successful calendar days of recovery if scheduled runs fail
|
|
@@ -1,11 +1,3 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: declarative-data-backup
|
|
3
|
-
Version: 0.3.1
|
|
4
|
-
Summary: Declarative Borg-based data backups
|
|
5
|
-
Requires-Python: >=3.11
|
|
6
|
-
Description-Content-Type: text/markdown
|
|
7
|
-
Requires-Dist: PyYAML<7,>=6
|
|
8
|
-
|
|
9
1
|
# Declarative Data Backup
|
|
10
2
|
|
|
11
3
|
Declarative Data Backup creates encrypted Borg archives for configured source
|
|
@@ -173,6 +165,16 @@ counters or Borg phase. Unchanged counters do not prove forward progress;
|
|
|
173
165
|
before Borg reports a phase, the status only indicates that the process is
|
|
174
166
|
running. Warnings and statistics are not throttled.
|
|
175
167
|
|
|
168
|
+
On SIGTERM (including normal Kubernetes pod termination) or Ctrl+C, the
|
|
169
|
+
wrapper asks Borg to stop and waits up to 20 seconds for cleanup before
|
|
170
|
+
releasing its coordination lock. No further sources or pruning are started.
|
|
171
|
+
The exit status is 143 for SIGTERM or 130 for Ctrl+C. Repeated termination
|
|
172
|
+
signals are ignored during cleanup. If Borg does not stop within the timeout,
|
|
173
|
+
its process group is killed and an error warns that Borg locks may remain.
|
|
174
|
+
Use a pod termination grace period of at least 30 seconds (60 recommended);
|
|
175
|
+
forced deletion or SIGKILL cannot be handled gracefully. Never break Borg
|
|
176
|
+
locks without first confirming that no repository operation is still running.
|
|
177
|
+
|
|
176
178
|
## Configuration reference
|
|
177
179
|
|
|
178
180
|
The top level must be a nonempty YAML mapping of backup group names to group
|
|
@@ -236,10 +238,12 @@ The stable source series is derived from the canonical source path and includes
|
|
|
236
238
|
a SHA-256 suffix. Borg receives the canonical absolute path as the source, so
|
|
237
239
|
the original directory hierarchy is retained inside the archive.
|
|
238
240
|
|
|
239
|
-
The application
|
|
240
|
-
If a create
|
|
241
|
-
|
|
242
|
-
|
|
241
|
+
The application attempts every configured backup group before pruning any
|
|
242
|
+
source. If a group has a create failure or Borg reports warnings, pruning is
|
|
243
|
+
skipped for that group, but other groups that completed successfully are still
|
|
244
|
+
pruned. Failures are reported and make the overall run fail. Retries use a new
|
|
245
|
+
run UUID and may create another archive for that day; archives are never
|
|
246
|
+
overwritten to handle a collision.
|
|
243
247
|
|
|
244
248
|
`--keep-daily N` retains the latest archive in up to `N` daily buckets. It does
|
|
245
249
|
not guarantee `N` successful calendar days of recovery if scheduled runs fail
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import logging
|
|
2
2
|
import os
|
|
3
|
+
import signal
|
|
3
4
|
import uuid
|
|
4
5
|
from collections.abc import Mapping
|
|
5
6
|
from datetime import UTC, datetime
|
|
7
|
+
from types import FrameType
|
|
6
8
|
|
|
7
9
|
from .borg import (
|
|
8
10
|
backup_folder,
|
|
@@ -14,7 +16,7 @@ from .borg.borg_cli import ensure_borg_binary
|
|
|
14
16
|
from .config import configure_logging, load_yaml, parse_backup_definitions
|
|
15
17
|
from .config.backup_config.models import BackupDefinition
|
|
16
18
|
from .environment import Environment
|
|
17
|
-
from .errors import BackupError, BorgWarningError
|
|
19
|
+
from .errors import BackupError, BackupInterrupted, BorgWarningError
|
|
18
20
|
|
|
19
21
|
logger = logging.getLogger(__name__)
|
|
20
22
|
|
|
@@ -54,42 +56,86 @@ def run_backup(
|
|
|
54
56
|
run_id = uuid.uuid4().hex
|
|
55
57
|
with repository_lock(settings.borg_base_dir / "backup.lock"):
|
|
56
58
|
created_at = datetime.now(UTC)
|
|
59
|
+
successful_definitions = []
|
|
60
|
+
failures: list[BackupError] = []
|
|
57
61
|
for definition in definitions:
|
|
58
62
|
logger.info(
|
|
59
63
|
"Starting backup group '%s' (%d sources).",
|
|
60
64
|
definition.name,
|
|
61
65
|
len(definition.sources),
|
|
62
66
|
)
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
+
try:
|
|
68
|
+
for folder in definition.sources:
|
|
69
|
+
backup_folder(
|
|
70
|
+
folder,
|
|
71
|
+
definition.compression,
|
|
72
|
+
definition.name,
|
|
73
|
+
settings,
|
|
74
|
+
created_at,
|
|
75
|
+
run_id,
|
|
76
|
+
definition.excludes,
|
|
77
|
+
)
|
|
78
|
+
except BackupError as exc:
|
|
79
|
+
logger.error(
|
|
80
|
+
"Backup group '%s' failed; retention pruning will be skipped "
|
|
81
|
+
"for this group: %s",
|
|
67
82
|
definition.name,
|
|
68
|
-
|
|
69
|
-
created_at,
|
|
70
|
-
run_id,
|
|
71
|
-
definition.excludes,
|
|
83
|
+
exc,
|
|
72
84
|
)
|
|
85
|
+
failures.append(exc)
|
|
86
|
+
continue
|
|
87
|
+
|
|
73
88
|
logger.info("Backup group '%s' completed.", definition.name)
|
|
89
|
+
successful_definitions.append(definition)
|
|
74
90
|
|
|
75
|
-
for definition in
|
|
91
|
+
for definition in successful_definitions:
|
|
76
92
|
logger.info(
|
|
77
93
|
"Starting retention pruning for group '%s' (%d sources).",
|
|
78
94
|
definition.name,
|
|
79
95
|
len(definition.sources),
|
|
80
96
|
)
|
|
97
|
+
pruning_succeeded = True
|
|
81
98
|
for folder in definition.sources:
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
99
|
+
try:
|
|
100
|
+
prune(
|
|
101
|
+
folder,
|
|
102
|
+
definition.retention_days,
|
|
103
|
+
definition.name,
|
|
104
|
+
settings,
|
|
105
|
+
)
|
|
106
|
+
except BackupError as exc:
|
|
107
|
+
logger.error(
|
|
108
|
+
"Retention pruning failed for group '%s' source '%s': %s",
|
|
109
|
+
definition.name,
|
|
110
|
+
folder,
|
|
111
|
+
exc,
|
|
112
|
+
)
|
|
113
|
+
failures.append(exc)
|
|
114
|
+
pruning_succeeded = False
|
|
115
|
+
|
|
116
|
+
if pruning_succeeded:
|
|
117
|
+
logger.info(
|
|
118
|
+
"Retention pruning for group '%s' completed.", definition.name
|
|
87
119
|
)
|
|
88
|
-
|
|
120
|
+
|
|
121
|
+
if failures:
|
|
122
|
+
message = "; ".join(str(failure) for failure in failures)
|
|
123
|
+
if any(isinstance(failure, BorgWarningError) for failure in failures):
|
|
124
|
+
raise BorgWarningError(message)
|
|
125
|
+
raise BackupError(message)
|
|
89
126
|
|
|
90
127
|
|
|
91
128
|
def main(environ: Mapping[str, str] | None = None) -> int:
|
|
92
129
|
configure_logging()
|
|
130
|
+
|
|
131
|
+
def interrupt(signum: int, frame: FrameType | None) -> None:
|
|
132
|
+
# Repeated signals must not interrupt Borg cleanup or lock release.
|
|
133
|
+
signal.signal(signal.SIGTERM, signal.SIG_IGN)
|
|
134
|
+
signal.signal(signal.SIGINT, signal.SIG_IGN)
|
|
135
|
+
raise BackupInterrupted(signum)
|
|
136
|
+
|
|
137
|
+
previous_sigterm = signal.signal(signal.SIGTERM, interrupt)
|
|
138
|
+
previous_sigint = signal.signal(signal.SIGINT, interrupt)
|
|
93
139
|
try:
|
|
94
140
|
settings = Environment.from_env(environ)
|
|
95
141
|
logger.info("Starting backup using configuration %s", settings.config_path)
|
|
@@ -100,8 +146,8 @@ def main(environ: Mapping[str, str] | None = None) -> int:
|
|
|
100
146
|
|
|
101
147
|
except BorgWarningError as exc:
|
|
102
148
|
logger.error(
|
|
103
|
-
"Backup
|
|
104
|
-
"pruning
|
|
149
|
+
"Backup failed because Borg reported warnings; "
|
|
150
|
+
"retention pruning may have completed for other groups: %s",
|
|
105
151
|
exc,
|
|
106
152
|
)
|
|
107
153
|
return 1
|
|
@@ -114,6 +160,15 @@ def main(environ: Mapping[str, str] | None = None) -> int:
|
|
|
114
160
|
exc.strerror or "I/O error",
|
|
115
161
|
)
|
|
116
162
|
return 1
|
|
163
|
+
except BackupInterrupted as exc:
|
|
164
|
+
logger.error(
|
|
165
|
+
"Interrupted by %s; no further backups or pruning will run.",
|
|
166
|
+
signal.Signals(exc.signum).name,
|
|
167
|
+
)
|
|
168
|
+
return 128 + exc.signum
|
|
117
169
|
except KeyboardInterrupt:
|
|
118
170
|
logger.error("Interrupted.")
|
|
119
171
|
return 130
|
|
172
|
+
finally:
|
|
173
|
+
signal.signal(signal.SIGTERM, previous_sigterm)
|
|
174
|
+
signal.signal(signal.SIGINT, previous_sigint)
|
|
@@ -3,15 +3,63 @@ import logging
|
|
|
3
3
|
import os
|
|
4
4
|
import selectors
|
|
5
5
|
import shutil
|
|
6
|
+
import signal
|
|
6
7
|
import subprocess
|
|
7
8
|
import sys
|
|
8
9
|
import time
|
|
10
|
+
from collections.abc import Generator
|
|
11
|
+
from contextlib import contextmanager
|
|
9
12
|
|
|
10
13
|
from ..environment import Environment
|
|
11
14
|
from ..errors import BorgWarningError, fail
|
|
12
15
|
|
|
13
16
|
logger = logging.getLogger(__name__)
|
|
14
17
|
PROGRESS_INTERVAL_SECONDS = 30
|
|
18
|
+
SHUTDOWN_TIMEOUT_SECONDS = 20
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@contextmanager
|
|
22
|
+
def borg_process(
|
|
23
|
+
command: list[str],
|
|
24
|
+
environment: dict[str, str],
|
|
25
|
+
*,
|
|
26
|
+
capture_stderr: bool = False,
|
|
27
|
+
) -> Generator[subprocess.Popen[bytes], None, None]:
|
|
28
|
+
with subprocess.Popen(
|
|
29
|
+
command,
|
|
30
|
+
env=environment,
|
|
31
|
+
start_new_session=True,
|
|
32
|
+
stderr=subprocess.PIPE if capture_stderr else None,
|
|
33
|
+
) as process:
|
|
34
|
+
try:
|
|
35
|
+
yield process
|
|
36
|
+
except BaseException:
|
|
37
|
+
if process.poll() is None:
|
|
38
|
+
logger.warning(
|
|
39
|
+
"Stopping Borg (pid=%d); waiting up to %d seconds for cleanup.",
|
|
40
|
+
process.pid,
|
|
41
|
+
SHUTDOWN_TIMEOUT_SECONDS,
|
|
42
|
+
)
|
|
43
|
+
process.terminate()
|
|
44
|
+
try:
|
|
45
|
+
_, stderr = process.communicate(timeout=SHUTDOWN_TIMEOUT_SECONDS)
|
|
46
|
+
except subprocess.TimeoutExpired:
|
|
47
|
+
logger.error(
|
|
48
|
+
"Borg did not stop in time; killing its process group. "
|
|
49
|
+
"Stale Borg locks may require inspection before retrying."
|
|
50
|
+
)
|
|
51
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
52
|
+
_, stderr = process.communicate()
|
|
53
|
+
if stderr:
|
|
54
|
+
sys.stderr.write(stderr.decode("utf-8", errors="replace"))
|
|
55
|
+
sys.stderr.flush()
|
|
56
|
+
logger.warning("Borg stopped (exit code %d).", process.returncode)
|
|
57
|
+
raise
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def run_without_progress(command: list[str], environment: dict[str, str]) -> int:
|
|
61
|
+
with borg_process(command, environment) as process:
|
|
62
|
+
return process.wait()
|
|
15
63
|
|
|
16
64
|
|
|
17
65
|
def run_with_progress(
|
|
@@ -51,49 +99,34 @@ def run_with_progress(
|
|
|
51
99
|
else:
|
|
52
100
|
print(text, file=sys.stderr, flush=True)
|
|
53
101
|
|
|
54
|
-
with
|
|
55
|
-
command,
|
|
56
|
-
env=environment,
|
|
57
|
-
stderr=subprocess.PIPE,
|
|
58
|
-
) as process:
|
|
102
|
+
with borg_process(command, environment, capture_stderr=True) as process:
|
|
59
103
|
assert process.stderr is not None
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
|
104
|
+
with selectors.DefaultSelector() as selector:
|
|
105
|
+
selector.register(process.stderr, selectors.EVENT_READ)
|
|
106
|
+
pending = b""
|
|
107
|
+
while selector.get_map():
|
|
108
|
+
for key, _ in selector.select(max(0, next_report - time.monotonic())):
|
|
109
|
+
chunk = os.read(key.fd, 65536)
|
|
110
|
+
if not chunk:
|
|
111
|
+
selector.unregister(key.fileobj)
|
|
112
|
+
if pending:
|
|
113
|
+
handle_line(pending)
|
|
114
|
+
break
|
|
115
|
+
pending += chunk
|
|
116
|
+
while b"\n" in pending:
|
|
117
|
+
line, pending = pending.split(b"\n", 1)
|
|
118
|
+
handle_line(line)
|
|
119
|
+
now = time.monotonic()
|
|
120
|
+
if now >= next_report and selector.get_map():
|
|
121
|
+
logger.info(
|
|
122
|
+
"Borg progress for %s (elapsed_seconds=%.0f; "
|
|
123
|
+
"latest reported: %s).",
|
|
124
|
+
context,
|
|
125
|
+
now - started_at,
|
|
126
|
+
latest_progress,
|
|
127
|
+
)
|
|
128
|
+
next_report = now + PROGRESS_INTERVAL_SECONDS
|
|
129
|
+
return process.wait()
|
|
97
130
|
|
|
98
131
|
|
|
99
132
|
def ensure_borg_binary() -> None:
|
|
@@ -141,11 +174,7 @@ def run_borg(
|
|
|
141
174
|
context,
|
|
142
175
|
)
|
|
143
176
|
else:
|
|
144
|
-
returncode =
|
|
145
|
-
command,
|
|
146
|
-
env=borg_environment(settings),
|
|
147
|
-
check=False,
|
|
148
|
-
).returncode
|
|
177
|
+
returncode = run_without_progress(command, borg_environment(settings))
|
|
149
178
|
except FileNotFoundError:
|
|
150
179
|
fail("Required executable 'borg' could not be started.")
|
|
151
180
|
except OSError as exc:
|
|
@@ -9,5 +9,11 @@ class BorgWarningError(BackupError):
|
|
|
9
9
|
"""Raised when Borg completes with warnings that make the run unsafe."""
|
|
10
10
|
|
|
11
11
|
|
|
12
|
+
class BackupInterrupted(KeyboardInterrupt):
|
|
13
|
+
def __init__(self, signum: int) -> None:
|
|
14
|
+
self.signum = signum
|
|
15
|
+
super().__init__(f"Interrupted by signal {signum}.")
|
|
16
|
+
|
|
17
|
+
|
|
12
18
|
def fail(message: str) -> NoReturn:
|
|
13
19
|
raise BackupError(message)
|
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: declarative-data-backup
|
|
3
|
+
Version: 0.4.1
|
|
4
|
+
Summary: Declarative Borg-based data backups
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: PyYAML<7,>=6
|
|
8
|
+
|
|
1
9
|
# Declarative Data Backup
|
|
2
10
|
|
|
3
11
|
Declarative Data Backup creates encrypted Borg archives for configured source
|
|
@@ -165,6 +173,16 @@ counters or Borg phase. Unchanged counters do not prove forward progress;
|
|
|
165
173
|
before Borg reports a phase, the status only indicates that the process is
|
|
166
174
|
running. Warnings and statistics are not throttled.
|
|
167
175
|
|
|
176
|
+
On SIGTERM (including normal Kubernetes pod termination) or Ctrl+C, the
|
|
177
|
+
wrapper asks Borg to stop and waits up to 20 seconds for cleanup before
|
|
178
|
+
releasing its coordination lock. No further sources or pruning are started.
|
|
179
|
+
The exit status is 143 for SIGTERM or 130 for Ctrl+C. Repeated termination
|
|
180
|
+
signals are ignored during cleanup. If Borg does not stop within the timeout,
|
|
181
|
+
its process group is killed and an error warns that Borg locks may remain.
|
|
182
|
+
Use a pod termination grace period of at least 30 seconds (60 recommended);
|
|
183
|
+
forced deletion or SIGKILL cannot be handled gracefully. Never break Borg
|
|
184
|
+
locks without first confirming that no repository operation is still running.
|
|
185
|
+
|
|
168
186
|
## Configuration reference
|
|
169
187
|
|
|
170
188
|
The top level must be a nonempty YAML mapping of backup group names to group
|
|
@@ -228,10 +246,12 @@ The stable source series is derived from the canonical source path and includes
|
|
|
228
246
|
a SHA-256 suffix. Borg receives the canonical absolute path as the source, so
|
|
229
247
|
the original directory hierarchy is retained inside the archive.
|
|
230
248
|
|
|
231
|
-
The application
|
|
232
|
-
If a create
|
|
233
|
-
|
|
234
|
-
|
|
249
|
+
The application attempts every configured backup group before pruning any
|
|
250
|
+
source. If a group has a create failure or Borg reports warnings, pruning is
|
|
251
|
+
skipped for that group, but other groups that completed successfully are still
|
|
252
|
+
pruned. Failures are reported and make the overall run fail. Retries use a new
|
|
253
|
+
run UUID and may create another archive for that day; archives are never
|
|
254
|
+
overwritten to handle a collision.
|
|
235
255
|
|
|
236
256
|
`--keep-daily N` retains the latest archive in up to `N` daily buckets. It does
|
|
237
257
|
not guarantee `N` successful calendar days of recovery if scheduled runs fail
|
|
@@ -79,7 +79,7 @@ def test_main_runs_all_creates_before_any_prune(
|
|
|
79
79
|
assert "Retention pruning for group 'media_backup' completed." in caplog.text
|
|
80
80
|
|
|
81
81
|
|
|
82
|
-
def
|
|
82
|
+
def test_main_warning_skips_failed_group_but_prunes_successful_groups(
|
|
83
83
|
settings_factory,
|
|
84
84
|
monkeypatch,
|
|
85
85
|
caplog,
|
|
@@ -91,14 +91,15 @@ def test_main_warning_aborts_remaining_creates_and_all_pruning(
|
|
|
91
91
|
second.mkdir()
|
|
92
92
|
_write_config(
|
|
93
93
|
settings,
|
|
94
|
-
f"
|
|
94
|
+
f"failed:\n folders: [{first}]\nsucceeded:\n folders: [{second}]\n",
|
|
95
95
|
)
|
|
96
96
|
_configure_preflight(monkeypatch)
|
|
97
97
|
calls = []
|
|
98
98
|
|
|
99
99
|
def warning(folder, *args):
|
|
100
100
|
calls.append(("create", folder))
|
|
101
|
-
|
|
101
|
+
if folder == first:
|
|
102
|
+
raise BorgWarningError(f"warning for {folder}")
|
|
102
103
|
|
|
103
104
|
monkeypatch.setattr(backup, "backup_folder", warning)
|
|
104
105
|
monkeypatch.setattr(
|
|
@@ -109,7 +110,11 @@ def test_main_warning_aborts_remaining_creates_and_all_pruning(
|
|
|
109
110
|
caplog.set_level(logging.ERROR)
|
|
110
111
|
|
|
111
112
|
assert backup.main(_settings_environment(settings)) == 1
|
|
112
|
-
assert calls == [
|
|
113
|
+
assert calls == [
|
|
114
|
+
("create", first),
|
|
115
|
+
("create", second),
|
|
116
|
+
("prune", second),
|
|
117
|
+
]
|
|
113
118
|
assert "warnings" in caplog.text
|
|
114
119
|
assert str(first) in caplog.text
|
|
115
120
|
|
|
@@ -269,6 +274,7 @@ def test_main_prune_failure_is_nonzero_and_scoped(
|
|
|
269
274
|
("create", first),
|
|
270
275
|
("create", second),
|
|
271
276
|
("prune", first),
|
|
277
|
+
("prune", second),
|
|
272
278
|
]
|
|
273
279
|
|
|
274
280
|
|
|
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
|
|
File without changes
|