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.
Files changed (27) hide show
  1. {declarative_data_backup-0.3.0/src/declarative_data_backup.egg-info → declarative_data_backup-0.4.0}/PKG-INFO +17 -2
  2. declarative_data_backup-0.3.0/PKG-INFO → declarative_data_backup-0.4.0/README.md +16 -9
  3. {declarative_data_backup-0.3.0 → declarative_data_backup-0.4.0}/pyproject.toml +1 -1
  4. {declarative_data_backup-0.3.0 → declarative_data_backup-0.4.0}/src/declarative_data_backup/backup.py +21 -1
  5. declarative_data_backup-0.4.0/src/declarative_data_backup/borg/borg_cli.py +193 -0
  6. {declarative_data_backup-0.3.0 → declarative_data_backup-0.4.0}/src/declarative_data_backup/errors.py +6 -0
  7. declarative_data_backup-0.3.0/README.md → declarative_data_backup-0.4.0/src/declarative_data_backup.egg-info/PKG-INFO +24 -1
  8. declarative_data_backup-0.3.0/src/declarative_data_backup/borg/borg_cli.py +0 -69
  9. {declarative_data_backup-0.3.0 → declarative_data_backup-0.4.0}/setup.cfg +0 -0
  10. {declarative_data_backup-0.3.0 → declarative_data_backup-0.4.0}/src/declarative_data_backup/__init__.py +0 -0
  11. {declarative_data_backup-0.3.0 → declarative_data_backup-0.4.0}/src/declarative_data_backup/__main__.py +0 -0
  12. {declarative_data_backup-0.3.0 → declarative_data_backup-0.4.0}/src/declarative_data_backup/borg/__init__.py +0 -0
  13. {declarative_data_backup-0.3.0 → declarative_data_backup-0.4.0}/src/declarative_data_backup/borg/archive_naming.py +0 -0
  14. {declarative_data_backup-0.3.0 → declarative_data_backup-0.4.0}/src/declarative_data_backup/borg/borg.py +0 -0
  15. {declarative_data_backup-0.3.0 → declarative_data_backup-0.4.0}/src/declarative_data_backup/config/__init__.py +0 -0
  16. {declarative_data_backup-0.3.0 → declarative_data_backup-0.4.0}/src/declarative_data_backup/config/backup_config/__init__.py +0 -0
  17. {declarative_data_backup-0.3.0 → declarative_data_backup-0.4.0}/src/declarative_data_backup/config/backup_config/models.py +0 -0
  18. {declarative_data_backup-0.3.0 → declarative_data_backup-0.4.0}/src/declarative_data_backup/config/backup_config/utils.py +0 -0
  19. {declarative_data_backup-0.3.0 → declarative_data_backup-0.4.0}/src/declarative_data_backup/config/backup_config/yaml_parser.py +0 -0
  20. {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
  21. {declarative_data_backup-0.3.0 → declarative_data_backup-0.4.0}/src/declarative_data_backup/config/logging.py +0 -0
  22. {declarative_data_backup-0.3.0 → declarative_data_backup-0.4.0}/src/declarative_data_backup/environment.py +0 -0
  23. {declarative_data_backup-0.3.0 → declarative_data_backup-0.4.0}/src/declarative_data_backup.egg-info/SOURCES.txt +0 -0
  24. {declarative_data_backup-0.3.0 → declarative_data_backup-0.4.0}/src/declarative_data_backup.egg-info/dependency_links.txt +0 -0
  25. {declarative_data_backup-0.3.0 → declarative_data_backup-0.4.0}/src/declarative_data_backup.egg-info/requires.txt +0 -0
  26. {declarative_data_backup-0.3.0 → declarative_data_backup-0.4.0}/src/declarative_data_backup.egg-info/top_level.txt +0 -0
  27. {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.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 enable Borg's live progress output during archive creation. Defaults to `false` to limit log volume. |
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 enable Borg's live progress output during archive creation. Defaults to `false` to limit log volume. |
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
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "declarative-data-backup"
7
- version = "0.3.0"
7
+ version = "0.4.0"
8
8
  description = "Declarative Borg-based data backups"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -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 enable Borg's live progress output during archive creation. Defaults to `false` to limit log volume. |
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}).")