declarative-data-backup 0.3.0__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.0/src/declarative_data_backup.egg-info → declarative_data_backup-0.4.0}/PKG-INFO +17 -2
- declarative_data_backup-0.3.0/PKG-INFO → declarative_data_backup-0.4.0/README.md +16 -9
- {declarative_data_backup-0.3.0 → declarative_data_backup-0.4.0}/pyproject.toml +1 -1
- {declarative_data_backup-0.3.0 → declarative_data_backup-0.4.0}/src/declarative_data_backup/backup.py +21 -1
- declarative_data_backup-0.4.0/src/declarative_data_backup/borg/borg_cli.py +193 -0
- {declarative_data_backup-0.3.0 → declarative_data_backup-0.4.0}/src/declarative_data_backup/errors.py +6 -0
- declarative_data_backup-0.3.0/README.md → declarative_data_backup-0.4.0/src/declarative_data_backup.egg-info/PKG-INFO +24 -1
- declarative_data_backup-0.3.0/src/declarative_data_backup/borg/borg_cli.py +0 -69
- {declarative_data_backup-0.3.0 → declarative_data_backup-0.4.0}/setup.cfg +0 -0
- {declarative_data_backup-0.3.0 → declarative_data_backup-0.4.0}/src/declarative_data_backup/__init__.py +0 -0
- {declarative_data_backup-0.3.0 → declarative_data_backup-0.4.0}/src/declarative_data_backup/__main__.py +0 -0
- {declarative_data_backup-0.3.0 → declarative_data_backup-0.4.0}/src/declarative_data_backup/borg/__init__.py +0 -0
- {declarative_data_backup-0.3.0 → declarative_data_backup-0.4.0}/src/declarative_data_backup/borg/archive_naming.py +0 -0
- {declarative_data_backup-0.3.0 → declarative_data_backup-0.4.0}/src/declarative_data_backup/borg/borg.py +0 -0
- {declarative_data_backup-0.3.0 → declarative_data_backup-0.4.0}/src/declarative_data_backup/config/__init__.py +0 -0
- {declarative_data_backup-0.3.0 → declarative_data_backup-0.4.0}/src/declarative_data_backup/config/backup_config/__init__.py +0 -0
- {declarative_data_backup-0.3.0 → declarative_data_backup-0.4.0}/src/declarative_data_backup/config/backup_config/models.py +0 -0
- {declarative_data_backup-0.3.0 → declarative_data_backup-0.4.0}/src/declarative_data_backup/config/backup_config/utils.py +0 -0
- {declarative_data_backup-0.3.0 → declarative_data_backup-0.4.0}/src/declarative_data_backup/config/backup_config/yaml_parser.py +0 -0
- {declarative_data_backup-0.3.0 → declarative_data_backup-0.4.0}/src/declarative_data_backup/config/backup_config/yaml_raw_loader.py +0 -0
- {declarative_data_backup-0.3.0 → declarative_data_backup-0.4.0}/src/declarative_data_backup/config/logging.py +0 -0
- {declarative_data_backup-0.3.0 → declarative_data_backup-0.4.0}/src/declarative_data_backup/environment.py +0 -0
- {declarative_data_backup-0.3.0 → declarative_data_backup-0.4.0}/src/declarative_data_backup.egg-info/SOURCES.txt +0 -0
- {declarative_data_backup-0.3.0 → declarative_data_backup-0.4.0}/src/declarative_data_backup.egg-info/dependency_links.txt +0 -0
- {declarative_data_backup-0.3.0 → declarative_data_backup-0.4.0}/src/declarative_data_backup.egg-info/requires.txt +0 -0
- {declarative_data_backup-0.3.0 → declarative_data_backup-0.4.0}/src/declarative_data_backup.egg-info/top_level.txt +0 -0
- {declarative_data_backup-0.3.0 → 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
|
|
@@ -149,7 +149,7 @@ 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
|
|
152
|
+
| `BACKUP_PROGRESS` | Set to `true` to log the latest Borg progress every 30 seconds during archive creation. Defaults to `false`. |
|
|
153
153
|
| `BACKUP_VERBOSE` | Set to `true` to show Borg's per-file create and per-archive prune output. Defaults to `false` for concise logs. |
|
|
154
154
|
|
|
155
155
|
Provide credentials through a secret manager or protected files; do not put
|
|
@@ -167,6 +167,21 @@ the run fails and pruning is skipped. Logs include group and source start/end
|
|
|
167
167
|
messages, while Borg's per-file and per-archive output is disabled by default.
|
|
168
168
|
Set `BACKUP_VERBOSE=true` for detailed Borg output or `BACKUP_PROGRESS=true`
|
|
169
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.
|
|
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.
|
|
170
185
|
|
|
171
186
|
## Configuration reference
|
|
172
187
|
|
|
@@ -1,11 +1,3 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: declarative-data-backup
|
|
3
|
-
Version: 0.3.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
|
-
|
|
9
1
|
# Declarative Data Backup
|
|
10
2
|
|
|
11
3
|
Declarative Data Backup creates encrypted Borg archives for configured source
|
|
@@ -149,7 +141,7 @@ Optional variables:
|
|
|
149
141
|
| --- | --- |
|
|
150
142
|
| `BORG_REMOTE_PATH` | Remote Borg executable path, passed as Borg's `--remote-path`. Leave unset to use Borg's default. |
|
|
151
143
|
| `BORG_EXIT_CODES` | Borg exit-code mode: `legacy` or `modern`. |
|
|
152
|
-
| `BACKUP_PROGRESS` | Set to `true` to
|
|
144
|
+
| `BACKUP_PROGRESS` | Set to `true` to log the latest Borg progress every 30 seconds during archive creation. Defaults to `false`. |
|
|
153
145
|
| `BACKUP_VERBOSE` | Set to `true` to show Borg's per-file create and per-archive prune output. Defaults to `false` for concise logs. |
|
|
154
146
|
|
|
155
147
|
Provide credentials through a secret manager or protected files; do not put
|
|
@@ -167,6 +159,21 @@ the run fails and pruning is skipped. Logs include group and source start/end
|
|
|
167
159
|
messages, while Borg's per-file and per-archive output is disabled by default.
|
|
168
160
|
Set `BACKUP_VERBOSE=true` for detailed Borg output or `BACKUP_PROGRESS=true`
|
|
169
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.
|
|
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.
|
|
170
177
|
|
|
171
178
|
## Configuration reference
|
|
172
179
|
|
|
@@ -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)
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import selectors
|
|
5
|
+
import shutil
|
|
6
|
+
import signal
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
from collections.abc import Generator
|
|
11
|
+
from contextlib import contextmanager
|
|
12
|
+
|
|
13
|
+
from ..environment import Environment
|
|
14
|
+
from ..errors import BorgWarningError, fail
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
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()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def run_with_progress(
|
|
66
|
+
command: list[str], environment: dict[str, str], context: str
|
|
67
|
+
) -> int:
|
|
68
|
+
started_at = time.monotonic()
|
|
69
|
+
next_report = started_at + PROGRESS_INTERVAL_SECONDS
|
|
70
|
+
latest_progress = "Waiting for Borg progress (process running)."
|
|
71
|
+
|
|
72
|
+
def handle_line(line: bytes) -> None:
|
|
73
|
+
nonlocal latest_progress
|
|
74
|
+
text = line.decode("utf-8", errors="replace")
|
|
75
|
+
try:
|
|
76
|
+
event = json.loads(text)
|
|
77
|
+
except json.JSONDecodeError:
|
|
78
|
+
print(text, file=sys.stderr, flush=True)
|
|
79
|
+
return
|
|
80
|
+
if not isinstance(event, dict):
|
|
81
|
+
print(text, file=sys.stderr, flush=True)
|
|
82
|
+
return
|
|
83
|
+
kind = event.get("type")
|
|
84
|
+
if kind in ("archive_progress", "progress_message", "progress_percent"):
|
|
85
|
+
if event.get("finished"):
|
|
86
|
+
return
|
|
87
|
+
if kind == "archive_progress":
|
|
88
|
+
latest_progress = (
|
|
89
|
+
f"files={event.get('nfiles')} "
|
|
90
|
+
f"original_bytes={event.get('original_size')} "
|
|
91
|
+
f"compressed_bytes={event.get('compressed_size')} "
|
|
92
|
+
f"deduplicated_bytes={event.get('deduplicated_size')}"
|
|
93
|
+
)
|
|
94
|
+
else:
|
|
95
|
+
latest_progress = str(event.get("message", text))
|
|
96
|
+
return
|
|
97
|
+
if kind == "log_message":
|
|
98
|
+
print(event.get("message", text), file=sys.stderr, flush=True)
|
|
99
|
+
else:
|
|
100
|
+
print(text, file=sys.stderr, flush=True)
|
|
101
|
+
|
|
102
|
+
with borg_process(command, environment, capture_stderr=True) as process:
|
|
103
|
+
assert process.stderr is not None
|
|
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()
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def ensure_borg_binary() -> None:
|
|
133
|
+
if shutil.which("borg") is None:
|
|
134
|
+
fail("Required executable 'borg' was not found on PATH.")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def borg_environment(settings: Environment) -> dict[str, str]:
|
|
138
|
+
environment = os.environ.copy()
|
|
139
|
+
environment.update(
|
|
140
|
+
{
|
|
141
|
+
"BORG_REPO": settings.borg_repo,
|
|
142
|
+
"BORG_BASE_DIR": str(settings.borg_base_dir),
|
|
143
|
+
"BORG_KEY_FILE": str(settings.key_file),
|
|
144
|
+
"BORG_RSH": settings.borg_rsh,
|
|
145
|
+
"BORG_PASSCOMMAND": settings.borg_passcommand,
|
|
146
|
+
}
|
|
147
|
+
)
|
|
148
|
+
if settings.borg_exit_codes is not None:
|
|
149
|
+
environment["BORG_EXIT_CODES"] = settings.borg_exit_codes
|
|
150
|
+
return environment
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def run_borg(
|
|
154
|
+
args: list[str],
|
|
155
|
+
settings: Environment,
|
|
156
|
+
operation: str,
|
|
157
|
+
context: str,
|
|
158
|
+
) -> None:
|
|
159
|
+
logger.info("Running Borg %s for %s.", operation, context)
|
|
160
|
+
command = ["borg"]
|
|
161
|
+
remote_path = settings.borg_remote_path
|
|
162
|
+
if remote_path is not None:
|
|
163
|
+
if not remote_path.strip() or "\0" in remote_path:
|
|
164
|
+
fail("BORG_REMOTE_PATH must be a nonempty Borg executable path.")
|
|
165
|
+
command.extend(("--remote-path", remote_path))
|
|
166
|
+
command.extend(args)
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
if settings.backup_progress and operation == "create":
|
|
170
|
+
command.insert(1, "--log-json")
|
|
171
|
+
returncode = run_with_progress(
|
|
172
|
+
command,
|
|
173
|
+
borg_environment(settings),
|
|
174
|
+
context,
|
|
175
|
+
)
|
|
176
|
+
else:
|
|
177
|
+
returncode = run_without_progress(command, borg_environment(settings))
|
|
178
|
+
except FileNotFoundError:
|
|
179
|
+
fail("Required executable 'borg' could not be started.")
|
|
180
|
+
except OSError as exc:
|
|
181
|
+
fail(
|
|
182
|
+
f"Unable to start Borg {operation} for {context}: {exc.strerror or 'I/O error'}."
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
if returncode == 0:
|
|
186
|
+
return
|
|
187
|
+
|
|
188
|
+
if returncode == 1:
|
|
189
|
+
message = f"Borg {operation} reported warnings for {context} (exit code 1)."
|
|
190
|
+
logger.warning("%s", message)
|
|
191
|
+
raise BorgWarningError(message)
|
|
192
|
+
|
|
193
|
+
fail(f"Borg {operation} failed for {context} (exit code {returncode}).")
|
|
@@ -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
|
|
@@ -141,7 +149,7 @@ Optional variables:
|
|
|
141
149
|
| --- | --- |
|
|
142
150
|
| `BORG_REMOTE_PATH` | Remote Borg executable path, passed as Borg's `--remote-path`. Leave unset to use Borg's default. |
|
|
143
151
|
| `BORG_EXIT_CODES` | Borg exit-code mode: `legacy` or `modern`. |
|
|
144
|
-
| `BACKUP_PROGRESS` | Set to `true` to
|
|
152
|
+
| `BACKUP_PROGRESS` | Set to `true` to log the latest Borg progress every 30 seconds during archive creation. Defaults to `false`. |
|
|
145
153
|
| `BACKUP_VERBOSE` | Set to `true` to show Borg's per-file create and per-archive prune output. Defaults to `false` for concise logs. |
|
|
146
154
|
|
|
147
155
|
Provide credentials through a secret manager or protected files; do not put
|
|
@@ -159,6 +167,21 @@ the run fails and pruning is skipped. Logs include group and source start/end
|
|
|
159
167
|
messages, while Borg's per-file and per-archive output is disabled by default.
|
|
160
168
|
Set `BACKUP_VERBOSE=true` for detailed Borg output or `BACKUP_PROGRESS=true`
|
|
161
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.
|
|
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.
|
|
162
185
|
|
|
163
186
|
## Configuration reference
|
|
164
187
|
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|