declarative-data-backup 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1 @@
1
+ """Declarative Borg-based data backups."""
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from .backup import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
@@ -0,0 +1,111 @@
1
+ import logging
2
+ import os
3
+ import uuid
4
+ from collections.abc import Mapping
5
+ from datetime import UTC, datetime
6
+
7
+ from .borg import (
8
+ backup_folder,
9
+ check_keyfile,
10
+ prune,
11
+ repository_lock,
12
+ )
13
+ from .borg.borg_cli import ensure_borg_binary
14
+ from .config import configure_logging, load_yaml, parse_backup_definitions
15
+ from .config.backup_config.models import BackupDefinition
16
+ from .environment import Environment
17
+ from .errors import BackupError, BorgWarningError
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ def check_settings(settings: Environment) -> None:
23
+ ensure_borg_binary()
24
+ if not settings.borg_base_dir.is_dir():
25
+ raise BackupError(
26
+ f"BORG_BASE_DIR does not exist or is not a directory: "
27
+ f"{settings.borg_base_dir}."
28
+ )
29
+ if not os.access(settings.borg_base_dir, os.R_OK | os.W_OK | os.X_OK):
30
+ raise BackupError(
31
+ f"BORG_BASE_DIR is not accessible for Borg state: {settings.borg_base_dir}."
32
+ )
33
+ check_keyfile(settings)
34
+
35
+
36
+ def load_definitions(settings: Environment) -> list[BackupDefinition]:
37
+ return parse_backup_definitions(
38
+ load_yaml(settings.config_path),
39
+ settings.server_root,
40
+ )
41
+
42
+
43
+ def run_backup(
44
+ settings: Environment,
45
+ definitions: list[BackupDefinition],
46
+ ) -> None:
47
+ total_folders = sum(len(definition.folders) for definition in definitions)
48
+ logger.info(
49
+ "Validated %d backup groups containing %d folders",
50
+ len(definitions),
51
+ total_folders,
52
+ )
53
+
54
+ run_id = uuid.uuid4().hex
55
+ with repository_lock(settings.borg_base_dir / "backup.lock"):
56
+ created_at = datetime.now(UTC)
57
+ for definition in definitions:
58
+ logger.info(
59
+ "Starting backup group '%s' (%d folders).",
60
+ definition.name,
61
+ len(definition.folders),
62
+ )
63
+ for folder in definition.folders:
64
+ backup_folder(
65
+ folder,
66
+ definition.compression,
67
+ definition.name,
68
+ settings,
69
+ created_at,
70
+ run_id,
71
+ )
72
+
73
+ for definition in definitions:
74
+ for folder in definition.folders:
75
+ prune(
76
+ folder,
77
+ definition.retention_days,
78
+ definition.name,
79
+ settings,
80
+ )
81
+
82
+
83
+ def main(environ: Mapping[str, str] | None = None) -> int:
84
+ configure_logging()
85
+ try:
86
+ settings = Environment.from_env(environ)
87
+ logger.info("Starting backup using configuration %s", settings.config_path)
88
+ check_settings(settings)
89
+ run_backup(settings, load_definitions(settings))
90
+ logger.info("Backup completed successfully.")
91
+ return 0
92
+
93
+ except BorgWarningError as exc:
94
+ logger.error(
95
+ "Backup stopped because Borg reported warnings; "
96
+ "pruning was not completed: %s",
97
+ exc,
98
+ )
99
+ return 1
100
+ except BackupError as exc:
101
+ logger.error("%s", exc)
102
+ return 1
103
+ except OSError as exc:
104
+ logger.error(
105
+ "Unexpected I/O failure: %s.",
106
+ exc.strerror or "I/O error",
107
+ )
108
+ return 1
109
+ except KeyboardInterrupt:
110
+ logger.error("Interrupted.")
111
+ return 130
@@ -0,0 +1,13 @@
1
+ from .borg import (
2
+ backup_folder,
3
+ check_keyfile,
4
+ prune,
5
+ repository_lock,
6
+ )
7
+
8
+ __all__ = [
9
+ "backup_folder",
10
+ "check_keyfile",
11
+ "prune",
12
+ "repository_lock",
13
+ ]
@@ -0,0 +1,48 @@
1
+ import hashlib
2
+ import re
3
+ import uuid
4
+ from datetime import UTC, datetime
5
+ from pathlib import Path
6
+
7
+ from ..errors import fail
8
+
9
+ APPLICATION_ARCHIVE_PREFIX = "bbkp__"
10
+ _SAFE_ARCHIVE_ID = re.compile(r"[a-f0-9]{32}\Z")
11
+ _MAX_ARCHIVE_NAME_LENGTH = 255
12
+ _MAX_SOURCE_SLUG_LENGTH = 120
13
+
14
+
15
+ def archive_series_id(path: Path) -> str:
16
+ canonical = str(path.resolve())
17
+ escaped = canonical.strip("/").replace("/", "__")
18
+ escaped = re.sub(r"[^A-Za-z0-9._-]", "_", escaped)
19
+ escaped = escaped[:_MAX_SOURCE_SLUG_LENGTH].rstrip("_") or "root"
20
+ digest = hashlib.sha256(
21
+ canonical.encode("utf-8", errors="surrogateescape")
22
+ ).hexdigest()[:24]
23
+ return f"{APPLICATION_ARCHIVE_PREFIX}{escaped}__{digest}"
24
+
25
+
26
+ def archive_name_escaper(
27
+ path: Path,
28
+ created_at: datetime | None = None,
29
+ run_id: str | None = None,
30
+ ) -> str:
31
+ if created_at is None:
32
+ created_at = datetime.now(UTC)
33
+ if created_at.tzinfo is None or created_at.utcoffset() is None:
34
+ fail("Archive timestamps must include a timezone.")
35
+
36
+ timestamp = created_at.astimezone(UTC).strftime("%Y%m%dT%H%M%SZ")
37
+ unique_id = uuid.uuid4().hex if run_id is None else run_id
38
+ if not _SAFE_ARCHIVE_ID.fullmatch(unique_id):
39
+ fail("Archive run identifiers must be 32 lowercase hexadecimal characters.")
40
+
41
+ name = f"{archive_series_id(path)}__{timestamp}__{unique_id}"
42
+ if len(name) > _MAX_ARCHIVE_NAME_LENGTH:
43
+ fail("Generated Borg archive name exceeds the supported length.")
44
+ return name
45
+
46
+
47
+ def archive_series_glob(path: Path) -> str:
48
+ return f"{archive_series_id(path)}__*"
@@ -0,0 +1,166 @@
1
+ import fcntl
2
+ import logging
3
+ import os
4
+ import stat
5
+ import time
6
+ from collections.abc import Generator
7
+ from contextlib import contextmanager
8
+ from datetime import datetime
9
+ from pathlib import Path
10
+
11
+ from ..environment import (
12
+ REPOSITORY_LOCK_TIMEOUT_SECONDS,
13
+ Environment,
14
+ )
15
+ from ..errors import BackupError, fail
16
+ from .archive_naming import (
17
+ archive_name_escaper,
18
+ archive_series_glob,
19
+ )
20
+ from .borg_cli import run_borg
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ def backup_folder(
26
+ folder: Path,
27
+ compression: str,
28
+ group: str,
29
+ settings: Environment,
30
+ created_at: datetime,
31
+ run_id: str,
32
+ ) -> None:
33
+ name = archive_name_escaper(
34
+ folder,
35
+ created_at=created_at,
36
+ run_id=run_id,
37
+ )
38
+ context = f"group={group!r} source={folder}"
39
+ started_at = time.monotonic()
40
+ logger.info("Starting backup for %s as archive %s.", context, name)
41
+ try:
42
+ run_borg(
43
+ [
44
+ "create",
45
+ "--verbose",
46
+ "--stats",
47
+ "--compression",
48
+ compression,
49
+ f"{settings.borg_repo}::{name}",
50
+ str(folder),
51
+ ],
52
+ settings,
53
+ "create",
54
+ context,
55
+ )
56
+ except BackupError:
57
+ logger.error(
58
+ "Backup failed for %s (elapsed_seconds=%.2f).",
59
+ context,
60
+ time.monotonic() - started_at,
61
+ )
62
+ raise
63
+ logger.info(
64
+ "Backup succeeded for %s (elapsed_seconds=%.2f).",
65
+ context,
66
+ time.monotonic() - started_at,
67
+ )
68
+
69
+
70
+ def prune(
71
+ source: Path,
72
+ retention_days: int,
73
+ group: str,
74
+ settings: Environment,
75
+ ) -> None:
76
+ selector = archive_series_glob(source)
77
+ context = f"group={group!r} source={source}"
78
+ started_at = time.monotonic()
79
+ logger.info(
80
+ "Starting prune for %s (keep_daily=%d).",
81
+ context,
82
+ retention_days,
83
+ )
84
+ try:
85
+ run_borg(
86
+ [
87
+ "prune",
88
+ "--verbose",
89
+ "--list",
90
+ "--glob-archives",
91
+ selector,
92
+ "--keep-daily",
93
+ str(retention_days),
94
+ settings.borg_repo,
95
+ ],
96
+ settings,
97
+ "prune",
98
+ context,
99
+ )
100
+ except BackupError:
101
+ logger.error(
102
+ "Prune failed for %s (elapsed_seconds=%.2f).",
103
+ context,
104
+ time.monotonic() - started_at,
105
+ )
106
+ raise
107
+ logger.info(
108
+ "Prune succeeded for %s (elapsed_seconds=%.2f).",
109
+ context,
110
+ time.monotonic() - started_at,
111
+ )
112
+
113
+
114
+ def check_keyfile(settings: Environment) -> None:
115
+ try:
116
+ if not settings.key_file.is_file():
117
+ fail(f"Borg keyfile does not exist: {settings.key_file}.")
118
+ if not os.access(settings.key_file, os.R_OK):
119
+ fail(f"Borg keyfile is not readable: {settings.key_file}.")
120
+ permissions = stat.S_IMODE(settings.key_file.stat().st_mode)
121
+ except OSError:
122
+ fail(f"Borg keyfile cannot be accessed: {settings.key_file}.")
123
+
124
+ if permissions & 0o077:
125
+ logger.warning(
126
+ "Borg keyfile permissions are broader than 0600: %s.",
127
+ settings.key_file,
128
+ )
129
+
130
+
131
+ @contextmanager
132
+ def repository_lock(
133
+ lock_path: Path,
134
+ timeout_seconds: int = REPOSITORY_LOCK_TIMEOUT_SECONDS,
135
+ ) -> Generator[None]:
136
+ started_at = time.monotonic()
137
+ try:
138
+ lock_file = lock_path.open("a", encoding="utf-8")
139
+ except OSError as exc:
140
+ fail(
141
+ f"Unable to open repository coordination lock: {exc.strerror or 'I/O error'}."
142
+ )
143
+
144
+ acquired = False
145
+ try:
146
+ while not acquired:
147
+ try:
148
+ fcntl.flock(
149
+ lock_file.fileno(),
150
+ fcntl.LOCK_EX | fcntl.LOCK_NB,
151
+ )
152
+ acquired = True
153
+ except BlockingIOError:
154
+ if time.monotonic() - started_at >= timeout_seconds:
155
+ fail(
156
+ "Timed out waiting for the repository coordination lock "
157
+ f"after {timeout_seconds} seconds."
158
+ )
159
+ time.sleep(0.25)
160
+
161
+ logger.info("Acquired repository coordination lock.")
162
+ yield
163
+ finally:
164
+ if acquired:
165
+ fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
166
+ lock_file.close()
@@ -0,0 +1,69 @@
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}).")
@@ -0,0 +1,11 @@
1
+ from .backup_config.utils import read_mount_points
2
+ from .backup_config.yaml_parser import parse_backup_definitions
3
+ from .backup_config.yaml_raw_loader import load_yaml
4
+ from .logging import configure_logging
5
+
6
+ __all__ = [
7
+ "configure_logging",
8
+ "load_yaml",
9
+ "parse_backup_definitions",
10
+ "read_mount_points",
11
+ ]
@@ -0,0 +1 @@
1
+ __all__: list[str] = []
@@ -0,0 +1,12 @@
1
+ from dataclasses import dataclass
2
+ from pathlib import Path
3
+
4
+
5
+ @dataclass(frozen=True)
6
+ class BackupDefinition:
7
+ name: str
8
+ folders: tuple[Path, ...]
9
+ compression: str
10
+ retention_days: int
11
+ required_mounts: tuple[Path, ...] = ()
12
+ allow_empty: bool = True
@@ -0,0 +1,141 @@
1
+ import os
2
+ import re
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ from ...errors import fail
7
+ from .models import BackupDefinition
8
+
9
+ _MOUNT_ESCAPE = re.compile(r"\\([0-7]{3})")
10
+
11
+
12
+ def resolve_path(raw_path: Any, server_root: Path, label: str) -> Path:
13
+ if not isinstance(raw_path, str) or not raw_path:
14
+ fail(f"{label} must be a nonempty string.")
15
+
16
+ path = Path(raw_path)
17
+ if not path.is_absolute():
18
+ fail(f"{label} must be absolute: {raw_path!r}.")
19
+ if ".." in path.parts:
20
+ fail(f"{label} must not contain '..': {raw_path!r}.")
21
+ if not path.is_relative_to(server_root):
22
+ fail(f"{label} must be lexically inside SERVER_ROOT: {raw_path!r}.")
23
+
24
+ try:
25
+ resolved = path.resolve(strict=True)
26
+ except (OSError, RuntimeError, ValueError):
27
+ fail(f"{label} does not exist or cannot be resolved: {raw_path!r}.")
28
+
29
+ if not resolved.is_relative_to(server_root):
30
+ fail(f"{label} resolves outside SERVER_ROOT: {raw_path!r}.")
31
+ return resolved
32
+
33
+
34
+ def parse_folders(
35
+ raw_folders: Any,
36
+ server_root: Path,
37
+ group: str,
38
+ allow_empty: bool,
39
+ ) -> tuple[Path, ...]:
40
+ if not isinstance(raw_folders, list) or not raw_folders:
41
+ fail(f"'{group}.folders' must be a nonempty YAML list.")
42
+
43
+ folders = []
44
+ for raw_folder in raw_folders:
45
+ folder = resolve_path(raw_folder, server_root, f"'{group}.folders' entry")
46
+ if not folder.is_dir():
47
+ fail(f"Backup source is not a directory: {folder}.")
48
+ if not os.access(folder, os.R_OK | os.X_OK):
49
+ fail(f"Backup source is not readable: {folder}.")
50
+ if not allow_empty:
51
+ try:
52
+ is_empty = next(folder.iterdir(), None) is None
53
+ except OSError:
54
+ fail(f"Unable to inspect backup source: {folder}.")
55
+ if is_empty:
56
+ fail(
57
+ f"Backup source is empty and '{group}.allow_empty' is false: {folder}."
58
+ )
59
+ folders.append(folder)
60
+ return tuple(folders)
61
+
62
+
63
+ def parse_required_mounts(
64
+ raw_mounts: Any,
65
+ server_root: Path,
66
+ group: str,
67
+ ) -> tuple[Path, ...]:
68
+ if not isinstance(raw_mounts, list):
69
+ fail(f"'{group}.required_mounts' must be a YAML list.")
70
+
71
+ mounts = tuple(
72
+ resolve_path(raw, server_root, f"'{group}.required_mounts' entry")
73
+ for raw in raw_mounts
74
+ )
75
+ if len(set(mounts)) != len(mounts):
76
+ fail(f"'{group}.required_mounts' contains a duplicate path.")
77
+ for mount in mounts:
78
+ if not mount.is_dir():
79
+ fail(f"Required mount is not a directory: {mount}.")
80
+ return mounts
81
+
82
+
83
+ def check_folders_for_overlappings(definitions: list[BackupDefinition]) -> None:
84
+ owners: dict[Path, str] = {}
85
+ for definition in definitions:
86
+ for folder in definition.folders:
87
+ for existing, owner in owners.items():
88
+ if folder == existing:
89
+ fail(
90
+ f"Duplicate canonical backup source {folder} "
91
+ f"(already configured by '{owner}')."
92
+ )
93
+ if folder in existing.parents or existing in folder.parents:
94
+ fail(
95
+ f"Overlapping backup sources are not allowed: "
96
+ f"'{owner}' uses {existing}, while "
97
+ f"'{definition.name}' uses {folder}."
98
+ )
99
+ owners[folder] = definition.name
100
+
101
+
102
+ def _mountinfo_path(raw_mount: str) -> Path:
103
+ decoded = _MOUNT_ESCAPE.sub(
104
+ lambda match: chr(int(match.group(1), 8)),
105
+ raw_mount,
106
+ )
107
+ return Path(decoded)
108
+
109
+
110
+ def read_mount_points(mountinfo_path: Path) -> set[Path]:
111
+ try:
112
+ lines = mountinfo_path.read_text(encoding="utf-8").splitlines()
113
+ except (OSError, UnicodeError) as exc:
114
+ fail(
115
+ f"Unable to read mount information: {getattr(exc, 'strerror', None) or 'I/O error'}."
116
+ )
117
+
118
+ mount_points: set[Path] = set()
119
+ for line_number, line in enumerate(lines, start=1):
120
+ fields = line.split()
121
+ if len(fields) < 10 or "-" not in fields:
122
+ fail(f"Malformed mount information at line {line_number}.")
123
+ mount_points.add(_mountinfo_path(fields[4]))
124
+ return mount_points
125
+
126
+
127
+ def check_required_mounts_are_mounted(
128
+ definitions: list[BackupDefinition],
129
+ mountinfo_path: Path,
130
+ ) -> None:
131
+ required = {
132
+ mount for definition in definitions for mount in definition.required_mounts
133
+ }
134
+ if required:
135
+ missing = sorted(required - read_mount_points(mountinfo_path))
136
+ if missing:
137
+ fail(
138
+ "Required filesystem mount point(s) are not mounted: "
139
+ + ", ".join(map(str, missing))
140
+ + "."
141
+ )
@@ -0,0 +1,136 @@
1
+ import re
2
+ from pathlib import Path
3
+ from typing import Any
4
+
5
+ from ...environment import DEFAULT_COMPRESSION, DEFAULT_RETENTION_DAYS
6
+ from ...errors import fail
7
+ from .models import BackupDefinition
8
+ from .utils import (
9
+ check_folders_for_overlappings,
10
+ check_required_mounts_are_mounted,
11
+ parse_folders,
12
+ parse_required_mounts,
13
+ )
14
+
15
+ _GROUP_NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9_-]{0,63}\Z")
16
+ _GROUP_KEYS = {
17
+ "folders",
18
+ "compression",
19
+ "retention_days",
20
+ "required_mounts",
21
+ "allow_empty",
22
+ }
23
+ _GROUP_DEFAULTS = {
24
+ "compression": DEFAULT_COMPRESSION,
25
+ "retention_days": DEFAULT_RETENTION_DAYS,
26
+ "required_mounts": [],
27
+ "allow_empty": True,
28
+ }
29
+
30
+
31
+ def _validate_compression(value: Any, group: str) -> str:
32
+ if not isinstance(value, str):
33
+ fail(f"'{group}.compression' is not a supported Borg 1.4 compression value.")
34
+
35
+ parts = value.split(",")
36
+ index = 0
37
+ while index < len(parts):
38
+ codec = parts[index]
39
+ if codec in {"none", "lz4"}:
40
+ index += 1
41
+ break
42
+ elif codec in {"zstd", "zlib", "lzma"}:
43
+ index += 1
44
+ if index < len(parts) and parts[index].isdigit():
45
+ level = int(parts[index])
46
+ minimum, maximum = {
47
+ "zstd": (1, 22),
48
+ "zlib": (0, 9),
49
+ "lzma": (0, 9),
50
+ }[codec]
51
+ if not minimum <= level <= maximum:
52
+ fail(
53
+ f"'{group}.compression' level for {codec} "
54
+ f"must be between {minimum} and {maximum}."
55
+ )
56
+ index += 1
57
+ break
58
+ elif codec == "auto":
59
+ index += 1
60
+ elif codec == "obfuscate":
61
+ if index + 2 >= len(parts) or not parts[index + 1].isdigit():
62
+ fail(f"'{group}.compression' has an invalid obfuscation specifier.")
63
+ obfuscation_spec = int(parts[index + 1])
64
+ if (
65
+ obfuscation_spec not in range(1, 7)
66
+ and obfuscation_spec not in range(110, 124)
67
+ and obfuscation_spec != 250
68
+ ):
69
+ fail(f"'{group}.compression' has an unsupported obfuscation specifier.")
70
+ index += 2
71
+ else:
72
+ fail(
73
+ f"'{group}.compression' is not a supported Borg 1.4 compression value."
74
+ )
75
+
76
+ if index != len(parts):
77
+ fail(f"'{group}.compression' contains unexpected compression parameters.")
78
+ return value
79
+
80
+
81
+ def _parse_definition(name: Any, raw: Any, server_root: Path) -> BackupDefinition:
82
+ if not isinstance(name, str) or not _GROUP_NAME.fullmatch(name):
83
+ fail("Backup group names must contain only letters, digits, '_' or '-'.")
84
+ if not isinstance(raw, dict):
85
+ fail(f"'{name}' must be a YAML mapping.")
86
+
87
+ unknown_keys = set(raw) - _GROUP_KEYS
88
+ if unknown_keys:
89
+ fail(f"'{name}' contains unsupported configuration keys.")
90
+
91
+ group = _GROUP_DEFAULTS | raw
92
+ compression = _validate_compression(group["compression"], name)
93
+ retention_days = group["retention_days"]
94
+ if (
95
+ isinstance(retention_days, bool)
96
+ or not isinstance(retention_days, int)
97
+ or retention_days < 1
98
+ ):
99
+ fail(f"'{name}.retention_days' must be a positive integer.")
100
+
101
+ allow_empty = group["allow_empty"]
102
+ if not isinstance(allow_empty, bool):
103
+ fail(f"'{name}.allow_empty' must be a boolean.")
104
+
105
+ return BackupDefinition(
106
+ name=name,
107
+ folders=parse_folders(group.get("folders"), server_root, name, allow_empty),
108
+ compression=compression,
109
+ retention_days=retention_days,
110
+ required_mounts=parse_required_mounts(
111
+ group["required_mounts"],
112
+ server_root,
113
+ name,
114
+ ),
115
+ allow_empty=allow_empty,
116
+ )
117
+
118
+
119
+ def parse_backup_definitions(
120
+ data: Any,
121
+ server_root: Path,
122
+ mountinfo_path: Path = Path("/proc/self/mountinfo"),
123
+ ) -> list[BackupDefinition]:
124
+ if not isinstance(data, dict):
125
+ fail("Top-level configuration must be a YAML mapping.")
126
+ if not data:
127
+ fail("Backup configuration is empty.")
128
+ if not server_root.is_dir():
129
+ fail(f"SERVER_ROOT does not exist or is not a directory: {server_root}.")
130
+
131
+ definitions = [
132
+ _parse_definition(name, raw, server_root) for name, raw in data.items()
133
+ ]
134
+ check_folders_for_overlappings(definitions)
135
+ check_required_mounts_are_mounted(definitions, mountinfo_path)
136
+ return definitions
@@ -0,0 +1,79 @@
1
+ from pathlib import Path
2
+ from typing import Any
3
+
4
+ import yaml
5
+ from yaml.constructor import ConstructorError
6
+ from yaml.nodes import MappingNode
7
+
8
+ from ...errors import fail
9
+
10
+
11
+ class UniqueKeyLoader(yaml.SafeLoader):
12
+ def construct_mapping(
13
+ self,
14
+ node: MappingNode,
15
+ deep: bool = False,
16
+ ) -> dict[Any, Any]:
17
+ if not isinstance(node, MappingNode):
18
+ raise ConstructorError(
19
+ None,
20
+ None,
21
+ "expected a YAML mapping",
22
+ node.start_mark,
23
+ )
24
+
25
+ self.flatten_mapping(node)
26
+ mapping: dict[Any, Any] = {}
27
+ for key_node, value_node in node.value:
28
+ key = self.construct_object(key_node, deep=deep)
29
+ try:
30
+ exists = key in mapping
31
+ except TypeError as exc:
32
+ raise ConstructorError(
33
+ "while constructing a mapping",
34
+ node.start_mark,
35
+ "found an unhashable mapping key",
36
+ key_node.start_mark,
37
+ ) from exc
38
+
39
+ if exists:
40
+ raise ConstructorError(
41
+ "while constructing a mapping",
42
+ node.start_mark,
43
+ "found a duplicate mapping key",
44
+ key_node.start_mark,
45
+ )
46
+
47
+ mapping[key] = self.construct_object(value_node, deep=deep)
48
+
49
+ return mapping
50
+
51
+
52
+ def load_yaml(path: Path) -> dict[str, Any]:
53
+ try:
54
+ with path.open("r", encoding="utf-8") as file:
55
+ data = yaml.load(file, Loader=UniqueKeyLoader)
56
+ except yaml.YAMLError as exc:
57
+ mark = getattr(exc, "problem_mark", None)
58
+ location = (
59
+ f" at line {mark.line + 1}, column {mark.column + 1}"
60
+ if mark is not None
61
+ else ""
62
+ )
63
+ duplicate = (
64
+ "duplicate YAML mapping key"
65
+ if getattr(exc, "problem", "") == "found a duplicate mapping key"
66
+ else "malformed YAML"
67
+ )
68
+ fail(f"Invalid YAML in {path}{location}: {duplicate}.")
69
+ except (OSError, UnicodeError) as exc:
70
+ fail(
71
+ f"Unable to read configuration file {path}: "
72
+ f"{getattr(exc, 'strerror', None) or 'I/O error'}."
73
+ )
74
+
75
+ if not isinstance(data, dict):
76
+ fail("Top-level configuration must be a YAML mapping.")
77
+ if not data:
78
+ fail("Backup configuration is empty.")
79
+ return data
@@ -0,0 +1,8 @@
1
+ import logging
2
+
3
+
4
+ def configure_logging() -> None:
5
+ logging.basicConfig(
6
+ level=logging.INFO,
7
+ format="%(asctime)s %(levelname)s %(message)s",
8
+ )
@@ -0,0 +1,70 @@
1
+ import os
2
+ from collections.abc import Mapping
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+
6
+ from .errors import fail
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class Environment:
11
+ config_path: Path
12
+ key_file: Path
13
+ borg_repo: str
14
+ server_root: Path
15
+ borg_base_dir: Path
16
+ borg_rsh: str
17
+ borg_passcommand: str
18
+ borg_remote_path: str | None
19
+ borg_exit_codes: str | None
20
+
21
+ @classmethod
22
+ def from_env(
23
+ cls,
24
+ environ: Mapping[str, str] | None = None,
25
+ ) -> "Environment":
26
+ source = os.environ if environ is None else environ
27
+
28
+ def required(name: str) -> str:
29
+ value = source.get(name)
30
+ if value is None or not value.strip():
31
+ fail(f"{name} environment variable is required.")
32
+ return value
33
+
34
+ def required_path(name: str) -> Path:
35
+ raw_value = required(name)
36
+ if "\0" in raw_value:
37
+ fail(f"{name} must be a valid absolute path.")
38
+ value = Path(raw_value)
39
+ if not value.is_absolute():
40
+ fail(f"{name} must be an absolute path.")
41
+ return value
42
+
43
+ repo = required("BORG_REPO")
44
+ if "\0" in repo:
45
+ fail("BORG_REPO must be a valid repository location.")
46
+ borg_exit_codes = source.get("BORG_EXIT_CODES")
47
+ if borg_exit_codes not in (None, "legacy", "modern"):
48
+ fail("BORG_EXIT_CODES must be either 'legacy' or 'modern'.")
49
+
50
+ try:
51
+ server_root = required_path("SERVER_ROOT").resolve(strict=True)
52
+ except (OSError, RuntimeError, ValueError):
53
+ fail("SERVER_ROOT does not exist or cannot be accessed.")
54
+
55
+ return cls(
56
+ config_path=required_path("BACKUP_CONFIG_PATH"),
57
+ key_file=required_path("BORG_KEY_FILE"),
58
+ borg_repo=repo,
59
+ server_root=server_root,
60
+ borg_base_dir=required_path("BORG_BASE_DIR"),
61
+ borg_rsh=required("BORG_RSH"),
62
+ borg_passcommand=required("BORG_PASSCOMMAND"),
63
+ borg_remote_path=source.get("BORG_REMOTE_PATH"),
64
+ borg_exit_codes=borg_exit_codes,
65
+ )
66
+
67
+
68
+ DEFAULT_COMPRESSION = "auto,zstd,22"
69
+ DEFAULT_RETENTION_DAYS = 30
70
+ REPOSITORY_LOCK_TIMEOUT_SECONDS = 3600
@@ -0,0 +1,13 @@
1
+ from typing import NoReturn
2
+
3
+
4
+ class BackupError(RuntimeError):
5
+ """Raised when the backup configuration or execution is invalid."""
6
+
7
+
8
+ class BorgWarningError(BackupError):
9
+ """Raised when Borg completes with warnings that make the run unsafe."""
10
+
11
+
12
+ def fail(message: str) -> NoReturn:
13
+ raise BackupError(message)
@@ -0,0 +1,319 @@
1
+ Metadata-Version: 2.4
2
+ Name: declarative-data-backup
3
+ Version: 0.1.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
+ Provides-Extra: dev
9
+ Requires-Dist: pre-commit<5,>=4; extra == "dev"
10
+ Requires-Dist: pyright<2,>=1.1.400; extra == "dev"
11
+ Requires-Dist: pytest<10,>=8; extra == "dev"
12
+ Requires-Dist: ruff<1,>=0.11; extra == "dev"
13
+
14
+ # Declarative Data Backup
15
+
16
+ Declarative Data Backup creates encrypted Borg archives for configured source
17
+ directories. Each source gets its own archive series in one Borg repository,
18
+ so Borg can deduplicate chunks across all sources. The application validates
19
+ the complete configuration before invoking Borg and prunes a source's archives
20
+ only after every create operation in the run has succeeded.
21
+
22
+ ## Requirements
23
+
24
+ - Python 3.11 or newer.
25
+ - Borg 1.4.x installed separately and available as `borg` on `PATH`.
26
+ - A Borg repository that has already been initialized in keyfile encryption
27
+ mode, its matching keyfile, and a protected passphrase source.
28
+ - For remote repositories, OpenSSH and a trusted `known_hosts` file.
29
+
30
+ The application never initializes a repository. Borg 2 is not supported.
31
+
32
+ ## Installation
33
+
34
+ Install the package from PyPI:
35
+
36
+ ```shell
37
+ python -m pip install declarative-data-backup
38
+ ```
39
+
40
+ Or install a checkout with the development dependencies:
41
+
42
+ ```shell
43
+ python -m pip install -e ".[dev]"
44
+ ```
45
+
46
+ The PyPI distribution is named `declarative-data-backup`; its importable
47
+ package is `declarative_data_backup`.
48
+
49
+ ## GitLab release automation
50
+
51
+ The pipeline in [.gitlab-ci.yml](.gitlab-ci.yml) publishes releases when a Git
52
+ tag in `major.minor.patch` format is pushed, for example `1.2.3`. Use the same
53
+ version for the GitLab Release name and tag. Tags with a `v` prefix, prerelease
54
+ suffix, or leading zeros are not published. Branch pushes do not run this
55
+ release pipeline.
56
+
57
+ For each version tag, the pipeline:
58
+
59
+ 1. Sets the package version in the CI checkout's `pyproject.toml`, builds a
60
+ wheel and source distribution, and checks them with Twine.
61
+ 2. Uploads `declarative-data-backup` to PyPI.
62
+ 3. Builds the existing Python 3.13 Dockerfile with the same package version,
63
+ verifies the installed version, and pushes
64
+ `${CI_REGISTRY_IMAGE}/declarative-data-backup:1.2.3-python3.13` to the
65
+ project's GitLab Container Registry.
66
+
67
+ The source checkout's version is not committed back to the repository.
68
+ The two publishing jobs run independently after the package build succeeds;
69
+ publishing is not atomic across PyPI and the container registry.
70
+
71
+ Before the first release:
72
+
73
+ - Enable the project's GitLab Container Registry.
74
+ - Add `PYPI_API_TOKEN` in GitLab **Settings > CI/CD > Variables** as a masked,
75
+ protected variable. Use a PyPI API token authorized to publish
76
+ `declarative-data-backup`; for a new PyPI project, its first upload may
77
+ require an account-wide token, which should then be replaced with a
78
+ project-scoped token.
79
+ - Protect release tags in GitLab (for example, `*.*.*`) and restrict who can
80
+ create them. Protected variables are only available on protected refs.
81
+ - Use a trusted Docker-executor runner configured for Docker-in-Docker with
82
+ TLS: `privileged = true` and `volumes = ["/certs/client", "/cache"]` in its
83
+ `[runners.docker]` configuration. GitLab.com instance runners support this
84
+ setup. Privileged runners should not execute untrusted code. If your runner
85
+ requires a tag, add it to `publish_container`.
86
+
87
+ Registry authentication uses GitLab's built-in `CI_REGISTRY_USER` and
88
+ `CI_REGISTRY_PASSWORD`; no separate registry password is needed.
89
+
90
+ Create and push a release tag from the commit you want to publish:
91
+
92
+ ```shell
93
+ git tag 1.2.3
94
+ git push origin 1.2.3
95
+ ```
96
+
97
+ Creating a GitLab Release with a new `1.2.3` tag also triggers the tag pipeline.
98
+ Creating or editing a Release on an already existing tag does not trigger a
99
+ new pipeline. PyPI versions cannot be overwritten: do not move or reuse
100
+ release tags. If only the container job fails, retry that job rather than
101
+ rerunning a successful PyPI upload.
102
+
103
+ ## Configure and run
104
+
105
+ Create a YAML file accessible to the backup process:
106
+
107
+ ```yaml
108
+ media_backup:
109
+ folders:
110
+ - /server_root/media/photos
111
+ - /server_root/media/music
112
+ compression: auto,zstd,22
113
+ retention_days: 30
114
+ required_mounts:
115
+ - /server_root/media
116
+ allow_empty: true
117
+
118
+ postgres_backup:
119
+ folders:
120
+ - /server_root/path/to/postgres-backups
121
+ required_mounts:
122
+ - /server_root/path/to/postgres-backups
123
+ ```
124
+
125
+ Set the required environment variables before starting the process:
126
+
127
+ | Variable | Purpose |
128
+ | --- | --- |
129
+ | `BACKUP_CONFIG_PATH` | Absolute path to the YAML configuration. |
130
+ | `SERVER_ROOT` | Absolute path to the root containing all configured source directories. |
131
+ | `BORG_REPO` | Existing Borg repository location, local or remote. |
132
+ | `BORG_KEY_FILE` | Path to the external keyfile for the encrypted repository. |
133
+ | `BORG_BASE_DIR` | Writable directory for Borg state and the backup coordination lock. |
134
+ | `BORG_RSH` | SSH command and options used by Borg for a remote repository. |
135
+ | `BORG_PASSCOMMAND` | Command Borg runs to obtain the repository passphrase. |
136
+
137
+ Optional variables:
138
+
139
+ | Variable | Purpose |
140
+ | --- | --- |
141
+ | `BORG_REMOTE_PATH` | Remote Borg executable path, passed as Borg's `--remote-path`. Leave unset to use Borg's default. |
142
+ | `BORG_EXIT_CODES` | Borg exit-code mode: `legacy` or `modern`. |
143
+
144
+ Provide credentials through a secret manager or protected files; do not put
145
+ passphrases, private keys, or credentials in the YAML file, source control, or
146
+ shell history. For example, in a controlled environment where these variables
147
+ have already been securely populated:
148
+
149
+ ```shell
150
+ python -m declarative_data_backup
151
+ ```
152
+
153
+ The command exits with status `0` on success, nonzero on configuration or Borg
154
+ failure, and `130` when interrupted. Borg's exit code `1` indicates warnings:
155
+ the run fails and pruning is skipped.
156
+
157
+ ## Configuration reference
158
+
159
+ The top level must be a nonempty YAML mapping of backup group names to group
160
+ mappings. Group names may contain ASCII letters, digits, `_`, and `-`, and
161
+ must be at most 64 characters. Duplicate YAML keys, unknown group fields,
162
+ invalid values, and malformed or empty configuration are rejected before any
163
+ create or prune operation.
164
+
165
+ Each group supports:
166
+
167
+ - `folders` (required): a nonempty list of absolute directory paths. Each
168
+ source must exist, be readable, and resolve inside `SERVER_ROOT`. Duplicate
169
+ canonical paths and parent/child overlaps across groups are rejected.
170
+ - `compression` (optional): defaults to `auto,zstd,22`. Supported Borg 1.4
171
+ codecs include `none`, `lz4`, `zstd`, `zlib`, and `lzma`; `auto` may precede
172
+ a codec. Codec levels are checked against supported ranges: zstd `1`-`22`,
173
+ zlib `0`-`9`, and lzma `0`-`9`. Borg's `obfuscate` compression specifier is
174
+ also accepted for specifier values `1`-`6`, `110`-`123`, or `250`; it is
175
+ useful only with repository encryption.
176
+ - `retention_days` (optional): positive integer, default `30`. This controls
177
+ Borg's `--keep-daily` policy.
178
+ - `required_mounts` (optional): list of exact mount points that must appear
179
+ in the process's `/proc/self/mountinfo`.
180
+ - `allow_empty` (optional): boolean, default `true`. If set to `false`, an
181
+ empty source is rejected.
182
+
183
+ Configure `required_mounts` explicitly for every source that depends on a
184
+ separately mounted filesystem. The program does not infer mount requirements
185
+ from source paths. In a container or Kubernetes pod, mounting `/server_root`
186
+ does not prove that nested host filesystems are mounted. In particular,
187
+ `allow_empty: true` does not prove that a disk is present: an empty source may
188
+ be legitimate and can be archived.
189
+
190
+ Paths are resolved before backup. A top-level source symlink is allowed only
191
+ when its canonical target remains inside `SERVER_ROOT`; symlinks within a
192
+ source are stored as links rather than followed.
193
+
194
+ ## Archives, retention, and compaction
195
+
196
+ The application creates one archive per configured source in the single
197
+ `BORG_REPO`. Names have this form:
198
+
199
+ ```text
200
+ bbkp__<source-series>__<UTC timestamp>__<run UUID>
201
+ ```
202
+
203
+ The stable source series is derived from the canonical source path and includes
204
+ a SHA-256 suffix. Borg receives the canonical absolute path as the source, so
205
+ the original directory hierarchy is retained inside the archive.
206
+
207
+ The application creates all configured archives before pruning any source.
208
+ If a create fails or Borg reports warnings, the run stops and no pruning is
209
+ performed. Retries use a new run UUID and may create another archive for that
210
+ day; archives are never overwritten to handle a collision.
211
+
212
+ `--keep-daily N` retains the latest archive in up to `N` daily buckets. It does
213
+ not guarantee `N` successful calendar days of recovery if scheduled runs fail
214
+ or are missed. Removing a source from the YAML does not delete its old
215
+ archives. The source-series selector does not match archives made with the
216
+ previous non-namespaced naming scheme; retain those archives unless a
217
+ separately reviewed migration or cleanup is performed.
218
+
219
+ Pruning marks repository data for deletion; it does not reclaim the space.
220
+ Repository compaction is a separate maintenance operation and is not run by
221
+ this application. Any scheduled compact job or manual maintenance must first
222
+ acquire an exclusive `flock` on:
223
+
224
+ ```text
225
+ <BORG_BASE_DIR>/backup.lock
226
+ ```
227
+
228
+ Use the same persistent state volume and filesystem as the backup job, with
229
+ working advisory-lock semantics. The backup process waits up to 3600 seconds
230
+ for this lock. Kubernetes `concurrencyPolicy: Forbid` does not coordinate
231
+ separate CronJobs, and Borg's own repository lock does not replace this
232
+ application-level lock. Never unlink or replace the lock file while any
233
+ process could hold it.
234
+
235
+ ## Deployment and operational notes
236
+
237
+ The Dockerfile builds an image with Borg 1.4.1 and the Python package. The
238
+ application itself does not set CPU or I/O priority; a scheduled deployment
239
+ may wrap the command with `nice -n 19` and `ionice -c 3`. For Kubernetes or
240
+ another container runtime:
241
+
242
+ - Run as a non-root user and confirm it can read the keyfile and credentials
243
+ and write the Borg state volume.
244
+ - Mount source data read-only. Keep Borg cache, configuration, security state,
245
+ keyfile, and the coordination lock on persistent writable storage.
246
+ - For nested source filesystems, configure and verify the corresponding
247
+ `required_mounts`.
248
+ - Use a versioned image tag or digest and confirm private-registry pull
249
+ credentials have only the required read access.
250
+ - Do not mount a service-account token unless the workload needs one.
251
+ - Ensure any compact job mounts the same state volume and honors the lock
252
+ contract above.
253
+ - Monitor failed and warning backup jobs. Retention cannot make up for days
254
+ without successful archives.
255
+ - A fixed schedule does not ensure upstream jobs, such as Unmanic processing,
256
+ have completed; establish that dependency separately.
257
+
258
+ The default `auto,zstd,22` may be slow or exceed a 1 GiB memory limit on some
259
+ data sets. It has not been benchmarked under that limit and may be terminated
260
+ for memory use. Compression can be overridden per group without recreating
261
+ the repository.
262
+
263
+ Kubernetes Secret projection permissions, storage ownership, mount behavior,
264
+ node placement, registry access, and workload scheduling must be verified in
265
+ the target cluster. They are not verified by this Python package or its
266
+ container integration test.
267
+
268
+ ## Testing
269
+
270
+ Run the unit tests:
271
+
272
+ ```shell
273
+ python -m pytest -m "not integration"
274
+ ```
275
+
276
+ The opt-in local Borg integration test uses a temporary encrypted repository:
277
+
278
+ ```shell
279
+ RUN_BORG_LOCAL_INTEGRATION=1 python -m pytest -m integration \
280
+ tests/integration/test_local_borg_integration.py
281
+ ```
282
+
283
+ The opt-in Docker SSH integration test uses disposable containers and a
284
+ temporary repository; it does not use production credentials or BorgBase:
285
+
286
+ ```shell
287
+ RUN_BORG_DOCKER_INTEGRATION=1 python -m pytest -m integration \
288
+ tests/integration/test_docker_integration.py
289
+ ```
290
+
291
+ Do not treat skipped integration tests as successful restore validation.
292
+
293
+ ## Recovery
294
+
295
+ Recovery requires the repository and the matching external keyfile and
296
+ passphrase. Keep independent recovery copies of the keyfile and passphrase;
297
+ the repository must not be their only copy. Also retain the SSH private key
298
+ and verified `known_hosts` entry for a remote repository.
299
+
300
+ With the required Borg environment and credentials securely configured, list
301
+ archives and inspect the desired archive:
302
+
303
+ ```shell
304
+ borg list "$BORG_REPO"
305
+ borg list "$BORG_REPO"::'bbkp__<source-series>__<timestamp>__<run-uuid>'
306
+ ```
307
+
308
+ Extract into an isolated recovery directory and verify the restored data
309
+ before returning it to service:
310
+
311
+ ```shell
312
+ mkdir -p /restore
313
+ cd /restore
314
+ borg extract "$BORG_REPO"::'bbkp__<source-series>__<timestamp>__<run-uuid>'
315
+ ```
316
+
317
+ Test recovery periodically using a disposable or approved recovery
318
+ environment. This repository's tests do not verify access to a live remote
319
+ repository or actual Kubernetes Secret projection and storage behavior.
@@ -0,0 +1,20 @@
1
+ declarative_data_backup/__init__.py,sha256=fzbCEZKBtlf2q6gIQT-EpOjIcRXS9tvwHwhbpkfw5zo,43
2
+ declarative_data_backup/__main__.py,sha256=ITJgzyMyGjFKmAXg_t3lgvBk06Mra4CDKHmZJKeoCJg,86
3
+ declarative_data_backup/backup.py,sha256=PL635OglOFRpj9DmwVsfCBXejbbVEtVDv_UIi3rN-yE,3332
4
+ declarative_data_backup/environment.py,sha256=7CSKXtJhvN2-P35ykpsAJ2yg58A5eiqH895k2n5L-aA,2251
5
+ declarative_data_backup/errors.py,sha256=QzAqBWnwm_TsRI1g_An9iwBOQAB-rIrHQV6ur5l3bCk,320
6
+ declarative_data_backup/borg/__init__.py,sha256=kSSwLAXTsMbio6PopLLbdC-0C6gnQqub5bMpAc5dwKQ,185
7
+ declarative_data_backup/borg/archive_naming.py,sha256=Sugp3-bjHoijT6DYSFCwJAOUpwOiu5iJun351J-4SJA,1585
8
+ declarative_data_backup/borg/borg.py,sha256=qOp33-wxg3Zz9I5b0exeV5RsSZ5qlws2PJSFLu5Kkvk,4410
9
+ declarative_data_backup/borg/borg_cli.py,sha256=TLAh2plWqYNwTDmNrDSpknTrB4_wkFzJGOhvGi6Se3w,2062
10
+ declarative_data_backup/config/__init__.py,sha256=IuTiEReE4DV9fDWGb1sS9ip5MDsxtS_6RZ541RCSv88,321
11
+ declarative_data_backup/config/logging.py,sha256=GKjdt26vvIWsQHek7KaALTYmv104TyTVLtAI29GqDv8,165
12
+ declarative_data_backup/config/backup_config/__init__.py,sha256=juqd9PbXs4yg45zMJ7BHAOPQjb7sgEbWE9InBtGZhfo,24
13
+ declarative_data_backup/config/backup_config/models.py,sha256=npHkm-6H1UgTNE8UTV7Zhg4mCqKQC8N7iAF-f3eaWXQ,270
14
+ declarative_data_backup/config/backup_config/utils.py,sha256=cN4W36z3a4Vg4dQceNHGSpMzkcVjMQFJdf3XQAUxywU,4766
15
+ declarative_data_backup/config/backup_config/yaml_parser.py,sha256=JluOZZEikFwyMY8XBb7Owth1iJtnabanSkZega2I1oU,4533
16
+ declarative_data_backup/config/backup_config/yaml_raw_loader.py,sha256=t_BXlrA0bwYZRVRSdH23SljyQj6QXj_f04Q6YouZRnY,2460
17
+ declarative_data_backup-0.1.0.dist-info/METADATA,sha256=T-SGGBVnH0_8uscsh-uRnA8knY6qWucoSLLTUr9QZ8A,13027
18
+ declarative_data_backup-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
19
+ declarative_data_backup-0.1.0.dist-info/top_level.txt,sha256=nwO5fKvUWgOJAFwiGXK89jQlJP4EP5r-2KaxKZtBrVg,24
20
+ declarative_data_backup-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ declarative_data_backup