dar-backup 1.0.0__py3-none-any.whl → 1.0.1__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.
- dar_backup/Changelog.md +35 -1
- dar_backup/README.md +283 -22
- dar_backup/__about__.py +3 -1
- dar_backup/cleanup.py +153 -38
- dar_backup/command_runner.py +135 -102
- dar_backup/config_settings.py +143 -0
- dar_backup/dar-backup.conf +11 -0
- dar_backup/dar-backup.conf.j2 +42 -0
- dar_backup/dar_backup.py +391 -90
- dar_backup/manager.py +9 -3
- dar_backup/util.py +383 -130
- {dar_backup-1.0.0.dist-info → dar_backup-1.0.1.dist-info}/METADATA +285 -24
- dar_backup-1.0.1.dist-info/RECORD +25 -0
- {dar_backup-1.0.0.dist-info → dar_backup-1.0.1.dist-info}/WHEEL +1 -1
- dar_backup-1.0.0.dist-info/RECORD +0 -25
- {dar_backup-1.0.0.dist-info → dar_backup-1.0.1.dist-info}/entry_points.txt +0 -0
- {dar_backup-1.0.0.dist-info → dar_backup-1.0.1.dist-info}/licenses/LICENSE +0 -0
dar_backup/config_settings.py
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
2
|
|
|
3
3
|
import configparser
|
|
4
|
+
import re
|
|
4
5
|
from dataclasses import dataclass, field, fields
|
|
5
6
|
from os.path import expandvars, expanduser
|
|
6
7
|
from pathlib import Path
|
|
8
|
+
from typing import Optional, Pattern
|
|
7
9
|
|
|
8
10
|
from dar_backup.exceptions import ConfigSettingsError
|
|
9
11
|
|
|
@@ -44,8 +46,19 @@ class ConfigSettings:
|
|
|
44
46
|
incr_age: int = field(init=False)
|
|
45
47
|
error_correction_percent: int = field(init=False)
|
|
46
48
|
par2_enabled: bool = field(init=False)
|
|
49
|
+
par2_dir: Optional[str] = field(init=False, default=None)
|
|
50
|
+
par2_layout: Optional[str] = field(init=False, default=None)
|
|
51
|
+
par2_mode: Optional[str] = field(init=False, default=None)
|
|
52
|
+
par2_ratio_full: Optional[int] = field(init=False, default=None)
|
|
53
|
+
par2_ratio_diff: Optional[int] = field(init=False, default=None)
|
|
54
|
+
par2_ratio_incr: Optional[int] = field(init=False, default=None)
|
|
55
|
+
par2_run_verify: Optional[bool] = field(init=False, default=None)
|
|
47
56
|
logfile_max_bytes: int = field(init=False)
|
|
48
57
|
logfile_no_count: int = field(init=False)
|
|
58
|
+
dar_backup_discord_webhook_url: Optional[str] = field(init=False, default=None)
|
|
59
|
+
restoretest_exclude_prefixes: list[str] = field(init=False, default_factory=list)
|
|
60
|
+
restoretest_exclude_suffixes: list[str] = field(init=False, default_factory=list)
|
|
61
|
+
restoretest_exclude_regex: Optional[Pattern[str]] = field(init=False, default=None)
|
|
49
62
|
|
|
50
63
|
|
|
51
64
|
OPTIONAL_CONFIG_FIELDS = [
|
|
@@ -70,6 +83,13 @@ class ConfigSettings:
|
|
|
70
83
|
"type": int,
|
|
71
84
|
"default": 5,
|
|
72
85
|
},
|
|
86
|
+
{
|
|
87
|
+
"section": "MISC",
|
|
88
|
+
"key": "DAR_BACKUP_DISCORD_WEBHOOK_URL",
|
|
89
|
+
"attr": "dar_backup_discord_webhook_url",
|
|
90
|
+
"type": str,
|
|
91
|
+
"default": None,
|
|
92
|
+
},
|
|
73
93
|
# Add more optional fields here
|
|
74
94
|
]
|
|
75
95
|
|
|
@@ -103,6 +123,29 @@ class ConfigSettings:
|
|
|
103
123
|
else:
|
|
104
124
|
raise ConfigSettingsError(f"Invalid boolean value for 'ENABLED' in [PAR2]: '{val}'")
|
|
105
125
|
|
|
126
|
+
self.par2_dir = self._get_optional_str("PAR2", "PAR2_DIR", default=None)
|
|
127
|
+
self.par2_layout = self._get_optional_str("PAR2", "PAR2_LAYOUT", default="by-backup")
|
|
128
|
+
self.par2_mode = self._get_optional_str("PAR2", "PAR2_MODE", default=None)
|
|
129
|
+
self.par2_ratio_full = self._get_optional_int("PAR2", "PAR2_RATIO_FULL", default=None)
|
|
130
|
+
self.par2_ratio_diff = self._get_optional_int("PAR2", "PAR2_RATIO_DIFF", default=None)
|
|
131
|
+
self.par2_ratio_incr = self._get_optional_int("PAR2", "PAR2_RATIO_INCR", default=None)
|
|
132
|
+
self.par2_run_verify = self._get_optional_bool("PAR2", "PAR2_RUN_VERIFY", default=None)
|
|
133
|
+
self.restoretest_exclude_prefixes = self._get_optional_csv_list(
|
|
134
|
+
"MISC",
|
|
135
|
+
"RESTORETEST_EXCLUDE_PREFIXES",
|
|
136
|
+
default=[]
|
|
137
|
+
)
|
|
138
|
+
self.restoretest_exclude_suffixes = self._get_optional_csv_list(
|
|
139
|
+
"MISC",
|
|
140
|
+
"RESTORETEST_EXCLUDE_SUFFIXES",
|
|
141
|
+
default=[]
|
|
142
|
+
)
|
|
143
|
+
self.restoretest_exclude_regex = self._get_optional_regex(
|
|
144
|
+
"MISC",
|
|
145
|
+
"RESTORETEST_EXCLUDE_REGEX",
|
|
146
|
+
default=None
|
|
147
|
+
)
|
|
148
|
+
|
|
106
149
|
# Load optional fields
|
|
107
150
|
for opt in self.OPTIONAL_CONFIG_FIELDS:
|
|
108
151
|
if self.config.has_option(opt['section'], opt['key']):
|
|
@@ -144,4 +187,104 @@ class ConfigSettings:
|
|
|
144
187
|
]
|
|
145
188
|
return f"<ConfigSettings({', '.join(safe_fields)})>"
|
|
146
189
|
|
|
190
|
+
def _get_optional_str(self, section: str, key: str, default: Optional[str] = None) -> Optional[str]:
|
|
191
|
+
if self.config.has_option(section, key):
|
|
192
|
+
return self.config.get(section, key).strip()
|
|
193
|
+
return default
|
|
194
|
+
|
|
195
|
+
def _get_optional_int(self, section: str, key: str, default: Optional[int] = None) -> Optional[int]:
|
|
196
|
+
if self.config.has_option(section, key):
|
|
197
|
+
raw = self.config.get(section, key).strip()
|
|
198
|
+
return int(raw)
|
|
199
|
+
return default
|
|
200
|
+
|
|
201
|
+
def _get_optional_bool(self, section: str, key: str, default: Optional[bool] = None) -> Optional[bool]:
|
|
202
|
+
if not self.config.has_option(section, key):
|
|
203
|
+
return default
|
|
204
|
+
val = self.config.get(section, key).strip().lower()
|
|
205
|
+
if val in ('true', '1', 'yes'):
|
|
206
|
+
return True
|
|
207
|
+
if val in ('false', '0', 'no'):
|
|
208
|
+
return False
|
|
209
|
+
raise ConfigSettingsError(f"Invalid boolean value for '{key}' in [{section}]: '{val}'")
|
|
210
|
+
|
|
211
|
+
def _get_optional_csv_list(self, section: str, key: str, default: Optional[list[str]] = None) -> list[str]:
|
|
212
|
+
if not self.config.has_option(section, key):
|
|
213
|
+
return default if default is not None else []
|
|
214
|
+
raw = self.config.get(section, key).strip()
|
|
215
|
+
if not raw:
|
|
216
|
+
return default if default is not None else []
|
|
217
|
+
return [item.strip() for item in raw.split(",") if item.strip()]
|
|
218
|
+
|
|
219
|
+
def _get_optional_regex(
|
|
220
|
+
self,
|
|
221
|
+
section: str,
|
|
222
|
+
key: str,
|
|
223
|
+
default: Optional[Pattern[str]] = None
|
|
224
|
+
) -> Optional[Pattern[str]]:
|
|
225
|
+
if not self.config.has_option(section, key):
|
|
226
|
+
return default
|
|
227
|
+
raw = self.config.get(section, key).strip()
|
|
228
|
+
if not raw:
|
|
229
|
+
return default
|
|
230
|
+
try:
|
|
231
|
+
return re.compile(raw, re.IGNORECASE)
|
|
232
|
+
except re.error as exc:
|
|
233
|
+
raise ConfigSettingsError(
|
|
234
|
+
f"Invalid regex for '{key}' in [{section}]: {exc}"
|
|
235
|
+
) from exc
|
|
236
|
+
|
|
237
|
+
def get_par2_config(self, backup_definition: Optional[str] = None) -> dict:
|
|
238
|
+
"""
|
|
239
|
+
Return PAR2 settings, applying per-backup overrides when present.
|
|
240
|
+
"""
|
|
241
|
+
par2_config = {
|
|
242
|
+
"par2_dir": self.par2_dir,
|
|
243
|
+
"par2_layout": self.par2_layout,
|
|
244
|
+
"par2_mode": self.par2_mode,
|
|
245
|
+
"par2_ratio_full": self.par2_ratio_full,
|
|
246
|
+
"par2_ratio_diff": self.par2_ratio_diff,
|
|
247
|
+
"par2_ratio_incr": self.par2_ratio_incr,
|
|
248
|
+
"par2_run_verify": self.par2_run_verify,
|
|
249
|
+
"par2_enabled": self.par2_enabled,
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if not backup_definition or not self.config.has_section(backup_definition):
|
|
253
|
+
return par2_config
|
|
254
|
+
|
|
255
|
+
section = self.config[backup_definition]
|
|
256
|
+
for raw_key, raw_value in section.items():
|
|
257
|
+
key = raw_key.upper()
|
|
258
|
+
value = raw_value.strip()
|
|
259
|
+
if not key.startswith("PAR2_"):
|
|
260
|
+
continue
|
|
261
|
+
if key == "PAR2_DIR":
|
|
262
|
+
par2_config["par2_dir"] = value
|
|
263
|
+
elif key == "PAR2_LAYOUT":
|
|
264
|
+
par2_config["par2_layout"] = value
|
|
265
|
+
elif key == "PAR2_MODE":
|
|
266
|
+
par2_config["par2_mode"] = value
|
|
267
|
+
elif key == "PAR2_RATIO_FULL":
|
|
268
|
+
par2_config["par2_ratio_full"] = int(value)
|
|
269
|
+
elif key == "PAR2_RATIO_DIFF":
|
|
270
|
+
par2_config["par2_ratio_diff"] = int(value)
|
|
271
|
+
elif key == "PAR2_RATIO_INCR":
|
|
272
|
+
par2_config["par2_ratio_incr"] = int(value)
|
|
273
|
+
elif key == "PAR2_RUN_VERIFY":
|
|
274
|
+
val = value.lower()
|
|
275
|
+
if val in ('true', '1', 'yes'):
|
|
276
|
+
par2_config["par2_run_verify"] = True
|
|
277
|
+
elif val in ('false', '0', 'no'):
|
|
278
|
+
par2_config["par2_run_verify"] = False
|
|
279
|
+
else:
|
|
280
|
+
raise ConfigSettingsError(f"Invalid boolean value for 'PAR2_RUN_VERIFY' in [{backup_definition}]: '{value}'")
|
|
281
|
+
elif key == "PAR2_ENABLED":
|
|
282
|
+
val = value.lower()
|
|
283
|
+
if val in ('true', '1', 'yes'):
|
|
284
|
+
par2_config["par2_enabled"] = True
|
|
285
|
+
elif val in ('false', '0', 'no'):
|
|
286
|
+
par2_config["par2_enabled"] = False
|
|
287
|
+
else:
|
|
288
|
+
raise ConfigSettingsError(f"Invalid boolean value for 'PAR2_ENABLED' in [{backup_definition}]: '{value}'")
|
|
147
289
|
|
|
290
|
+
return par2_config
|
dar_backup/dar-backup.conf
CHANGED
|
@@ -9,10 +9,15 @@ LOGFILE_LOCATION = ~/dar-backup/dar-backup.log
|
|
|
9
9
|
MAX_SIZE_VERIFICATION_MB = 20
|
|
10
10
|
MIN_SIZE_VERIFICATION_MB = 1
|
|
11
11
|
NO_FILES_VERIFICATION = 5
|
|
12
|
+
# Optional restore test filters (case-insensitive)
|
|
13
|
+
# RESTORETEST_EXCLUDE_PREFIXES = .cache/, .local/share/Trash/, .mozilla/
|
|
14
|
+
# RESTORETEST_EXCLUDE_SUFFIXES = .sqlite-wal, .sqlite-shm, .log, .tmp, .lock, .journal
|
|
15
|
+
# RESTORETEST_EXCLUDE_REGEX = (^|/)(Cache|Logs)/ # optional extra noise
|
|
12
16
|
# timeout in seconds for backup, test, restore and par2 operations
|
|
13
17
|
# The author has such `dar` tasks running for 10-15 hours on the yearly backups, so a value of 24 hours is used.
|
|
14
18
|
# If a timeout is not specified when using the CommandRunner, a default timeout of 30 secs is used.
|
|
15
19
|
COMMAND_TIMEOUT_SECS = 86400
|
|
20
|
+
#DAR_BACKUP_DISCORD_WEBHOOK_URL = https://discord.com/api/webhooks/<id>/<token>
|
|
16
21
|
|
|
17
22
|
[DIRECTORIES]
|
|
18
23
|
BACKUP_DIR = @@BACKUP_DIR@@
|
|
@@ -31,6 +36,12 @@ INCR_AGE = 40
|
|
|
31
36
|
[PAR2]
|
|
32
37
|
ERROR_CORRECTION_PERCENT = 5
|
|
33
38
|
ENABLED = True
|
|
39
|
+
# Optional PAR2 configuration
|
|
40
|
+
# PAR2_DIR = /path/to/par2-store
|
|
41
|
+
# PAR2_RATIO_FULL = 10
|
|
42
|
+
# PAR2_RATIO_DIFF = 5
|
|
43
|
+
# PAR2_RATIO_INCR = 5
|
|
44
|
+
# PAR2_RUN_VERIFY = false
|
|
34
45
|
|
|
35
46
|
[PREREQ]
|
|
36
47
|
#SCRIPT_1 = <pre-script 1>
|
dar_backup/dar-backup.conf.j2
CHANGED
|
@@ -29,10 +29,16 @@ LOGFILE_LOCATION = {{ vars_map.DAR_BACKUP_DIR -}}/dar-backup.log
|
|
|
29
29
|
# optional parameters
|
|
30
30
|
# LOGFILE_MAX_BYTES = 26214400 # 25 MB default, change as neeeded
|
|
31
31
|
# LOGFILE_BACKUP_COUNT = 5 # default, change as needed
|
|
32
|
+
# DAR_BACKUP_DISCORD_WEBHOOK_URL **should really** be given as an environment variable for security reasons
|
|
33
|
+
# DAR_BACKUP_DISCORD_WEBHOOK_URL = https://discord.com/api/webhooks/<id>/<token>
|
|
32
34
|
|
|
33
35
|
MAX_SIZE_VERIFICATION_MB = 2
|
|
34
36
|
MIN_SIZE_VERIFICATION_MB = 0
|
|
35
37
|
NO_FILES_VERIFICATION = 1
|
|
38
|
+
# Optional restore test filters (case-insensitive)
|
|
39
|
+
# RESTORETEST_EXCLUDE_PREFIXES = .cache/, .local/share/Trash/, .mozilla/
|
|
40
|
+
# RESTORETEST_EXCLUDE_SUFFIXES = .sqlite-wal, .sqlite-shm, .log, .tmp, .lock, .journal
|
|
41
|
+
# RESTORETEST_EXCLUDE_REGEX = (^|/)(Cache|Logs)/
|
|
36
42
|
# timeout in seconds for backup, test, restore and par2 operations
|
|
37
43
|
# The author has such `dar` tasks running for 10-15 hours on the yearly backups, so a value of 24 hours is used.
|
|
38
44
|
# If a timeout is not specified when using the CommandRunner, a default timeout of 30 secs is used.
|
|
@@ -54,7 +60,16 @@ INCR_AGE = 40
|
|
|
54
60
|
|
|
55
61
|
[PAR2]
|
|
56
62
|
ERROR_CORRECTION_PERCENT = 5
|
|
63
|
+
# Enable or disable PAR2 generation globally
|
|
57
64
|
ENABLED = True
|
|
65
|
+
# Optional PAR2 configuration
|
|
66
|
+
# PAR2_DIR = /path/to/par2-store
|
|
67
|
+
# PAR2_LAYOUT = by-backup
|
|
68
|
+
# PAR2_RATIOs are meuasured as percentages. Same function as ERROR_CORRECTION_PERCENT
|
|
69
|
+
# PAR2_RATIO_FULL = 10
|
|
70
|
+
# PAR2_RATIO_DIFF = 5
|
|
71
|
+
# PAR2_RATIO_INCR = 5
|
|
72
|
+
# PAR2_RUN_VERIFY = false
|
|
58
73
|
|
|
59
74
|
[PREREQ]
|
|
60
75
|
#SCRIPT_1 = <pre-script 1>
|
|
@@ -62,3 +77,30 @@ ENABLED = True
|
|
|
62
77
|
[POSTREQ]
|
|
63
78
|
#SCRIPT_1 = <post-script 1>
|
|
64
79
|
|
|
80
|
+
#######################################################################
|
|
81
|
+
## Per-backup configuration example overrides
|
|
82
|
+
#######################################################################
|
|
83
|
+
#
|
|
84
|
+
## --------------------------------------------------------------------
|
|
85
|
+
## Per-backup overrides (section name must match backup.d filename stem)
|
|
86
|
+
## Example: backup.d/home.conf -> [home]
|
|
87
|
+
## --------------------------------------------------------------------
|
|
88
|
+
#
|
|
89
|
+
##[home]
|
|
90
|
+
## Disable PAR2 entirely for this backup definition
|
|
91
|
+
#PAR2_ENABLED = false
|
|
92
|
+
##
|
|
93
|
+
##[media]
|
|
94
|
+
## Store PAR2 files in a separate location for this backup definition
|
|
95
|
+
##PAR2_DIR = /samba/par2/media
|
|
96
|
+
## Raise redundancy only for FULL
|
|
97
|
+
##
|
|
98
|
+
#[documents]
|
|
99
|
+
## Run verify par2 sets after creation
|
|
100
|
+
#PAR2_RUN_VERIFY = true
|
|
101
|
+
##
|
|
102
|
+
##[etc]
|
|
103
|
+
## Keep global PAR2 settings but tweak ratios for this backup definition
|
|
104
|
+
##PAR2_RATIO_FULL = 15
|
|
105
|
+
##PAR2_RATIO_DIFF = 8
|
|
106
|
+
##PAR2_RATIO_INCR = 8
|