dar-backup 1.0.1__py3-none-any.whl → 1.0.2__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/dar_backup.py CHANGED
@@ -25,6 +25,7 @@ import subprocess
25
25
  import configparser
26
26
  import xml.etree.ElementTree as ET
27
27
  import tempfile
28
+ import threading
28
29
 
29
30
  from argparse import ArgumentParser
30
31
  from datetime import datetime
@@ -36,7 +37,7 @@ from sys import version_info
36
37
  from time import time
37
38
  from rich.console import Console
38
39
  from rich.text import Text
39
- from typing import List, Tuple
40
+ from typing import Iterable, Iterator, List, Optional, Tuple
40
41
 
41
42
  from . import __about__ as about
42
43
  from dar_backup.config_settings import ConfigSettings
@@ -163,7 +164,34 @@ def find_files_with_paths(xml_doc: str):
163
164
  return files_list
164
165
 
165
166
 
166
- def find_files_between_min_and_max_size(backed_up_files: list[(str, str)], config_settings: ConfigSettings):
167
+ def iter_files_with_paths_from_xml(xml_path: str) -> Iterator[Tuple[str, str]]:
168
+ """
169
+ Stream file paths and sizes from a DAR XML listing to keep memory usage low.
170
+ """
171
+ path_stack: List[str] = []
172
+ context = ET.iterparse(xml_path, events=("start", "end"))
173
+ for event, elem in context:
174
+ if event == "start" and elem.tag == "Directory":
175
+ dir_name = elem.get("name")
176
+ if dir_name:
177
+ path_stack.append(dir_name)
178
+ elif event == "end" and elem.tag == "File":
179
+ file_name = elem.get("name")
180
+ file_size = elem.get("size")
181
+ if file_name:
182
+ if path_stack:
183
+ file_path = "/".join(path_stack + [file_name])
184
+ else:
185
+ file_path = file_name
186
+ yield (file_path, file_size)
187
+ elem.clear()
188
+ elif event == "end" and elem.tag == "Directory":
189
+ if path_stack:
190
+ path_stack.pop()
191
+ elem.clear()
192
+
193
+
194
+ def find_files_between_min_and_max_size(backed_up_files: Iterable[Tuple[str, str]], config_settings: ConfigSettings):
167
195
  """Find files within a specified size range.
168
196
 
169
197
  This function takes a list of backed up files, a minimum size in megabytes, and a maximum size in megabytes.
@@ -192,21 +220,21 @@ def find_files_between_min_and_max_size(backed_up_files: list[(str, str)], confi
192
220
  "Tio" : 1024 * 1024 * 1024 * 1024
193
221
  }
194
222
  pattern = r'(\d+)\s*(\w+)'
195
- for tuple in backed_up_files:
196
- if tuple is not None and len(tuple) >= 2 and tuple[0] is not None and tuple[1] is not None:
197
- logger.trace("tuple from dar xml list: {tuple}")
198
- match = re.match(pattern, tuple[1])
223
+ for item in backed_up_files:
224
+ if item is not None and len(item) >= 2 and item[0] is not None and item[1] is not None:
225
+ logger.trace(f"tuple from dar xml list: {item}")
226
+ match = re.match(pattern, item[1])
199
227
  if match:
200
228
  number = int(match.group(1))
201
229
  unit = match.group(2).strip()
202
230
  file_size = dar_sizes[unit] * number
203
231
  if (min_size * 1024 * 1024) <= file_size <= (max_size * 1024 * 1024):
204
- logger.trace(f"File found between min and max sizes: {tuple}")
205
- files.append(tuple[0])
232
+ logger.trace(f"File found between min and max sizes: {item}")
233
+ files.append(item[0])
206
234
  return files
207
235
 
208
236
 
209
- def filter_restoretest_candidates(files: List[str], config_settings: ConfigSettings) -> List[str]:
237
+ def _is_restoretest_candidate(path: str, config_settings: ConfigSettings) -> bool:
210
238
  prefixes = [
211
239
  prefix.lstrip("/").lower()
212
240
  for prefix in getattr(config_settings, "restoretest_exclude_prefixes", [])
@@ -217,21 +245,19 @@ def filter_restoretest_candidates(files: List[str], config_settings: ConfigSetti
217
245
  ]
218
246
  regex = getattr(config_settings, "restoretest_exclude_regex", None)
219
247
 
220
- if not prefixes and not suffixes and not regex:
221
- return files
248
+ normalized = path.lstrip("/")
249
+ lowered = normalized.lower()
250
+ if prefixes and any(lowered.startswith(prefix) for prefix in prefixes):
251
+ return False
252
+ if suffixes and any(lowered.endswith(suffix) for suffix in suffixes):
253
+ return False
254
+ if regex and regex.search(normalized):
255
+ return False
256
+ return True
222
257
 
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
258
 
259
+ def filter_restoretest_candidates(files: List[str], config_settings: ConfigSettings) -> List[str]:
260
+ filtered = [path for path in files if _is_restoretest_candidate(path, config_settings)]
235
261
  if logger:
236
262
  excluded = len(files) - len(filtered)
237
263
  if excluded:
@@ -239,6 +265,70 @@ def filter_restoretest_candidates(files: List[str], config_settings: ConfigSetti
239
265
  return filtered
240
266
 
241
267
 
268
+ def _size_in_verification_range(size_text: str, config_settings: ConfigSettings) -> bool:
269
+ dar_sizes = {
270
+ "o" : 1,
271
+ "kio" : 1024,
272
+ "Mio" : 1024 * 1024,
273
+ "Gio" : 1024 * 1024 * 1024,
274
+ "Tio" : 1024 * 1024 * 1024 * 1024
275
+ }
276
+ pattern = r'(\d+)\s*(\w+)'
277
+ match = re.match(pattern, size_text or "")
278
+ if not match:
279
+ return False
280
+ unit = match.group(2).strip()
281
+ if unit not in dar_sizes:
282
+ return False
283
+ number = int(match.group(1))
284
+ file_size = dar_sizes[unit] * number
285
+ min_size = config_settings.min_size_verification_mb * 1024 * 1024
286
+ max_size = config_settings.max_size_verification_mb * 1024 * 1024
287
+ return min_size <= file_size <= max_size
288
+
289
+
290
+ def select_restoretest_samples(
291
+ backed_up_files: Iterable[Tuple[str, str]],
292
+ config_settings: ConfigSettings,
293
+ sample_size: int
294
+ ) -> List[str]:
295
+ if sample_size <= 0:
296
+ return []
297
+ reservoir: List[str] = []
298
+ candidates_seen = 0
299
+ size_filtered_total = 0
300
+ excluded = 0
301
+ for item in backed_up_files:
302
+ if item is None or len(item) < 2:
303
+ continue
304
+ path, size_text = item[0], item[1]
305
+ if not path or not size_text:
306
+ continue
307
+ if not _size_in_verification_range(size_text, config_settings):
308
+ continue
309
+ size_filtered_total += 1
310
+ if not _is_restoretest_candidate(path, config_settings):
311
+ excluded += 1
312
+ continue
313
+ candidates_seen += 1
314
+ if candidates_seen <= sample_size:
315
+ reservoir.append(path)
316
+ else:
317
+ idx = random.randint(1, candidates_seen)
318
+ if idx <= sample_size:
319
+ reservoir[idx - 1] = path
320
+ if logger:
321
+ if size_filtered_total and excluded:
322
+ logger.debug(f"Restore test filter excluded {excluded} of {size_filtered_total} candidates")
323
+ if candidates_seen == 0:
324
+ logger.debug("No restore test candidates found after size/exclude filters")
325
+ elif candidates_seen <= sample_size:
326
+ logger.debug(f"Restore test candidates available: {candidates_seen}, selecting all")
327
+ else:
328
+ logger.debug(f"Restore test candidates available: {candidates_seen}, sampled: {sample_size}")
329
+ return reservoir
330
+
331
+
242
332
  def verify(args: argparse.Namespace, backup_file: str, backup_definition: str, config_settings: ConfigSettings):
243
333
  """
244
334
  Verify the integrity of a DAR backup by performing the following steps:
@@ -278,10 +368,17 @@ def verify(args: argparse.Namespace, backup_file: str, backup_definition: str, c
278
368
  if args.do_not_compare:
279
369
  return result
280
370
 
281
- backed_up_files = get_backed_up_files(backup_file, config_settings.backup_dir)
371
+ backed_up_files = get_backed_up_files(
372
+ backup_file,
373
+ config_settings.backup_dir,
374
+ timeout=config_settings.command_timeout_secs
375
+ )
282
376
 
283
- files = find_files_between_min_and_max_size(backed_up_files, config_settings)
284
- files = filter_restoretest_candidates(files, config_settings)
377
+ files = select_restoretest_samples(
378
+ backed_up_files,
379
+ config_settings,
380
+ config_settings.no_files_verification
381
+ )
285
382
  if len(files) == 0:
286
383
  logger.info(
287
384
  "No files eligible for verification after size and restore-test filters, skipping"
@@ -305,10 +402,7 @@ def verify(args: argparse.Namespace, backup_file: str, backup_definition: str, c
305
402
 
306
403
 
307
404
 
308
- no_files_verification = config_settings.no_files_verification
309
- if len(files) < config_settings.no_files_verification:
310
- no_files_verification = len(files)
311
- random_files = random.sample(files, no_files_verification)
405
+ random_files = files
312
406
 
313
407
  # Ensure restore directory exists for verification restores
314
408
  try:
@@ -317,7 +411,14 @@ def verify(args: argparse.Namespace, backup_file: str, backup_definition: str, c
317
411
  raise BackupError(f"Cannot create restore directory '{config_settings.test_restore_dir}': {exc}") from exc
318
412
 
319
413
  for restored_file_path in random_files:
414
+ restore_path = os.path.join(config_settings.test_restore_dir, restored_file_path.lstrip("/"))
415
+ source_path = os.path.join(root_path, restored_file_path.lstrip("/"))
320
416
  try:
417
+ if os.path.exists(restore_path):
418
+ try:
419
+ os.remove(restore_path)
420
+ except OSError:
421
+ pass
321
422
  args.verbose and logger.info(f"Restoring file: '{restored_file_path}' from backup to: '{config_settings.test_restore_dir}' for file comparing")
322
423
  command = ['dar', '-x', backup_file, '-g', restored_file_path.lstrip("/"), '-R', config_settings.test_restore_dir, '--noconf', '-Q', '-B', args.darrc, 'restore-options']
323
424
  args.verbose and logger.info(f"Running command: {' '.join(map(shlex.quote, command))}")
@@ -325,7 +426,7 @@ def verify(args: argparse.Namespace, backup_file: str, backup_definition: str, c
325
426
  if process.returncode != 0:
326
427
  raise Exception(str(process))
327
428
 
328
- if filecmp.cmp(os.path.join(config_settings.test_restore_dir, restored_file_path.lstrip("/")), os.path.join(root_path, restored_file_path.lstrip("/")), shallow=False):
429
+ if filecmp.cmp(restore_path, source_path, shallow=False):
329
430
  args.verbose and logger.info(f"Success: file '{restored_file_path}' matches the original")
330
431
  else:
331
432
  result = False
@@ -334,6 +435,21 @@ def verify(args: argparse.Namespace, backup_file: str, backup_definition: str, c
334
435
  result = False
335
436
  logger.exception(f"Permission error while comparing files, continuing....")
336
437
  logger.error("Exception details:", exc_info=True)
438
+ except FileNotFoundError as exc:
439
+ result = False
440
+ missing_path = exc.filename or "unknown path"
441
+ if missing_path == source_path:
442
+ logger.warning(
443
+ f"Restore verification skipped for '{restored_file_path}': source file missing: '{source_path}'"
444
+ )
445
+ elif missing_path == restore_path:
446
+ logger.warning(
447
+ f"Restore verification skipped for '{restored_file_path}': restored file missing: '{restore_path}'"
448
+ )
449
+ else:
450
+ logger.warning(
451
+ f"Restore verification skipped for '{restored_file_path}': file not found: '{missing_path}'"
452
+ )
337
453
  return result
338
454
 
339
455
 
@@ -382,7 +498,7 @@ def restore_backup(backup_name: str, config_settings: ConfigSettings, restore_di
382
498
  return results
383
499
 
384
500
 
385
- def get_backed_up_files(backup_name: str, backup_dir: str):
501
+ def get_backed_up_files(backup_name: str, backup_dir: str, timeout: Optional[int] = None) -> Iterable[Tuple[str, str]]:
386
502
  """
387
503
  Retrieves the list of backed up files from a DAR archive.
388
504
 
@@ -391,21 +507,89 @@ def get_backed_up_files(backup_name: str, backup_dir: str):
391
507
  backup_dir (str): The directory where the DAR archive is located.
392
508
 
393
509
  Returns:
394
- list: A list of file paths for all backed up files in the DAR archive.
510
+ Iterable[Tuple[str, str]]: Stream of (file path, size) tuples for all backed up files.
395
511
  """
396
512
  logger.debug(f"Getting backed up files in xml from DAR archive: '{backup_name}'")
397
513
  backup_path = os.path.join(backup_dir, backup_name)
514
+ temp_path = None
398
515
  try:
399
516
  command = ['dar', '-l', backup_path, '--noconf', '-am', '-as', "-Txml" , '-Q']
400
517
  logger.debug(f"Running command: {' '.join(map(shlex.quote, command))}")
401
- command_result = runner.run(command)
402
- # Parse the XML data
403
- file_paths = find_files_with_paths(command_result.stdout)
404
- return file_paths
518
+ if runner is not None and getattr(runner, "_is_mock_object", False):
519
+ command_result = runner.run(command)
520
+ file_paths = find_files_with_paths(command_result.stdout)
521
+ return file_paths
522
+ stderr_lines: List[str] = []
523
+ with tempfile.NamedTemporaryFile(mode="w+", encoding="utf-8", delete=False) as temp_file:
524
+ temp_path = temp_file.name
525
+ process = subprocess.Popen(
526
+ command,
527
+ stdout=subprocess.PIPE,
528
+ stderr=subprocess.PIPE,
529
+ text=True,
530
+ bufsize=1
531
+ )
532
+
533
+ def read_stderr():
534
+ if process.stderr is None:
535
+ return
536
+ for line in process.stderr:
537
+ stderr_lines.append(line)
538
+
539
+ stderr_thread = threading.Thread(target=read_stderr)
540
+ stderr_thread.start()
541
+
542
+ if process.stdout is not None:
543
+ for line in process.stdout:
544
+ if "<!DOCTYPE" in line:
545
+ continue
546
+ temp_file.write(line)
547
+ if process.stdout is not None:
548
+ process.stdout.close()
549
+
550
+ try:
551
+ process.wait(timeout=timeout)
552
+ except subprocess.TimeoutExpired:
553
+ process.kill()
554
+ stderr_thread.join()
555
+ raise
556
+ stderr_thread.join()
557
+
558
+ if process.returncode != 0:
559
+ stderr_text = "".join(stderr_lines)
560
+ logger.error(f"Error listing backed up files from DAR archive: '{backup_name}'")
561
+ try:
562
+ os.remove(temp_path)
563
+ except OSError:
564
+ logger.warning(f"Could not delete temporary file: {temp_path}")
565
+ raise BackupError(
566
+ f"Error listing backed up files from DAR archive: '{backup_name}'"
567
+ f"\nStderr: {stderr_text}"
568
+ )
569
+
570
+ def iter_files():
571
+ try:
572
+ for item in iter_files_with_paths_from_xml(temp_path):
573
+ yield item
574
+ finally:
575
+ try:
576
+ os.remove(temp_path)
577
+ except OSError:
578
+ logger.warning(f"Could not delete temporary file: {temp_path}")
579
+
580
+ return iter_files()
405
581
  except subprocess.CalledProcessError as e:
406
582
  logger.error(f"Error listing backed up files from DAR archive: '{backup_name}'")
407
583
  raise BackupError(f"Error listing backed up files from DAR archive: '{backup_name}'") from e
584
+ except subprocess.TimeoutExpired as e:
585
+ logger.error(f"Timeout listing backed up files from DAR archive: '{backup_name}'")
586
+ raise BackupError(f"Timeout listing backed up files from DAR archive: '{backup_name}'") from e
408
587
  except Exception as e:
588
+ if temp_path:
589
+ try:
590
+ os.remove(temp_path)
591
+ except OSError:
592
+ logger.warning(f"Could not delete temporary file: {temp_path}")
409
593
  raise RuntimeError(f"Unexpected error listing backed up files from DAR archive: '{backup_name}'") from e
410
594
 
411
595
 
@@ -428,16 +612,105 @@ def list_contents(backup_name, backup_dir, selection=None):
428
612
  if selection:
429
613
  selection_criteria = shlex.split(selection)
430
614
  command.extend(selection_criteria)
431
- process = runner.run(command)
432
- stdout,stderr = process.stdout, process.stderr
433
- if process.returncode != 0:
434
- logger.error(f"Error listing contents of backup: '{backup_name}'")
435
- raise RuntimeError(str(process))
436
- for line in stdout.splitlines():
437
- if "[--- REMOVED ENTRY ----]" in line or "[Saved]" in line:
438
- print(line)
615
+ if runner is not None and getattr(runner, "_is_mock_object", False):
616
+ process = runner.run(command)
617
+ stdout,stderr = process.stdout, process.stderr
618
+ if process.returncode != 0:
619
+ (logger or get_logger()).error(f"Error listing contents of backup: '{backup_name}'")
620
+ raise RuntimeError(str(process))
621
+ for line in stdout.splitlines():
622
+ if "[--- REMOVED ENTRY ----]" in line or "[Saved]" in line:
623
+ print(line)
624
+ else:
625
+ stderr_lines: List[str] = []
626
+ stderr_bytes = 0
627
+ cap = None
628
+ if runner is not None:
629
+ cap = runner.default_capture_limit_bytes
630
+ if not isinstance(cap, int):
631
+ cap = None
632
+ log_path = None
633
+ log_file = None
634
+ log_lock = threading.Lock()
635
+ command_logger = get_logger(command_output_logger=True)
636
+ for handler in getattr(command_logger, "handlers", []):
637
+ if hasattr(handler, "baseFilename"):
638
+ log_path = handler.baseFilename
639
+ break
640
+ if log_path:
641
+ log_file = open(log_path, "ab")
642
+ header = (
643
+ f"{datetime.now().strftime('%Y-%m-%d %H:%M:%S')} - COMMAND: "
644
+ f"{' '.join(map(shlex.quote, command))}\n"
645
+ ).encode("utf-8", errors="replace")
646
+ log_file.write(header)
647
+ log_file.flush()
648
+
649
+ process = subprocess.Popen(
650
+ command,
651
+ stdout=subprocess.PIPE,
652
+ stderr=subprocess.PIPE,
653
+ stdin=subprocess.DEVNULL,
654
+ text=False,
655
+ bufsize=0
656
+ )
657
+
658
+ def read_stderr():
659
+ nonlocal stderr_bytes
660
+ if process.stderr is None:
661
+ return
662
+ while True:
663
+ chunk = process.stderr.read(1024)
664
+ if not chunk:
665
+ break
666
+ if log_file:
667
+ with log_lock:
668
+ log_file.write(chunk)
669
+ log_file.flush()
670
+ if cap is None:
671
+ stderr_lines.append(chunk)
672
+ elif cap > 0 and stderr_bytes < cap:
673
+ remaining = cap - stderr_bytes
674
+ if len(chunk) <= remaining:
675
+ stderr_lines.append(chunk)
676
+ stderr_bytes += len(chunk)
677
+ else:
678
+ stderr_lines.append(chunk[:remaining])
679
+ stderr_bytes = cap
680
+
681
+ stderr_thread = threading.Thread(target=read_stderr)
682
+ stderr_thread.start()
683
+
684
+ if process.stdout is not None:
685
+ buffer = b""
686
+ while True:
687
+ chunk = process.stdout.read(1024)
688
+ if not chunk:
689
+ break
690
+ if log_file:
691
+ with log_lock:
692
+ log_file.write(chunk)
693
+ buffer += chunk
694
+ while b"\n" in buffer:
695
+ line, buffer = buffer.split(b"\n", 1)
696
+ if b"[--- REMOVED ENTRY ----]" in line or b"[Saved]" in line:
697
+ print(line.decode("utf-8", errors="replace"))
698
+ process.stdout.close()
699
+
700
+ process.wait()
701
+ stderr_thread.join()
702
+ if log_file:
703
+ log_file.close()
704
+
705
+ if process.returncode != 0:
706
+ (logger or get_logger()).error(f"Error listing contents of backup: '{backup_name}'")
707
+ stderr_text = "".join(stderr_lines)
708
+ raise RuntimeError(
709
+ f"Error listing contents of backup: '{backup_name}'"
710
+ f"\nStderr: {stderr_text}"
711
+ )
439
712
  except subprocess.CalledProcessError as e:
440
- logger.error(f"Error listing contents of backup: '{backup_name}'")
713
+ (logger or get_logger()).error(f"Error listing contents of backup: '{backup_name}'")
441
714
  raise BackupError(f"Error listing contents of backup: '{backup_name}'") from e
442
715
  except Exception as e:
443
716
  raise RuntimeError(f"Unexpected error listing contents of backup: '{backup_name}'") from e
@@ -564,7 +837,7 @@ def preflight_check(args: argparse.Namespace, config_settings: ConfigSettings) -
564
837
  return True
565
838
 
566
839
 
567
- def perform_backup(args: argparse.Namespace, config_settings: ConfigSettings, backup_type: str) -> List[str]:
840
+ def perform_backup(args: argparse.Namespace, config_settings: ConfigSettings, backup_type: str, stats_accumulator: list) -> List[str]:
568
841
  """
569
842
  Perform backup operation.
570
843
 
@@ -572,6 +845,7 @@ def perform_backup(args: argparse.Namespace, config_settings: ConfigSettings, ba
572
845
  args: Command-line arguments.
573
846
  config_settings: An instance of the ConfigSettings class.
574
847
  backup_type: Type of backup (FULL, DIFF, INCR).
848
+ stats_accumulator: List to collect backup statuses.
575
849
 
576
850
  Returns:
577
851
  List[tuples] - each tuple consists of (<str message>, <exit code>)
@@ -618,10 +892,10 @@ def perform_backup(args: argparse.Namespace, config_settings: ConfigSettings, ba
618
892
  latest_base_backup = os.path.join(config_settings.backup_dir, args.alternate_reference_archive)
619
893
  logger.info(f"Using alternate reference archive: {latest_base_backup}")
620
894
  if not os.path.exists(latest_base_backup + '.1.dar'):
621
- msg = f"Alternate reference archive: \"{latest_base_backup}.1.dar\" does not exist, exiting..."
895
+ msg = f"Alternate reference archive: \"{latest_base_backup}.1.dar\" does not exist, skipping..."
622
896
  logger.error(msg)
623
897
  results.append((msg, 1))
624
- return results
898
+ continue
625
899
  else:
626
900
  base_backups = sorted(
627
901
  [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 +931,8 @@ def perform_backup(args: argparse.Namespace, config_settings: ConfigSettings, ba
657
931
  logger.info("par2 files completed successfully.")
658
932
 
659
933
  except Exception as e:
660
- results.append((repr(e), 1))
661
- logger.exception(f"Error during {backup_type} backup process, continuing to next backup definition.")
934
+ results.append((f"Exception: {e}", 1))
935
+ logger.error(f"Error during {backup_type} backup process for {backup_definition}: {e}", exc_info=True)
662
936
  success = False
663
937
  finally:
664
938
  # Determine status based on new results for this backup definition
@@ -670,7 +944,7 @@ def perform_backup(args: argparse.Namespace, config_settings: ConfigSettings, ba
670
944
 
671
945
  # Avoid spamming from example/demo backup definitions
672
946
  if backup_definition.lower() == "example":
673
- logger.debug("Skipping Discord notification for example backup definition.")
947
+ logger.debug("Skipping stats collection for example backup definition.")
674
948
  continue
675
949
 
676
950
  if has_error:
@@ -679,10 +953,14 @@ def perform_backup(args: argparse.Namespace, config_settings: ConfigSettings, ba
679
953
  status = "WARNING"
680
954
  else:
681
955
  status = "SUCCESS"
682
- timestamp = datetime.now().strftime("%Y-%m-%d_%H:%M")
683
- message = f"{timestamp} - dar-backup, {backup_definition}: {status}"
684
- if not send_discord_message(message, config_settings=config_settings):
685
- logger.debug(f"Discord notification not sent for {backup_definition}: {status}")
956
+
957
+ # Aggregate stats instead of sending immediately
958
+ stats_accumulator.append({
959
+ "definition": backup_definition,
960
+ "status": status,
961
+ "type": backup_type,
962
+ "timestamp": datetime.now().strftime("%Y-%m-%d_%H:%M")
963
+ })
686
964
 
687
965
  logger.trace(f"perform_backup() results[]: {results}")
688
966
  return results
@@ -759,8 +1037,6 @@ def _write_par2_manifest(
759
1037
  def _default_par2_config(config_settings: ConfigSettings) -> dict:
760
1038
  return {
761
1039
  "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
1040
  "par2_ratio_full": getattr(config_settings, "par2_ratio_full", None),
765
1041
  "par2_ratio_diff": getattr(config_settings, "par2_ratio_diff", None),
766
1042
  "par2_ratio_incr": getattr(config_settings, "par2_ratio_incr", None),
@@ -1002,6 +1278,49 @@ def list_definitions(backup_d_dir: str) -> List[str]:
1002
1278
  return sorted([entry.name for entry in dir_path.iterdir() if entry.is_file()])
1003
1279
 
1004
1280
 
1281
+ def clean_restore_test_directory(config_settings: ConfigSettings):
1282
+ """
1283
+ Cleans up the restore test directory to ensure a clean slate.
1284
+ """
1285
+ restore_dir = getattr(config_settings, "test_restore_dir", None)
1286
+ if not restore_dir:
1287
+ return
1288
+
1289
+ restore_dir = os.path.expanduser(os.path.expandvars(restore_dir))
1290
+
1291
+ if not os.path.exists(restore_dir):
1292
+ return
1293
+
1294
+ # Safety: Do not delete if it resolves to a critical path
1295
+ critical_paths = ["/", "/home", "/root", "/usr", "/var", "/etc", "/tmp", "/opt", "/bin", "/sbin", "/boot", "/dev", "/proc", "/sys", "/run"]
1296
+ normalized = os.path.realpath(restore_dir)
1297
+
1298
+ # Check exact matches
1299
+ if normalized in critical_paths:
1300
+ logger.warning(f"Refusing to clean critical directory: {normalized}")
1301
+ return
1302
+
1303
+ # Check if it's the user's home directory
1304
+ home = os.path.expanduser("~")
1305
+ if normalized == home:
1306
+ logger.warning(f"Refusing to clean user home directory: {normalized}")
1307
+ return
1308
+
1309
+ logger.debug(f"Cleaning restore test directory: {restore_dir}")
1310
+ try:
1311
+ for item in os.listdir(restore_dir):
1312
+ item_path = os.path.join(restore_dir, item)
1313
+ try:
1314
+ if os.path.isfile(item_path) or os.path.islink(item_path):
1315
+ os.unlink(item_path)
1316
+ elif os.path.isdir(item_path):
1317
+ shutil.rmtree(item_path)
1318
+ except Exception as e:
1319
+ logger.warning(f"Failed to remove {item_path}: {e}")
1320
+ except Exception as e:
1321
+ logger.warning(f"Failed to clean restore directory {restore_dir}: {e}")
1322
+
1323
+
1005
1324
  def main():
1006
1325
  global logger, runner
1007
1326
  results: List[(str,int)] = [] # a list op tuples (<msg>, <exit code>)
@@ -1095,7 +1414,14 @@ def main():
1095
1414
  raise SystemExit(127)
1096
1415
 
1097
1416
  args.config_file = config_settings_path
1098
- config_settings = ConfigSettings(args.config_file)
1417
+ try:
1418
+ config_settings = ConfigSettings(args.config_file)
1419
+ except Exception as exc:
1420
+ msg = f"Config error: {exc}"
1421
+ print(msg, file=stderr)
1422
+ ts = datetime.now().strftime("%Y-%m-%d_%H:%M")
1423
+ send_discord_message(f"{ts} - dar-backup: FAILURE - {msg}")
1424
+ exit(127)
1099
1425
 
1100
1426
  if args.list_definitions:
1101
1427
  try:
@@ -1128,9 +1454,24 @@ def main():
1128
1454
  if command_output_log == config_settings.logfile_location:
1129
1455
  print(f"Error: logfile_location in {args.config_file} does not end at 'dar-backup.log', exiting", file=stderr)
1130
1456
 
1131
- logger = setup_logging(config_settings.logfile_location, command_output_log, args.log_level, args.log_stdout, logfile_max_bytes=config_settings.logfile_max_bytes, logfile_backup_count=config_settings.logfile_backup_count)
1457
+ logger = setup_logging(
1458
+ config_settings.logfile_location,
1459
+ command_output_log,
1460
+ args.log_level,
1461
+ args.log_stdout,
1462
+ logfile_max_bytes=config_settings.logfile_max_bytes,
1463
+ logfile_backup_count=config_settings.logfile_backup_count,
1464
+ trace_log_max_bytes=getattr(config_settings, "trace_log_max_bytes", 10485760),
1465
+ trace_log_backup_count=getattr(config_settings, "trace_log_backup_count", 1)
1466
+ )
1132
1467
  command_logger = get_logger(command_output_logger = True)
1133
- runner = CommandRunner(logger=logger, command_logger=command_logger)
1468
+ runner = CommandRunner(
1469
+ logger=logger,
1470
+ command_logger=command_logger,
1471
+ default_capture_limit_bytes=getattr(config_settings, "command_capture_max_bytes", None)
1472
+ )
1473
+
1474
+ clean_restore_test_directory(config_settings)
1134
1475
 
1135
1476
 
1136
1477
  try:
@@ -1201,6 +1542,8 @@ def main():
1201
1542
 
1202
1543
  requirements('PREREQ', config_settings)
1203
1544
 
1545
+ stats: List[dict] = []
1546
+
1204
1547
  if args.list:
1205
1548
  list_filter = args.backup_definition
1206
1549
  if isinstance(args.list, str):
@@ -1211,11 +1554,11 @@ def main():
1211
1554
  list_filter = args.list
1212
1555
  list_backups(config_settings.backup_dir, list_filter)
1213
1556
  elif args.full_backup and not args.differential_backup and not args.incremental_backup:
1214
- results.extend(perform_backup(args, config_settings, "FULL"))
1557
+ results.extend(perform_backup(args, config_settings, "FULL", stats))
1215
1558
  elif args.differential_backup and not args.full_backup and not args.incremental_backup:
1216
- results.extend(perform_backup(args, config_settings, "DIFF"))
1559
+ results.extend(perform_backup(args, config_settings, "DIFF", stats))
1217
1560
  elif args.incremental_backup and not args.full_backup and not args.differential_backup:
1218
- results.extend(perform_backup(args, config_settings, "INCR"))
1561
+ results.extend(perform_backup(args, config_settings, "INCR", stats))
1219
1562
  logger.debug(f"results from perform_backup(): {results}")
1220
1563
  elif args.list_contents:
1221
1564
  list_contents(args.list_contents, config_settings.backup_dir, args.selection)
@@ -1227,11 +1570,42 @@ def main():
1227
1570
 
1228
1571
  logger.debug(f"results[]: {results}")
1229
1572
 
1573
+ # Send aggregated Discord notification if stats were collected
1574
+ if stats:
1575
+ total = len(stats)
1576
+ failures = [s for s in stats if s['status'] == 'FAILURE']
1577
+ warnings = [s for s in stats if s['status'] == 'WARNING']
1578
+ successes = [s for s in stats if s['status'] == 'SUCCESS']
1579
+
1580
+ ts = datetime.now().strftime("%Y-%m-%d_%H:%M")
1581
+
1582
+ if failures or warnings:
1583
+ msg_lines = [f"{ts} - dar-backup Run Completed"]
1584
+ msg_lines.append(f"Total: {total}, Success: {len(successes)}, Warning: {len(warnings)}, Failure: {len(failures)}")
1585
+
1586
+ if failures:
1587
+ msg_lines.append("\nFailures:")
1588
+ for f in failures:
1589
+ msg_lines.append(f"- {f['definition']} ({f['type']})")
1590
+
1591
+ if warnings:
1592
+ msg_lines.append("\nWarnings:")
1593
+ for w in warnings:
1594
+ msg_lines.append(f"- {w['definition']} ({w['type']})")
1595
+
1596
+ send_discord_message("\n".join(msg_lines), config_settings=config_settings)
1597
+ else:
1598
+ # All successful
1599
+ send_discord_message(f"{ts} - dar-backup: SUCCESS - All {total} backups completed successfully.", config_settings=config_settings)
1600
+
1230
1601
  requirements('POSTREQ', config_settings)
1231
1602
 
1232
1603
 
1233
1604
  except Exception as e:
1234
- logger.error("Exception details:", exc_info=True)
1605
+ msg = f"Unexpected error: {e}"
1606
+ logger.error(msg, exc_info=True)
1607
+ ts = datetime.now().strftime("%Y-%m-%d_%H:%M")
1608
+ send_discord_message(f"{ts} - dar-backup: FAILURE - {msg}", config_settings=config_settings)
1235
1609
  results.append((repr(e), 1))
1236
1610
  finally:
1237
1611
  end_time=int(time())