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.
- declarative_data_backup/__init__.py +1 -0
- declarative_data_backup/__main__.py +6 -0
- declarative_data_backup/backup.py +111 -0
- declarative_data_backup/borg/__init__.py +13 -0
- declarative_data_backup/borg/archive_naming.py +48 -0
- declarative_data_backup/borg/borg.py +166 -0
- declarative_data_backup/borg/borg_cli.py +69 -0
- declarative_data_backup/config/__init__.py +11 -0
- declarative_data_backup/config/backup_config/__init__.py +1 -0
- declarative_data_backup/config/backup_config/models.py +12 -0
- declarative_data_backup/config/backup_config/utils.py +141 -0
- declarative_data_backup/config/backup_config/yaml_parser.py +136 -0
- declarative_data_backup/config/backup_config/yaml_raw_loader.py +79 -0
- declarative_data_backup/config/logging.py +8 -0
- declarative_data_backup/environment.py +70 -0
- declarative_data_backup/errors.py +13 -0
- declarative_data_backup-0.1.0.dist-info/METADATA +319 -0
- declarative_data_backup-0.1.0.dist-info/RECORD +20 -0
- declarative_data_backup-0.1.0.dist-info/WHEEL +5 -0
- declarative_data_backup-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Declarative Borg-based data backups."""
|
|
@@ -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,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,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 @@
|
|
|
1
|
+
declarative_data_backup
|