dar-backup 1.0.1__py3-none-any.whl → 1.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.
- dar_backup/__about__.py +1 -2
- dar_backup/clean_log.py +102 -63
- dar_backup/cleanup.py +139 -107
- dar_backup/command_runner.py +123 -15
- dar_backup/config_settings.py +25 -12
- dar_backup/dar-backup.conf +7 -0
- dar_backup/dar-backup.conf.j2 +3 -1
- dar_backup/dar_backup.py +529 -102
- dar_backup/dar_backup_systemd.py +1 -1
- dar_backup/demo.py +19 -11
- dar_backup/installer.py +18 -1
- dar_backup/manager.py +1085 -96
- dar_backup/util.py +128 -19
- {dar_backup-1.0.1.dist-info → dar_backup-1.1.0.dist-info}/METADATA +320 -42
- dar_backup-1.1.0.dist-info/RECORD +23 -0
- dar_backup/Changelog.md +0 -401
- dar_backup/README.md +0 -2045
- dar_backup-1.0.1.dist-info/RECORD +0 -25
- {dar_backup-1.0.1.dist-info → dar_backup-1.1.0.dist-info}/WHEEL +0 -0
- {dar_backup-1.0.1.dist-info → dar_backup-1.1.0.dist-info}/entry_points.txt +0 -0
- {dar_backup-1.0.1.dist-info → dar_backup-1.1.0.dist-info}/licenses/LICENSE +0 -0
dar_backup/dar_backup.py
CHANGED
|
@@ -25,23 +25,23 @@ import subprocess
|
|
|
25
25
|
import configparser
|
|
26
26
|
import xml.etree.ElementTree as ET
|
|
27
27
|
import tempfile
|
|
28
|
+
import threading
|
|
28
29
|
|
|
29
|
-
from argparse import ArgumentParser
|
|
30
30
|
from datetime import datetime
|
|
31
31
|
from pathlib import Path
|
|
32
32
|
from sys import exit
|
|
33
33
|
from sys import stderr
|
|
34
|
-
from sys import argv
|
|
35
34
|
from sys import version_info
|
|
36
35
|
from time import time
|
|
37
36
|
from rich.console import Console
|
|
38
37
|
from rich.text import Text
|
|
39
|
-
from typing import List, Tuple
|
|
38
|
+
from typing import Iterable, Iterator, List, Optional, Tuple
|
|
40
39
|
|
|
41
40
|
from . import __about__ as about
|
|
42
41
|
from dar_backup.config_settings import ConfigSettings
|
|
43
42
|
from dar_backup.util import list_backups
|
|
44
43
|
from dar_backup.util import setup_logging
|
|
44
|
+
from dar_backup.util import derive_trace_log_path
|
|
45
45
|
from dar_backup.util import get_logger
|
|
46
46
|
from dar_backup.util import BackupError
|
|
47
47
|
from dar_backup.util import RestoreError
|
|
@@ -53,14 +53,11 @@ from dar_backup.util import get_binary_info
|
|
|
53
53
|
from dar_backup.util import print_aligned_settings
|
|
54
54
|
from dar_backup.util import backup_definition_completer, list_archive_completer
|
|
55
55
|
from dar_backup.util import show_scriptname
|
|
56
|
-
from dar_backup.util import print_debug
|
|
57
56
|
from dar_backup.util import send_discord_message
|
|
58
57
|
|
|
59
58
|
from dar_backup.command_runner import CommandRunner
|
|
60
|
-
from dar_backup.command_runner import CommandResult
|
|
61
59
|
|
|
62
60
|
|
|
63
|
-
from argcomplete.completers import FilesCompleter
|
|
64
61
|
|
|
65
62
|
logger = None
|
|
66
63
|
runner = None
|
|
@@ -125,7 +122,7 @@ def generic_backup(type: str, command: List[str], backup_file: str, backup_defin
|
|
|
125
122
|
logger.error(f"Backup command failed: {e}")
|
|
126
123
|
raise BackupError(f"Backup command failed: {e}") from e
|
|
127
124
|
except Exception as e:
|
|
128
|
-
logger.exception(
|
|
125
|
+
logger.exception("Unexpected error during backup")
|
|
129
126
|
raise BackupError(f"Unexpected error during backup: {e}") from e
|
|
130
127
|
|
|
131
128
|
|
|
@@ -163,7 +160,59 @@ def find_files_with_paths(xml_doc: str):
|
|
|
163
160
|
return files_list
|
|
164
161
|
|
|
165
162
|
|
|
166
|
-
|
|
163
|
+
class DoctypeStripper:
|
|
164
|
+
"""
|
|
165
|
+
File-like wrapper that strips DOCTYPE lines to prevent XXE.
|
|
166
|
+
"""
|
|
167
|
+
def __init__(self, path):
|
|
168
|
+
self.f = open(path, "r", encoding="utf-8")
|
|
169
|
+
self.buf = ""
|
|
170
|
+
def read(self, n=-1):
|
|
171
|
+
if n is None or n < 0:
|
|
172
|
+
out = []
|
|
173
|
+
for line in self.f:
|
|
174
|
+
if "<!DOCTYPE" not in line:
|
|
175
|
+
out.append(line)
|
|
176
|
+
return "".join(out)
|
|
177
|
+
while len(self.buf) < n:
|
|
178
|
+
line = self.f.readline()
|
|
179
|
+
if not line:
|
|
180
|
+
break
|
|
181
|
+
if "<!DOCTYPE" not in line:
|
|
182
|
+
self.buf += line
|
|
183
|
+
result, self.buf = self.buf[:n], self.buf[n:]
|
|
184
|
+
return result
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def iter_files_with_paths_from_xml(xml_path: str) -> Iterator[Tuple[str, str]]:
|
|
188
|
+
"""
|
|
189
|
+
Stream file paths and sizes from a DAR XML listing to keep memory usage low.
|
|
190
|
+
"""
|
|
191
|
+
path_stack: List[str] = []
|
|
192
|
+
# Disable XXE by stripping DOCTYPE
|
|
193
|
+
context = ET.iterparse(DoctypeStripper(xml_path), events=("start", "end"))
|
|
194
|
+
for event, elem in context:
|
|
195
|
+
if event == "start" and elem.tag == "Directory":
|
|
196
|
+
dir_name = elem.get("name")
|
|
197
|
+
if dir_name:
|
|
198
|
+
path_stack.append(dir_name)
|
|
199
|
+
elif event == "end" and elem.tag == "File":
|
|
200
|
+
file_name = elem.get("name")
|
|
201
|
+
file_size = elem.get("size")
|
|
202
|
+
if file_name:
|
|
203
|
+
if path_stack:
|
|
204
|
+
file_path = "/".join(path_stack + [file_name])
|
|
205
|
+
else:
|
|
206
|
+
file_path = file_name
|
|
207
|
+
yield (file_path, file_size)
|
|
208
|
+
elem.clear()
|
|
209
|
+
elif event == "end" and elem.tag == "Directory":
|
|
210
|
+
if path_stack:
|
|
211
|
+
path_stack.pop()
|
|
212
|
+
elem.clear()
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def find_files_between_min_and_max_size(backed_up_files: Iterable[Tuple[str, str]], config_settings: ConfigSettings):
|
|
167
216
|
"""Find files within a specified size range.
|
|
168
217
|
|
|
169
218
|
This function takes a list of backed up files, a minimum size in megabytes, and a maximum size in megabytes.
|
|
@@ -192,21 +241,21 @@ def find_files_between_min_and_max_size(backed_up_files: list[(str, str)], confi
|
|
|
192
241
|
"Tio" : 1024 * 1024 * 1024 * 1024
|
|
193
242
|
}
|
|
194
243
|
pattern = r'(\d+)\s*(\w+)'
|
|
195
|
-
for
|
|
196
|
-
if
|
|
197
|
-
logger.trace("tuple from dar xml list: {
|
|
198
|
-
match = re.match(pattern,
|
|
244
|
+
for item in backed_up_files:
|
|
245
|
+
if item is not None and len(item) >= 2 and item[0] is not None and item[1] is not None:
|
|
246
|
+
logger.trace(f"tuple from dar xml list: {item}")
|
|
247
|
+
match = re.match(pattern, item[1])
|
|
199
248
|
if match:
|
|
200
249
|
number = int(match.group(1))
|
|
201
250
|
unit = match.group(2).strip()
|
|
202
251
|
file_size = dar_sizes[unit] * number
|
|
203
252
|
if (min_size * 1024 * 1024) <= file_size <= (max_size * 1024 * 1024):
|
|
204
|
-
logger.trace(f"File found between min and max sizes: {
|
|
205
|
-
files.append(
|
|
253
|
+
logger.trace(f"File found between min and max sizes: {item}")
|
|
254
|
+
files.append(item[0])
|
|
206
255
|
return files
|
|
207
256
|
|
|
208
257
|
|
|
209
|
-
def
|
|
258
|
+
def _is_restoretest_candidate(path: str, config_settings: ConfigSettings) -> bool:
|
|
210
259
|
prefixes = [
|
|
211
260
|
prefix.lstrip("/").lower()
|
|
212
261
|
for prefix in getattr(config_settings, "restoretest_exclude_prefixes", [])
|
|
@@ -217,21 +266,19 @@ def filter_restoretest_candidates(files: List[str], config_settings: ConfigSetti
|
|
|
217
266
|
]
|
|
218
267
|
regex = getattr(config_settings, "restoretest_exclude_regex", None)
|
|
219
268
|
|
|
220
|
-
|
|
221
|
-
|
|
269
|
+
normalized = path.lstrip("/")
|
|
270
|
+
lowered = normalized.lower()
|
|
271
|
+
if prefixes and any(lowered.startswith(prefix) for prefix in prefixes):
|
|
272
|
+
return False
|
|
273
|
+
if suffixes and any(lowered.endswith(suffix) for suffix in suffixes):
|
|
274
|
+
return False
|
|
275
|
+
if regex and regex.search(normalized):
|
|
276
|
+
return False
|
|
277
|
+
return True
|
|
222
278
|
|
|
223
|
-
filtered = []
|
|
224
|
-
for path in files:
|
|
225
|
-
normalized = path.lstrip("/")
|
|
226
|
-
lowered = normalized.lower()
|
|
227
|
-
if prefixes and any(lowered.startswith(prefix) for prefix in prefixes):
|
|
228
|
-
continue
|
|
229
|
-
if suffixes and any(lowered.endswith(suffix) for suffix in suffixes):
|
|
230
|
-
continue
|
|
231
|
-
if regex and regex.search(normalized):
|
|
232
|
-
continue
|
|
233
|
-
filtered.append(path)
|
|
234
279
|
|
|
280
|
+
def filter_restoretest_candidates(files: List[str], config_settings: ConfigSettings) -> List[str]:
|
|
281
|
+
filtered = [path for path in files if _is_restoretest_candidate(path, config_settings)]
|
|
235
282
|
if logger:
|
|
236
283
|
excluded = len(files) - len(filtered)
|
|
237
284
|
if excluded:
|
|
@@ -239,6 +286,70 @@ def filter_restoretest_candidates(files: List[str], config_settings: ConfigSetti
|
|
|
239
286
|
return filtered
|
|
240
287
|
|
|
241
288
|
|
|
289
|
+
def _size_in_verification_range(size_text: str, config_settings: ConfigSettings) -> bool:
|
|
290
|
+
dar_sizes = {
|
|
291
|
+
"o" : 1,
|
|
292
|
+
"kio" : 1024,
|
|
293
|
+
"Mio" : 1024 * 1024,
|
|
294
|
+
"Gio" : 1024 * 1024 * 1024,
|
|
295
|
+
"Tio" : 1024 * 1024 * 1024 * 1024
|
|
296
|
+
}
|
|
297
|
+
pattern = r'(\d+)\s*(\w+)'
|
|
298
|
+
match = re.match(pattern, size_text or "")
|
|
299
|
+
if not match:
|
|
300
|
+
return False
|
|
301
|
+
unit = match.group(2).strip()
|
|
302
|
+
if unit not in dar_sizes:
|
|
303
|
+
return False
|
|
304
|
+
number = int(match.group(1))
|
|
305
|
+
file_size = dar_sizes[unit] * number
|
|
306
|
+
min_size = config_settings.min_size_verification_mb * 1024 * 1024
|
|
307
|
+
max_size = config_settings.max_size_verification_mb * 1024 * 1024
|
|
308
|
+
return min_size <= file_size <= max_size
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def select_restoretest_samples(
|
|
312
|
+
backed_up_files: Iterable[Tuple[str, str]],
|
|
313
|
+
config_settings: ConfigSettings,
|
|
314
|
+
sample_size: int
|
|
315
|
+
) -> List[str]:
|
|
316
|
+
if sample_size <= 0:
|
|
317
|
+
return []
|
|
318
|
+
reservoir: List[str] = []
|
|
319
|
+
candidates_seen = 0
|
|
320
|
+
size_filtered_total = 0
|
|
321
|
+
excluded = 0
|
|
322
|
+
for item in backed_up_files:
|
|
323
|
+
if item is None or len(item) < 2:
|
|
324
|
+
continue
|
|
325
|
+
path, size_text = item[0], item[1]
|
|
326
|
+
if not path or not size_text:
|
|
327
|
+
continue
|
|
328
|
+
if not _size_in_verification_range(size_text, config_settings):
|
|
329
|
+
continue
|
|
330
|
+
size_filtered_total += 1
|
|
331
|
+
if not _is_restoretest_candidate(path, config_settings):
|
|
332
|
+
excluded += 1
|
|
333
|
+
continue
|
|
334
|
+
candidates_seen += 1
|
|
335
|
+
if candidates_seen <= sample_size:
|
|
336
|
+
reservoir.append(path)
|
|
337
|
+
else:
|
|
338
|
+
idx = random.randint(1, candidates_seen)
|
|
339
|
+
if idx <= sample_size:
|
|
340
|
+
reservoir[idx - 1] = path
|
|
341
|
+
if logger:
|
|
342
|
+
if size_filtered_total and excluded:
|
|
343
|
+
logger.debug(f"Restore test filter excluded {excluded} of {size_filtered_total} candidates")
|
|
344
|
+
if candidates_seen == 0:
|
|
345
|
+
logger.debug("No restore test candidates found after size/exclude filters")
|
|
346
|
+
elif candidates_seen <= sample_size:
|
|
347
|
+
logger.debug(f"Restore test candidates available: {candidates_seen}, selecting all")
|
|
348
|
+
else:
|
|
349
|
+
logger.debug(f"Restore test candidates available: {candidates_seen}, sampled: {sample_size}")
|
|
350
|
+
return reservoir
|
|
351
|
+
|
|
352
|
+
|
|
242
353
|
def verify(args: argparse.Namespace, backup_file: str, backup_definition: str, config_settings: ConfigSettings):
|
|
243
354
|
"""
|
|
244
355
|
Verify the integrity of a DAR backup by performing the following steps:
|
|
@@ -278,10 +389,17 @@ def verify(args: argparse.Namespace, backup_file: str, backup_definition: str, c
|
|
|
278
389
|
if args.do_not_compare:
|
|
279
390
|
return result
|
|
280
391
|
|
|
281
|
-
backed_up_files = get_backed_up_files(
|
|
392
|
+
backed_up_files = get_backed_up_files(
|
|
393
|
+
backup_file,
|
|
394
|
+
config_settings.backup_dir,
|
|
395
|
+
timeout=config_settings.command_timeout_secs
|
|
396
|
+
)
|
|
282
397
|
|
|
283
|
-
files =
|
|
284
|
-
|
|
398
|
+
files = select_restoretest_samples(
|
|
399
|
+
backed_up_files,
|
|
400
|
+
config_settings,
|
|
401
|
+
config_settings.no_files_verification
|
|
402
|
+
)
|
|
285
403
|
if len(files) == 0:
|
|
286
404
|
logger.info(
|
|
287
405
|
"No files eligible for verification after size and restore-test filters, skipping"
|
|
@@ -305,10 +423,7 @@ def verify(args: argparse.Namespace, backup_file: str, backup_definition: str, c
|
|
|
305
423
|
|
|
306
424
|
|
|
307
425
|
|
|
308
|
-
|
|
309
|
-
if len(files) < config_settings.no_files_verification:
|
|
310
|
-
no_files_verification = len(files)
|
|
311
|
-
random_files = random.sample(files, no_files_verification)
|
|
426
|
+
random_files = files
|
|
312
427
|
|
|
313
428
|
# Ensure restore directory exists for verification restores
|
|
314
429
|
try:
|
|
@@ -317,7 +432,14 @@ def verify(args: argparse.Namespace, backup_file: str, backup_definition: str, c
|
|
|
317
432
|
raise BackupError(f"Cannot create restore directory '{config_settings.test_restore_dir}': {exc}") from exc
|
|
318
433
|
|
|
319
434
|
for restored_file_path in random_files:
|
|
435
|
+
restore_path = os.path.join(config_settings.test_restore_dir, restored_file_path.lstrip("/"))
|
|
436
|
+
source_path = os.path.join(root_path, restored_file_path.lstrip("/"))
|
|
320
437
|
try:
|
|
438
|
+
if os.path.exists(restore_path):
|
|
439
|
+
try:
|
|
440
|
+
os.remove(restore_path)
|
|
441
|
+
except OSError:
|
|
442
|
+
pass
|
|
321
443
|
args.verbose and logger.info(f"Restoring file: '{restored_file_path}' from backup to: '{config_settings.test_restore_dir}' for file comparing")
|
|
322
444
|
command = ['dar', '-x', backup_file, '-g', restored_file_path.lstrip("/"), '-R', config_settings.test_restore_dir, '--noconf', '-Q', '-B', args.darrc, 'restore-options']
|
|
323
445
|
args.verbose and logger.info(f"Running command: {' '.join(map(shlex.quote, command))}")
|
|
@@ -325,15 +447,30 @@ def verify(args: argparse.Namespace, backup_file: str, backup_definition: str, c
|
|
|
325
447
|
if process.returncode != 0:
|
|
326
448
|
raise Exception(str(process))
|
|
327
449
|
|
|
328
|
-
if filecmp.cmp(
|
|
450
|
+
if filecmp.cmp(restore_path, source_path, shallow=False):
|
|
329
451
|
args.verbose and logger.info(f"Success: file '{restored_file_path}' matches the original")
|
|
330
452
|
else:
|
|
331
453
|
result = False
|
|
332
454
|
logger.error(f"Failure: file '{restored_file_path}' did not match the original")
|
|
333
455
|
except PermissionError:
|
|
334
456
|
result = False
|
|
335
|
-
logger.exception(
|
|
457
|
+
logger.exception("Permission error while comparing files, continuing....")
|
|
336
458
|
logger.error("Exception details:", exc_info=True)
|
|
459
|
+
except FileNotFoundError as exc:
|
|
460
|
+
result = False
|
|
461
|
+
missing_path = exc.filename or "unknown path"
|
|
462
|
+
if missing_path == source_path:
|
|
463
|
+
logger.warning(
|
|
464
|
+
f"Restore verification skipped for '{restored_file_path}': source file missing: '{source_path}'"
|
|
465
|
+
)
|
|
466
|
+
elif missing_path == restore_path:
|
|
467
|
+
logger.warning(
|
|
468
|
+
f"Restore verification skipped for '{restored_file_path}': restored file missing: '{restore_path}'"
|
|
469
|
+
)
|
|
470
|
+
else:
|
|
471
|
+
logger.warning(
|
|
472
|
+
f"Restore verification skipped for '{restored_file_path}': file not found: '{missing_path}'"
|
|
473
|
+
)
|
|
337
474
|
return result
|
|
338
475
|
|
|
339
476
|
|
|
@@ -382,7 +519,7 @@ def restore_backup(backup_name: str, config_settings: ConfigSettings, restore_di
|
|
|
382
519
|
return results
|
|
383
520
|
|
|
384
521
|
|
|
385
|
-
def get_backed_up_files(backup_name: str, backup_dir: str):
|
|
522
|
+
def get_backed_up_files(backup_name: str, backup_dir: str, timeout: Optional[int] = None) -> Iterable[Tuple[str, str]]:
|
|
386
523
|
"""
|
|
387
524
|
Retrieves the list of backed up files from a DAR archive.
|
|
388
525
|
|
|
@@ -391,21 +528,89 @@ def get_backed_up_files(backup_name: str, backup_dir: str):
|
|
|
391
528
|
backup_dir (str): The directory where the DAR archive is located.
|
|
392
529
|
|
|
393
530
|
Returns:
|
|
394
|
-
|
|
531
|
+
Iterable[Tuple[str, str]]: Stream of (file path, size) tuples for all backed up files.
|
|
395
532
|
"""
|
|
396
533
|
logger.debug(f"Getting backed up files in xml from DAR archive: '{backup_name}'")
|
|
397
534
|
backup_path = os.path.join(backup_dir, backup_name)
|
|
535
|
+
temp_path = None
|
|
398
536
|
try:
|
|
399
537
|
command = ['dar', '-l', backup_path, '--noconf', '-am', '-as', "-Txml" , '-Q']
|
|
400
538
|
logger.debug(f"Running command: {' '.join(map(shlex.quote, command))}")
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
539
|
+
if runner is not None and getattr(runner, "_is_mock_object", False):
|
|
540
|
+
command_result = runner.run(command)
|
|
541
|
+
file_paths = find_files_with_paths(command_result.stdout)
|
|
542
|
+
return file_paths
|
|
543
|
+
stderr_lines: List[str] = []
|
|
544
|
+
with tempfile.NamedTemporaryFile(mode="w+", encoding="utf-8", delete=False) as temp_file:
|
|
545
|
+
temp_path = temp_file.name
|
|
546
|
+
process = subprocess.Popen(
|
|
547
|
+
command,
|
|
548
|
+
stdout=subprocess.PIPE,
|
|
549
|
+
stderr=subprocess.PIPE,
|
|
550
|
+
text=True,
|
|
551
|
+
bufsize=1
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
def read_stderr():
|
|
555
|
+
if process.stderr is None:
|
|
556
|
+
return
|
|
557
|
+
for line in process.stderr:
|
|
558
|
+
stderr_lines.append(line)
|
|
559
|
+
|
|
560
|
+
stderr_thread = threading.Thread(target=read_stderr)
|
|
561
|
+
stderr_thread.start()
|
|
562
|
+
|
|
563
|
+
if process.stdout is not None:
|
|
564
|
+
for line in process.stdout:
|
|
565
|
+
if "<!DOCTYPE" in line:
|
|
566
|
+
continue
|
|
567
|
+
temp_file.write(line)
|
|
568
|
+
if process.stdout is not None:
|
|
569
|
+
process.stdout.close()
|
|
570
|
+
|
|
571
|
+
try:
|
|
572
|
+
process.wait(timeout=timeout)
|
|
573
|
+
except subprocess.TimeoutExpired:
|
|
574
|
+
process.kill()
|
|
575
|
+
stderr_thread.join()
|
|
576
|
+
raise
|
|
577
|
+
stderr_thread.join()
|
|
578
|
+
|
|
579
|
+
if process.returncode != 0:
|
|
580
|
+
stderr_text = "".join(stderr_lines)
|
|
581
|
+
logger.error(f"Error listing backed up files from DAR archive: '{backup_name}'")
|
|
582
|
+
try:
|
|
583
|
+
os.remove(temp_path)
|
|
584
|
+
except OSError:
|
|
585
|
+
logger.warning(f"Could not delete temporary file: {temp_path}")
|
|
586
|
+
raise BackupError(
|
|
587
|
+
f"Error listing backed up files from DAR archive: '{backup_name}'"
|
|
588
|
+
f"\nStderr: {stderr_text}"
|
|
589
|
+
)
|
|
590
|
+
|
|
591
|
+
def iter_files():
|
|
592
|
+
try:
|
|
593
|
+
for item in iter_files_with_paths_from_xml(temp_path):
|
|
594
|
+
yield item
|
|
595
|
+
finally:
|
|
596
|
+
try:
|
|
597
|
+
os.remove(temp_path)
|
|
598
|
+
except OSError:
|
|
599
|
+
logger.warning(f"Could not delete temporary file: {temp_path}")
|
|
600
|
+
|
|
601
|
+
return iter_files()
|
|
405
602
|
except subprocess.CalledProcessError as e:
|
|
406
603
|
logger.error(f"Error listing backed up files from DAR archive: '{backup_name}'")
|
|
407
604
|
raise BackupError(f"Error listing backed up files from DAR archive: '{backup_name}'") from e
|
|
605
|
+
except subprocess.TimeoutExpired as e:
|
|
606
|
+
logger.error(f"Timeout listing backed up files from DAR archive: '{backup_name}'")
|
|
607
|
+
raise BackupError(f"Timeout listing backed up files from DAR archive: '{backup_name}'") from e
|
|
408
608
|
except Exception as e:
|
|
609
|
+
if temp_path:
|
|
610
|
+
try:
|
|
611
|
+
os.remove(temp_path)
|
|
612
|
+
except OSError:
|
|
613
|
+
logger.warning(f"Could not delete temporary file: {temp_path}")
|
|
409
614
|
raise RuntimeError(f"Unexpected error listing backed up files from DAR archive: '{backup_name}'") from e
|
|
410
615
|
|
|
411
616
|
|
|
@@ -428,16 +633,105 @@ def list_contents(backup_name, backup_dir, selection=None):
|
|
|
428
633
|
if selection:
|
|
429
634
|
selection_criteria = shlex.split(selection)
|
|
430
635
|
command.extend(selection_criteria)
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
636
|
+
if runner is not None and getattr(runner, "_is_mock_object", False):
|
|
637
|
+
process = runner.run(command)
|
|
638
|
+
stdout,stderr = process.stdout, process.stderr
|
|
639
|
+
if process.returncode != 0:
|
|
640
|
+
(logger or get_logger()).error(f"Error listing contents of backup: '{backup_name}'")
|
|
641
|
+
raise RuntimeError(str(process))
|
|
642
|
+
for line in stdout.splitlines():
|
|
643
|
+
if "[--- REMOVED ENTRY ----]" in line or "[Saved]" in line:
|
|
644
|
+
print(line)
|
|
645
|
+
else:
|
|
646
|
+
stderr_lines: List[str] = []
|
|
647
|
+
stderr_bytes = 0
|
|
648
|
+
cap = None
|
|
649
|
+
if runner is not None:
|
|
650
|
+
cap = runner.default_capture_limit_bytes
|
|
651
|
+
if not isinstance(cap, int):
|
|
652
|
+
cap = None
|
|
653
|
+
log_path = None
|
|
654
|
+
log_file = None
|
|
655
|
+
log_lock = threading.Lock()
|
|
656
|
+
command_logger = get_logger(command_output_logger=True)
|
|
657
|
+
for handler in getattr(command_logger, "handlers", []):
|
|
658
|
+
if hasattr(handler, "baseFilename"):
|
|
659
|
+
log_path = handler.baseFilename
|
|
660
|
+
break
|
|
661
|
+
if log_path:
|
|
662
|
+
log_file = open(log_path, "ab")
|
|
663
|
+
header = (
|
|
664
|
+
f"{datetime.now().strftime('%Y-%m-%d %H:%M:%S')} - COMMAND: "
|
|
665
|
+
f"{' '.join(map(shlex.quote, command))}\n"
|
|
666
|
+
).encode("utf-8", errors="replace")
|
|
667
|
+
log_file.write(header)
|
|
668
|
+
log_file.flush()
|
|
669
|
+
|
|
670
|
+
process = subprocess.Popen(
|
|
671
|
+
command,
|
|
672
|
+
stdout=subprocess.PIPE,
|
|
673
|
+
stderr=subprocess.PIPE,
|
|
674
|
+
stdin=subprocess.DEVNULL,
|
|
675
|
+
text=False,
|
|
676
|
+
bufsize=0
|
|
677
|
+
)
|
|
678
|
+
|
|
679
|
+
def read_stderr():
|
|
680
|
+
nonlocal stderr_bytes
|
|
681
|
+
if process.stderr is None:
|
|
682
|
+
return
|
|
683
|
+
while True:
|
|
684
|
+
chunk = process.stderr.read(1024)
|
|
685
|
+
if not chunk:
|
|
686
|
+
break
|
|
687
|
+
if log_file:
|
|
688
|
+
with log_lock:
|
|
689
|
+
log_file.write(chunk)
|
|
690
|
+
log_file.flush()
|
|
691
|
+
if cap is None:
|
|
692
|
+
stderr_lines.append(chunk)
|
|
693
|
+
elif cap > 0 and stderr_bytes < cap:
|
|
694
|
+
remaining = cap - stderr_bytes
|
|
695
|
+
if len(chunk) <= remaining:
|
|
696
|
+
stderr_lines.append(chunk)
|
|
697
|
+
stderr_bytes += len(chunk)
|
|
698
|
+
else:
|
|
699
|
+
stderr_lines.append(chunk[:remaining])
|
|
700
|
+
stderr_bytes = cap
|
|
701
|
+
|
|
702
|
+
stderr_thread = threading.Thread(target=read_stderr)
|
|
703
|
+
stderr_thread.start()
|
|
704
|
+
|
|
705
|
+
if process.stdout is not None:
|
|
706
|
+
buffer = b""
|
|
707
|
+
while True:
|
|
708
|
+
chunk = process.stdout.read(1024)
|
|
709
|
+
if not chunk:
|
|
710
|
+
break
|
|
711
|
+
if log_file:
|
|
712
|
+
with log_lock:
|
|
713
|
+
log_file.write(chunk)
|
|
714
|
+
buffer += chunk
|
|
715
|
+
while b"\n" in buffer:
|
|
716
|
+
line, buffer = buffer.split(b"\n", 1)
|
|
717
|
+
if b"[--- REMOVED ENTRY ----]" in line or b"[Saved]" in line:
|
|
718
|
+
print(line.decode("utf-8", errors="replace"))
|
|
719
|
+
process.stdout.close()
|
|
720
|
+
|
|
721
|
+
process.wait()
|
|
722
|
+
stderr_thread.join()
|
|
723
|
+
if log_file:
|
|
724
|
+
log_file.close()
|
|
725
|
+
|
|
726
|
+
if process.returncode != 0:
|
|
727
|
+
(logger or get_logger()).error(f"Error listing contents of backup: '{backup_name}'")
|
|
728
|
+
stderr_text = "".join(stderr_lines)
|
|
729
|
+
raise RuntimeError(
|
|
730
|
+
f"Error listing contents of backup: '{backup_name}'"
|
|
731
|
+
f"\nStderr: {stderr_text}"
|
|
732
|
+
)
|
|
439
733
|
except subprocess.CalledProcessError as e:
|
|
440
|
-
logger.error(f"Error listing contents of backup: '{backup_name}'")
|
|
734
|
+
(logger or get_logger()).error(f"Error listing contents of backup: '{backup_name}'")
|
|
441
735
|
raise BackupError(f"Error listing contents of backup: '{backup_name}'") from e
|
|
442
736
|
except Exception as e:
|
|
443
737
|
raise RuntimeError(f"Unexpected error listing contents of backup: '{backup_name}'") from e
|
|
@@ -564,7 +858,7 @@ def preflight_check(args: argparse.Namespace, config_settings: ConfigSettings) -
|
|
|
564
858
|
return True
|
|
565
859
|
|
|
566
860
|
|
|
567
|
-
def perform_backup(args: argparse.Namespace, config_settings: ConfigSettings, backup_type: str) -> List[str]:
|
|
861
|
+
def perform_backup(args: argparse.Namespace, config_settings: ConfigSettings, backup_type: str, stats_accumulator: list) -> List[str]:
|
|
568
862
|
"""
|
|
569
863
|
Perform backup operation.
|
|
570
864
|
|
|
@@ -572,6 +866,7 @@ def perform_backup(args: argparse.Namespace, config_settings: ConfigSettings, ba
|
|
|
572
866
|
args: Command-line arguments.
|
|
573
867
|
config_settings: An instance of the ConfigSettings class.
|
|
574
868
|
backup_type: Type of backup (FULL, DIFF, INCR).
|
|
869
|
+
stats_accumulator: List to collect backup statuses.
|
|
575
870
|
|
|
576
871
|
Returns:
|
|
577
872
|
List[tuples] - each tuple consists of (<str message>, <exit code>)
|
|
@@ -618,10 +913,10 @@ def perform_backup(args: argparse.Namespace, config_settings: ConfigSettings, ba
|
|
|
618
913
|
latest_base_backup = os.path.join(config_settings.backup_dir, args.alternate_reference_archive)
|
|
619
914
|
logger.info(f"Using alternate reference archive: {latest_base_backup}")
|
|
620
915
|
if not os.path.exists(latest_base_backup + '.1.dar'):
|
|
621
|
-
msg = f"Alternate reference archive: \"{latest_base_backup}.1.dar\" does not exist,
|
|
916
|
+
msg = f"Alternate reference archive: \"{latest_base_backup}.1.dar\" does not exist, skipping..."
|
|
622
917
|
logger.error(msg)
|
|
623
918
|
results.append((msg, 1))
|
|
624
|
-
|
|
919
|
+
continue
|
|
625
920
|
else:
|
|
626
921
|
base_backups = sorted(
|
|
627
922
|
[f for f in os.listdir(config_settings.backup_dir) if f.startswith(f"{backup_definition}_{base_backup_type}_") and f.endswith('.1.dar')],
|
|
@@ -657,8 +952,8 @@ def perform_backup(args: argparse.Namespace, config_settings: ConfigSettings, ba
|
|
|
657
952
|
logger.info("par2 files completed successfully.")
|
|
658
953
|
|
|
659
954
|
except Exception as e:
|
|
660
|
-
results.append((
|
|
661
|
-
logger.
|
|
955
|
+
results.append((f"Exception: {e}", 1))
|
|
956
|
+
logger.error(f"Error during {backup_type} backup process for {backup_definition}: {e}", exc_info=True)
|
|
662
957
|
success = False
|
|
663
958
|
finally:
|
|
664
959
|
# Determine status based on new results for this backup definition
|
|
@@ -670,7 +965,7 @@ def perform_backup(args: argparse.Namespace, config_settings: ConfigSettings, ba
|
|
|
670
965
|
|
|
671
966
|
# Avoid spamming from example/demo backup definitions
|
|
672
967
|
if backup_definition.lower() == "example":
|
|
673
|
-
logger.debug("Skipping
|
|
968
|
+
logger.debug("Skipping stats collection for example backup definition.")
|
|
674
969
|
continue
|
|
675
970
|
|
|
676
971
|
if has_error:
|
|
@@ -679,10 +974,14 @@ def perform_backup(args: argparse.Namespace, config_settings: ConfigSettings, ba
|
|
|
679
974
|
status = "WARNING"
|
|
680
975
|
else:
|
|
681
976
|
status = "SUCCESS"
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
977
|
+
|
|
978
|
+
# Aggregate stats instead of sending immediately
|
|
979
|
+
stats_accumulator.append({
|
|
980
|
+
"definition": backup_definition,
|
|
981
|
+
"status": status,
|
|
982
|
+
"type": backup_type,
|
|
983
|
+
"timestamp": datetime.now().strftime("%Y-%m-%d_%H:%M")
|
|
984
|
+
})
|
|
686
985
|
|
|
687
986
|
logger.trace(f"perform_backup() results[]: {results}")
|
|
688
987
|
return results
|
|
@@ -759,8 +1058,6 @@ def _write_par2_manifest(
|
|
|
759
1058
|
def _default_par2_config(config_settings: ConfigSettings) -> dict:
|
|
760
1059
|
return {
|
|
761
1060
|
"par2_dir": getattr(config_settings, "par2_dir", None),
|
|
762
|
-
"par2_layout": getattr(config_settings, "par2_layout", "by-backup"),
|
|
763
|
-
"par2_mode": getattr(config_settings, "par2_mode", None),
|
|
764
1061
|
"par2_ratio_full": getattr(config_settings, "par2_ratio_full", None),
|
|
765
1062
|
"par2_ratio_diff": getattr(config_settings, "par2_ratio_diff", None),
|
|
766
1063
|
"par2_ratio_incr": getattr(config_settings, "par2_ratio_incr", None),
|
|
@@ -842,7 +1139,8 @@ def generate_par2_files(backup_file: str, config_settings: ConfigSettings, args,
|
|
|
842
1139
|
def filter_darrc_file(darrc_path):
|
|
843
1140
|
"""
|
|
844
1141
|
Filters the .darrc file to remove lines containing the options: -vt, -vs, -vd, -vf, and -va.
|
|
845
|
-
The filtered version is stored in a uniquely named file
|
|
1142
|
+
The filtered version is stored in a uniquely named file alongside the source .darrc
|
|
1143
|
+
(or a writable temp directory if needed).
|
|
846
1144
|
The file permissions are set to 440.
|
|
847
1145
|
|
|
848
1146
|
Params:
|
|
@@ -857,29 +1155,36 @@ def filter_darrc_file(darrc_path):
|
|
|
857
1155
|
# Define options to filter out
|
|
858
1156
|
options_to_remove = {"-vt", "-vs", "-vd", "-vf", "-va"}
|
|
859
1157
|
|
|
860
|
-
|
|
861
|
-
|
|
1158
|
+
candidate_dirs = [
|
|
1159
|
+
os.path.dirname(os.path.abspath(darrc_path)),
|
|
1160
|
+
os.path.expanduser("~"),
|
|
1161
|
+
tempfile.gettempdir(),
|
|
1162
|
+
]
|
|
1163
|
+
last_error = None
|
|
1164
|
+
|
|
1165
|
+
for candidate_dir in candidate_dirs:
|
|
1166
|
+
filtered_darrc_path = os.path.join(
|
|
1167
|
+
candidate_dir,
|
|
1168
|
+
f"filtered_darrc_{next(tempfile._get_candidate_names())}.darrc",
|
|
1169
|
+
)
|
|
1170
|
+
try:
|
|
1171
|
+
with open(darrc_path, "r") as infile, open(filtered_darrc_path, "w") as outfile:
|
|
1172
|
+
for line in infile:
|
|
1173
|
+
# Check if any unwanted option is in the line
|
|
1174
|
+
if not any(option in line for option in options_to_remove):
|
|
1175
|
+
outfile.write(line)
|
|
862
1176
|
|
|
863
|
-
|
|
864
|
-
|
|
1177
|
+
# Set file permissions to 440 (read-only for owner and group, no permissions for others)
|
|
1178
|
+
os.chmod(filtered_darrc_path, 0o440)
|
|
865
1179
|
|
|
866
|
-
|
|
867
|
-
with open(darrc_path, "r") as infile, open(filtered_darrc_path, "w") as outfile:
|
|
868
|
-
for line in infile:
|
|
869
|
-
# Check if any unwanted option is in the line
|
|
870
|
-
if not any(option in line for option in options_to_remove):
|
|
871
|
-
outfile.write(line)
|
|
872
|
-
|
|
873
|
-
# Set file permissions to 440 (read-only for owner and group, no permissions for others)
|
|
874
|
-
os.chmod(filtered_darrc_path, 0o440)
|
|
1180
|
+
return filtered_darrc_path
|
|
875
1181
|
|
|
876
|
-
|
|
1182
|
+
except Exception as e:
|
|
1183
|
+
last_error = e
|
|
1184
|
+
if os.path.exists(filtered_darrc_path):
|
|
1185
|
+
os.remove(filtered_darrc_path)
|
|
877
1186
|
|
|
878
|
-
|
|
879
|
-
# If anything goes wrong, clean up the temp file if it was created
|
|
880
|
-
if os.path.exists(filtered_darrc_path):
|
|
881
|
-
os.remove(filtered_darrc_path)
|
|
882
|
-
raise RuntimeError(f"Error filtering .darrc file: {e}")
|
|
1187
|
+
raise RuntimeError(f"Error filtering .darrc file: {last_error}")
|
|
883
1188
|
|
|
884
1189
|
|
|
885
1190
|
|
|
@@ -981,16 +1286,35 @@ def print_markdown(source: str, from_string: bool = False, pretty: bool = True):
|
|
|
981
1286
|
|
|
982
1287
|
|
|
983
1288
|
|
|
1289
|
+
def _resolve_doc_path(path: Optional[str], filename: str) -> Path:
|
|
1290
|
+
if path:
|
|
1291
|
+
return Path(path)
|
|
1292
|
+
|
|
1293
|
+
candidates = [
|
|
1294
|
+
Path.cwd() / "src" / "dar_backup" / filename,
|
|
1295
|
+
Path(__file__).parent / filename,
|
|
1296
|
+
]
|
|
1297
|
+
|
|
1298
|
+
try:
|
|
1299
|
+
candidates.append(Path(__file__).resolve().parents[2] / filename)
|
|
1300
|
+
except IndexError:
|
|
1301
|
+
pass
|
|
1302
|
+
|
|
1303
|
+
for candidate in candidates:
|
|
1304
|
+
if candidate.exists():
|
|
1305
|
+
return candidate
|
|
1306
|
+
|
|
1307
|
+
return candidates[0]
|
|
1308
|
+
|
|
1309
|
+
|
|
984
1310
|
def print_changelog(path: str = None, pretty: bool = True):
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
print_markdown(str(path), pretty=pretty)
|
|
1311
|
+
resolved_path = _resolve_doc_path(path, "Changelog.md")
|
|
1312
|
+
print_markdown(str(resolved_path), pretty=pretty)
|
|
988
1313
|
|
|
989
1314
|
|
|
990
1315
|
def print_readme(path: str = None, pretty: bool = True):
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
print_markdown(str(path), pretty=pretty)
|
|
1316
|
+
resolved_path = _resolve_doc_path(path, "README.md")
|
|
1317
|
+
print_markdown(str(resolved_path), pretty=pretty)
|
|
994
1318
|
|
|
995
1319
|
def list_definitions(backup_d_dir: str) -> List[str]:
|
|
996
1320
|
"""
|
|
@@ -1002,6 +1326,49 @@ def list_definitions(backup_d_dir: str) -> List[str]:
|
|
|
1002
1326
|
return sorted([entry.name for entry in dir_path.iterdir() if entry.is_file()])
|
|
1003
1327
|
|
|
1004
1328
|
|
|
1329
|
+
def clean_restore_test_directory(config_settings: ConfigSettings):
|
|
1330
|
+
"""
|
|
1331
|
+
Cleans up the restore test directory to ensure a clean slate.
|
|
1332
|
+
"""
|
|
1333
|
+
restore_dir = getattr(config_settings, "test_restore_dir", None)
|
|
1334
|
+
if not restore_dir:
|
|
1335
|
+
return
|
|
1336
|
+
|
|
1337
|
+
restore_dir = os.path.expanduser(os.path.expandvars(restore_dir))
|
|
1338
|
+
|
|
1339
|
+
if not os.path.exists(restore_dir):
|
|
1340
|
+
return
|
|
1341
|
+
|
|
1342
|
+
# Safety: Do not delete if it resolves to a critical path
|
|
1343
|
+
critical_paths = ["/", "/home", "/root", "/usr", "/var", "/etc", "/tmp", "/opt", "/bin", "/sbin", "/boot", "/dev", "/proc", "/sys", "/run"]
|
|
1344
|
+
normalized = os.path.realpath(restore_dir)
|
|
1345
|
+
|
|
1346
|
+
# Check exact matches
|
|
1347
|
+
if normalized in critical_paths:
|
|
1348
|
+
logger.warning(f"Refusing to clean critical directory: {normalized}")
|
|
1349
|
+
return
|
|
1350
|
+
|
|
1351
|
+
# Check if it's the user's home directory
|
|
1352
|
+
home = os.path.expanduser("~")
|
|
1353
|
+
if normalized == home:
|
|
1354
|
+
logger.warning(f"Refusing to clean user home directory: {normalized}")
|
|
1355
|
+
return
|
|
1356
|
+
|
|
1357
|
+
logger.debug(f"Cleaning restore test directory: {restore_dir}")
|
|
1358
|
+
try:
|
|
1359
|
+
for item in os.listdir(restore_dir):
|
|
1360
|
+
item_path = os.path.join(restore_dir, item)
|
|
1361
|
+
try:
|
|
1362
|
+
if os.path.isfile(item_path) or os.path.islink(item_path):
|
|
1363
|
+
os.unlink(item_path)
|
|
1364
|
+
elif os.path.isdir(item_path):
|
|
1365
|
+
shutil.rmtree(item_path)
|
|
1366
|
+
except Exception as e:
|
|
1367
|
+
logger.warning(f"Failed to remove {item_path}: {e}")
|
|
1368
|
+
except Exception as e:
|
|
1369
|
+
logger.warning(f"Failed to clean restore directory {restore_dir}: {e}")
|
|
1370
|
+
|
|
1371
|
+
|
|
1005
1372
|
def main():
|
|
1006
1373
|
global logger, runner
|
|
1007
1374
|
results: List[(str,int)] = [] # a list op tuples (<msg>, <exit code>)
|
|
@@ -1095,7 +1462,14 @@ def main():
|
|
|
1095
1462
|
raise SystemExit(127)
|
|
1096
1463
|
|
|
1097
1464
|
args.config_file = config_settings_path
|
|
1098
|
-
|
|
1465
|
+
try:
|
|
1466
|
+
config_settings = ConfigSettings(args.config_file)
|
|
1467
|
+
except Exception as exc:
|
|
1468
|
+
msg = f"Config error: {exc}"
|
|
1469
|
+
print(msg, file=stderr)
|
|
1470
|
+
ts = datetime.now().strftime("%Y-%m-%d_%H:%M")
|
|
1471
|
+
send_discord_message(f"{ts} - dar-backup: FAILURE - {msg}")
|
|
1472
|
+
exit(127)
|
|
1099
1473
|
|
|
1100
1474
|
if args.list_definitions:
|
|
1101
1475
|
try:
|
|
@@ -1128,10 +1502,29 @@ def main():
|
|
|
1128
1502
|
if command_output_log == config_settings.logfile_location:
|
|
1129
1503
|
print(f"Error: logfile_location in {args.config_file} does not end at 'dar-backup.log', exiting", file=stderr)
|
|
1130
1504
|
|
|
1131
|
-
|
|
1505
|
+
trace_log_file = derive_trace_log_path(config_settings.logfile_location)
|
|
1506
|
+
logger = setup_logging(
|
|
1507
|
+
config_settings.logfile_location,
|
|
1508
|
+
command_output_log,
|
|
1509
|
+
args.log_level,
|
|
1510
|
+
args.log_stdout,
|
|
1511
|
+
logfile_max_bytes=config_settings.logfile_max_bytes,
|
|
1512
|
+
logfile_backup_count=config_settings.logfile_backup_count,
|
|
1513
|
+
trace_log_file=trace_log_file,
|
|
1514
|
+
trace_log_max_bytes=getattr(config_settings, "trace_log_max_bytes", 10485760),
|
|
1515
|
+
trace_log_backup_count=getattr(config_settings, "trace_log_backup_count", 1)
|
|
1516
|
+
)
|
|
1132
1517
|
command_logger = get_logger(command_output_logger = True)
|
|
1133
|
-
runner = CommandRunner(
|
|
1518
|
+
runner = CommandRunner(
|
|
1519
|
+
logger=logger,
|
|
1520
|
+
command_logger=command_logger,
|
|
1521
|
+
default_capture_limit_bytes=getattr(config_settings, "command_capture_max_bytes", None)
|
|
1522
|
+
)
|
|
1134
1523
|
|
|
1524
|
+
clean_restore_test_directory(config_settings)
|
|
1525
|
+
|
|
1526
|
+
|
|
1527
|
+
filtered_darrc_path = None
|
|
1135
1528
|
|
|
1136
1529
|
try:
|
|
1137
1530
|
if not args.darrc:
|
|
@@ -1147,7 +1540,8 @@ def main():
|
|
|
1147
1540
|
|
|
1148
1541
|
if args.suppress_dar_msg:
|
|
1149
1542
|
logger.info("Suppressing dar messages, do not use options: -vt, -vs, -vd, -vf, -va")
|
|
1150
|
-
|
|
1543
|
+
filtered_darrc_path = filter_darrc_file(args.darrc)
|
|
1544
|
+
args.darrc = filtered_darrc_path
|
|
1151
1545
|
logger.debug(f"Filtered .darrc file: {args.darrc}")
|
|
1152
1546
|
|
|
1153
1547
|
start_msgs: List[Tuple[str, str]] = []
|
|
@@ -1181,6 +1575,7 @@ def main():
|
|
|
1181
1575
|
args.verbose and start_msgs.append(("Restore dir:", restore_dir))
|
|
1182
1576
|
|
|
1183
1577
|
args.verbose and start_msgs.append(("Logfile location:", config_settings.logfile_location))
|
|
1578
|
+
args.verbose and start_msgs.append(("Trace log:", trace_log_file))
|
|
1184
1579
|
args.verbose and start_msgs.append(("Logfile max size (bytes):", config_settings.logfile_max_bytes))
|
|
1185
1580
|
args.verbose and start_msgs.append(("Logfile backup count:", config_settings.logfile_backup_count))
|
|
1186
1581
|
|
|
@@ -1201,6 +1596,8 @@ def main():
|
|
|
1201
1596
|
|
|
1202
1597
|
requirements('PREREQ', config_settings)
|
|
1203
1598
|
|
|
1599
|
+
stats: List[dict] = []
|
|
1600
|
+
|
|
1204
1601
|
if args.list:
|
|
1205
1602
|
list_filter = args.backup_definition
|
|
1206
1603
|
if isinstance(args.list, str):
|
|
@@ -1211,11 +1608,11 @@ def main():
|
|
|
1211
1608
|
list_filter = args.list
|
|
1212
1609
|
list_backups(config_settings.backup_dir, list_filter)
|
|
1213
1610
|
elif args.full_backup and not args.differential_backup and not args.incremental_backup:
|
|
1214
|
-
results.extend(perform_backup(args, config_settings, "FULL"))
|
|
1611
|
+
results.extend(perform_backup(args, config_settings, "FULL", stats))
|
|
1215
1612
|
elif args.differential_backup and not args.full_backup and not args.incremental_backup:
|
|
1216
|
-
results.extend(perform_backup(args, config_settings, "DIFF"))
|
|
1613
|
+
results.extend(perform_backup(args, config_settings, "DIFF", stats))
|
|
1217
1614
|
elif args.incremental_backup and not args.full_backup and not args.differential_backup:
|
|
1218
|
-
results.extend(perform_backup(args, config_settings, "INCR"))
|
|
1615
|
+
results.extend(perform_backup(args, config_settings, "INCR", stats))
|
|
1219
1616
|
logger.debug(f"results from perform_backup(): {results}")
|
|
1220
1617
|
elif args.list_contents:
|
|
1221
1618
|
list_contents(args.list_contents, config_settings.backup_dir, args.selection)
|
|
@@ -1227,20 +1624,50 @@ def main():
|
|
|
1227
1624
|
|
|
1228
1625
|
logger.debug(f"results[]: {results}")
|
|
1229
1626
|
|
|
1627
|
+
# Send aggregated Discord notification if stats were collected
|
|
1628
|
+
if stats:
|
|
1629
|
+
total = len(stats)
|
|
1630
|
+
failures = [s for s in stats if s['status'] == 'FAILURE']
|
|
1631
|
+
warnings = [s for s in stats if s['status'] == 'WARNING']
|
|
1632
|
+
successes = [s for s in stats if s['status'] == 'SUCCESS']
|
|
1633
|
+
|
|
1634
|
+
ts = datetime.now().strftime("%Y-%m-%d_%H:%M")
|
|
1635
|
+
|
|
1636
|
+
if failures or warnings:
|
|
1637
|
+
msg_lines = [f"{ts} - dar-backup Run Completed"]
|
|
1638
|
+
msg_lines.append(f"Total: {total}, Success: {len(successes)}, Warning: {len(warnings)}, Failure: {len(failures)}")
|
|
1639
|
+
|
|
1640
|
+
if failures:
|
|
1641
|
+
msg_lines.append("\nFailures:")
|
|
1642
|
+
for f in failures:
|
|
1643
|
+
msg_lines.append(f"- {f['definition']} ({f['type']})")
|
|
1644
|
+
|
|
1645
|
+
if warnings:
|
|
1646
|
+
msg_lines.append("\nWarnings:")
|
|
1647
|
+
for w in warnings:
|
|
1648
|
+
msg_lines.append(f"- {w['definition']} ({w['type']})")
|
|
1649
|
+
|
|
1650
|
+
send_discord_message("\n".join(msg_lines), config_settings=config_settings)
|
|
1651
|
+
else:
|
|
1652
|
+
# All successful
|
|
1653
|
+
send_discord_message(f"{ts} - dar-backup: SUCCESS - All {total} backups completed successfully.", config_settings=config_settings)
|
|
1654
|
+
|
|
1230
1655
|
requirements('POSTREQ', config_settings)
|
|
1231
1656
|
|
|
1232
1657
|
|
|
1233
1658
|
except Exception as e:
|
|
1234
|
-
|
|
1659
|
+
msg = f"Unexpected error: {e}"
|
|
1660
|
+
logger.error(msg, exc_info=True)
|
|
1661
|
+
ts = datetime.now().strftime("%Y-%m-%d_%H:%M")
|
|
1662
|
+
send_discord_message(f"{ts} - dar-backup: FAILURE - {msg}", config_settings=config_settings)
|
|
1235
1663
|
results.append((repr(e), 1))
|
|
1236
1664
|
finally:
|
|
1237
1665
|
end_time=int(time())
|
|
1238
1666
|
logger.info(f"END TIME: {end_time}")
|
|
1239
1667
|
# Clean up
|
|
1240
|
-
if
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
logger.debug(f"Removed filtered .darrc: {args.darrc}")
|
|
1668
|
+
if filtered_darrc_path and os.path.exists(filtered_darrc_path):
|
|
1669
|
+
os.remove(filtered_darrc_path)
|
|
1670
|
+
logger.debug(f"Removed filtered .darrc: {filtered_darrc_path}")
|
|
1244
1671
|
|
|
1245
1672
|
|
|
1246
1673
|
# Determine exit code
|