declarative-data-backup 0.3.1__tar.gz → 0.4.0__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.0}/PKG-INFO +11 -1
- declarative_data_backup-0.3.1/PKG-INFO → declarative_data_backup-0.4.0/README.md +10 -8
- {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.0}/pyproject.toml +1 -1
- {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.0}/src/declarative_data_backup/backup.py +21 -1
- {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.0}/src/declarative_data_backup/borg/borg_cli.py +76 -47
- {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.0}/src/declarative_data_backup/errors.py +6 -0
- declarative_data_backup-0.3.1/README.md → declarative_data_backup-0.4.0/src/declarative_data_backup.egg-info/PKG-INFO +18 -0
- {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.0}/setup.cfg +0 -0
- {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.0}/src/declarative_data_backup/__init__.py +0 -0
- {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.0}/src/declarative_data_backup/__main__.py +0 -0
- {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.0}/src/declarative_data_backup/borg/__init__.py +0 -0
- {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.0}/src/declarative_data_backup/borg/archive_naming.py +0 -0
- {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.0}/src/declarative_data_backup/borg/borg.py +0 -0
- {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.0}/src/declarative_data_backup/config/__init__.py +0 -0
- {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.0}/src/declarative_data_backup/config/backup_config/__init__.py +0 -0
- {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.0}/src/declarative_data_backup/config/backup_config/models.py +0 -0
- {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.0}/src/declarative_data_backup/config/backup_config/utils.py +0 -0
- {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.0}/src/declarative_data_backup/config/backup_config/yaml_parser.py +0 -0
- {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.0}/src/declarative_data_backup/config/backup_config/yaml_raw_loader.py +0 -0
- {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.0}/src/declarative_data_backup/config/logging.py +0 -0
- {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.0}/src/declarative_data_backup/environment.py +0 -0
- {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.0}/src/declarative_data_backup.egg-info/SOURCES.txt +0 -0
- {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.0}/src/declarative_data_backup.egg-info/dependency_links.txt +0 -0
- {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.0}/src/declarative_data_backup.egg-info/requires.txt +0 -0
- {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.0}/src/declarative_data_backup.egg-info/top_level.txt +0 -0
- {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.0}/tests/test_backup.py +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.0
|
|
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
|
|
@@ -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
|
|
@@ -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
|
|
|
@@ -90,6 +92,15 @@ def run_backup(
|
|
|
90
92
|
|
|
91
93
|
def main(environ: Mapping[str, str] | None = None) -> int:
|
|
92
94
|
configure_logging()
|
|
95
|
+
|
|
96
|
+
def interrupt(signum: int, frame: FrameType | None) -> None:
|
|
97
|
+
# Repeated signals must not interrupt Borg cleanup or lock release.
|
|
98
|
+
signal.signal(signal.SIGTERM, signal.SIG_IGN)
|
|
99
|
+
signal.signal(signal.SIGINT, signal.SIG_IGN)
|
|
100
|
+
raise BackupInterrupted(signum)
|
|
101
|
+
|
|
102
|
+
previous_sigterm = signal.signal(signal.SIGTERM, interrupt)
|
|
103
|
+
previous_sigint = signal.signal(signal.SIGINT, interrupt)
|
|
93
104
|
try:
|
|
94
105
|
settings = Environment.from_env(environ)
|
|
95
106
|
logger.info("Starting backup using configuration %s", settings.config_path)
|
|
@@ -114,6 +125,15 @@ def main(environ: Mapping[str, str] | None = None) -> int:
|
|
|
114
125
|
exc.strerror or "I/O error",
|
|
115
126
|
)
|
|
116
127
|
return 1
|
|
128
|
+
except BackupInterrupted as exc:
|
|
129
|
+
logger.error(
|
|
130
|
+
"Interrupted by %s; no further backups or pruning will run.",
|
|
131
|
+
signal.Signals(exc.signum).name,
|
|
132
|
+
)
|
|
133
|
+
return 128 + exc.signum
|
|
117
134
|
except KeyboardInterrupt:
|
|
118
135
|
logger.error("Interrupted.")
|
|
119
136
|
return 130
|
|
137
|
+
finally:
|
|
138
|
+
signal.signal(signal.SIGTERM, previous_sigterm)
|
|
139
|
+
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.0
|
|
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
|
|
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
|
|
File without changes
|