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.
Files changed (26) hide show
  1. {declarative_data_backup-0.3.1/src/declarative_data_backup.egg-info → declarative_data_backup-0.4.0}/PKG-INFO +11 -1
  2. declarative_data_backup-0.3.1/PKG-INFO → declarative_data_backup-0.4.0/README.md +10 -8
  3. {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.0}/pyproject.toml +1 -1
  4. {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.0}/src/declarative_data_backup/backup.py +21 -1
  5. {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.0}/src/declarative_data_backup/borg/borg_cli.py +76 -47
  6. {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.0}/src/declarative_data_backup/errors.py +6 -0
  7. declarative_data_backup-0.3.1/README.md → declarative_data_backup-0.4.0/src/declarative_data_backup.egg-info/PKG-INFO +18 -0
  8. {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.0}/setup.cfg +0 -0
  9. {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.0}/src/declarative_data_backup/__init__.py +0 -0
  10. {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.0}/src/declarative_data_backup/__main__.py +0 -0
  11. {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.0}/src/declarative_data_backup/borg/__init__.py +0 -0
  12. {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.0}/src/declarative_data_backup/borg/archive_naming.py +0 -0
  13. {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.0}/src/declarative_data_backup/borg/borg.py +0 -0
  14. {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.0}/src/declarative_data_backup/config/__init__.py +0 -0
  15. {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.0}/src/declarative_data_backup/config/backup_config/__init__.py +0 -0
  16. {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.0}/src/declarative_data_backup/config/backup_config/models.py +0 -0
  17. {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.0}/src/declarative_data_backup/config/backup_config/utils.py +0 -0
  18. {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.0}/src/declarative_data_backup/config/backup_config/yaml_parser.py +0 -0
  19. {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
  20. {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.0}/src/declarative_data_backup/config/logging.py +0 -0
  21. {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.0}/src/declarative_data_backup/environment.py +0 -0
  22. {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.0}/src/declarative_data_backup.egg-info/SOURCES.txt +0 -0
  23. {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.0}/src/declarative_data_backup.egg-info/dependency_links.txt +0 -0
  24. {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.0}/src/declarative_data_backup.egg-info/requires.txt +0 -0
  25. {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.0}/src/declarative_data_backup.egg-info/top_level.txt +0 -0
  26. {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.1
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "declarative-data-backup"
7
- version = "0.3.1"
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)
@@ -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 subprocess.Popen(
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
- 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
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 = subprocess.run(
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