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/dar_backup.py
CHANGED
|
@@ -12,9 +12,6 @@ See section 15 and section 16 in the supplied "LICENSE" file
|
|
|
12
12
|
|
|
13
13
|
This script can be used to control `dar` to backup parts of or the whole system.
|
|
14
14
|
"""
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
15
|
import argcomplete
|
|
19
16
|
import argparse
|
|
20
17
|
import filecmp
|
|
@@ -25,9 +22,9 @@ import re
|
|
|
25
22
|
import shlex
|
|
26
23
|
import shutil
|
|
27
24
|
import subprocess
|
|
25
|
+
import configparser
|
|
28
26
|
import xml.etree.ElementTree as ET
|
|
29
27
|
import tempfile
|
|
30
|
-
import threading
|
|
31
28
|
|
|
32
29
|
from argparse import ArgumentParser
|
|
33
30
|
from datetime import datetime
|
|
@@ -39,7 +36,6 @@ from sys import version_info
|
|
|
39
36
|
from time import time
|
|
40
37
|
from rich.console import Console
|
|
41
38
|
from rich.text import Text
|
|
42
|
-
from threading import Event
|
|
43
39
|
from typing import List, Tuple
|
|
44
40
|
|
|
45
41
|
from . import __about__ as about
|
|
@@ -51,17 +47,18 @@ from dar_backup.util import BackupError
|
|
|
51
47
|
from dar_backup.util import RestoreError
|
|
52
48
|
from dar_backup.util import requirements
|
|
53
49
|
from dar_backup.util import show_version
|
|
50
|
+
from dar_backup.util import get_config_file
|
|
54
51
|
from dar_backup.util import get_invocation_command_line
|
|
55
52
|
from dar_backup.util import get_binary_info
|
|
56
53
|
from dar_backup.util import print_aligned_settings
|
|
57
54
|
from dar_backup.util import backup_definition_completer, list_archive_completer
|
|
58
55
|
from dar_backup.util import show_scriptname
|
|
59
56
|
from dar_backup.util import print_debug
|
|
57
|
+
from dar_backup.util import send_discord_message
|
|
60
58
|
|
|
61
59
|
from dar_backup.command_runner import CommandRunner
|
|
62
60
|
from dar_backup.command_runner import CommandResult
|
|
63
61
|
|
|
64
|
-
from dar_backup.rich_progress import show_log_driven_bar
|
|
65
62
|
|
|
66
63
|
from argcomplete.completers import FilesCompleter
|
|
67
64
|
|
|
@@ -99,29 +96,11 @@ def generic_backup(type: str, command: List[str], backup_file: str, backup_defin
|
|
|
99
96
|
|
|
100
97
|
logger.info(f"===> Starting {type} backup for {backup_definition}")
|
|
101
98
|
try:
|
|
102
|
-
log_basename = os.path. dirname(config_settings.logfile_location)
|
|
103
|
-
logfile = os.path.basename(config_settings.logfile_location)[:-4] + "-commands.log"
|
|
104
|
-
log_path = os.path.join( log_basename, logfile)
|
|
105
|
-
logger.debug(f"Commands log file: {log_path}")
|
|
106
|
-
|
|
107
|
-
# wrap a progress bar around the dar command
|
|
108
|
-
stop_event = Event()
|
|
109
|
-
session_marker = f"=== START BACKUP SESSION: {int(time())} ==="
|
|
110
|
-
get_logger(command_output_logger=True).info(session_marker)
|
|
111
|
-
progress_thread = threading.Thread(
|
|
112
|
-
target=show_log_driven_bar,
|
|
113
|
-
args=(log_path, stop_event, session_marker),
|
|
114
|
-
daemon=True
|
|
115
|
-
)
|
|
116
|
-
progress_thread.start()
|
|
117
99
|
try:
|
|
118
|
-
process = runner.run(command, timeout
|
|
100
|
+
process = runner.run(command, timeout=config_settings.command_timeout_secs)
|
|
119
101
|
except Exception as e:
|
|
120
102
|
print(f"[!] Backup failed: {e}")
|
|
121
103
|
raise
|
|
122
|
-
finally:
|
|
123
|
-
stop_event.set()
|
|
124
|
-
progress_thread.join()
|
|
125
104
|
|
|
126
105
|
if process.returncode == 0:
|
|
127
106
|
logger.info(f"{type} backup completed successfully.")
|
|
@@ -227,6 +206,39 @@ def find_files_between_min_and_max_size(backed_up_files: list[(str, str)], confi
|
|
|
227
206
|
return files
|
|
228
207
|
|
|
229
208
|
|
|
209
|
+
def filter_restoretest_candidates(files: List[str], config_settings: ConfigSettings) -> List[str]:
|
|
210
|
+
prefixes = [
|
|
211
|
+
prefix.lstrip("/").lower()
|
|
212
|
+
for prefix in getattr(config_settings, "restoretest_exclude_prefixes", [])
|
|
213
|
+
]
|
|
214
|
+
suffixes = [
|
|
215
|
+
suffix.lower()
|
|
216
|
+
for suffix in getattr(config_settings, "restoretest_exclude_suffixes", [])
|
|
217
|
+
]
|
|
218
|
+
regex = getattr(config_settings, "restoretest_exclude_regex", None)
|
|
219
|
+
|
|
220
|
+
if not prefixes and not suffixes and not regex:
|
|
221
|
+
return files
|
|
222
|
+
|
|
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
|
+
|
|
235
|
+
if logger:
|
|
236
|
+
excluded = len(files) - len(filtered)
|
|
237
|
+
if excluded:
|
|
238
|
+
logger.debug(f"Restore test filter excluded {excluded} of {len(files)} candidates")
|
|
239
|
+
return filtered
|
|
240
|
+
|
|
241
|
+
|
|
230
242
|
def verify(args: argparse.Namespace, backup_file: str, backup_definition: str, config_settings: ConfigSettings):
|
|
231
243
|
"""
|
|
232
244
|
Verify the integrity of a DAR backup by performing the following steps:
|
|
@@ -251,29 +263,11 @@ def verify(args: argparse.Namespace, backup_file: str, backup_definition: str, c
|
|
|
251
263
|
command = ['dar', '-t', backup_file, '-N', '-Q']
|
|
252
264
|
|
|
253
265
|
|
|
254
|
-
log_basename = os.path. dirname(config_settings.logfile_location)
|
|
255
|
-
logfile = os.path.basename(config_settings.logfile_location)[:-4] + "-commands.log"
|
|
256
|
-
log_path = os.path.join( log_basename, logfile)
|
|
257
|
-
|
|
258
|
-
# wrap a progress bar around the dar command
|
|
259
|
-
stop_event = Event()
|
|
260
|
-
session_marker = f"=== START BACKUP SESSION: {int(time())} ==="
|
|
261
|
-
get_logger(command_output_logger=True).info(session_marker)
|
|
262
|
-
|
|
263
|
-
progress_thread = threading.Thread(
|
|
264
|
-
target=show_log_driven_bar,
|
|
265
|
-
args=(log_path, stop_event, session_marker),
|
|
266
|
-
daemon=True
|
|
267
|
-
)
|
|
268
|
-
progress_thread.start()
|
|
269
266
|
try:
|
|
270
|
-
process = runner.run(command, timeout
|
|
267
|
+
process = runner.run(command, timeout=config_settings.command_timeout_secs)
|
|
271
268
|
except Exception as e:
|
|
272
269
|
print(f"[!] Backup failed: {e}")
|
|
273
270
|
raise
|
|
274
|
-
finally:
|
|
275
|
-
stop_event.set()
|
|
276
|
-
progress_thread.join()
|
|
277
271
|
|
|
278
272
|
|
|
279
273
|
if process.returncode == 0:
|
|
@@ -287,8 +281,11 @@ def verify(args: argparse.Namespace, backup_file: str, backup_definition: str, c
|
|
|
287
281
|
backed_up_files = get_backed_up_files(backup_file, config_settings.backup_dir)
|
|
288
282
|
|
|
289
283
|
files = find_files_between_min_and_max_size(backed_up_files, config_settings)
|
|
284
|
+
files = filter_restoretest_candidates(files, config_settings)
|
|
290
285
|
if len(files) == 0:
|
|
291
|
-
logger.info(
|
|
286
|
+
logger.info(
|
|
287
|
+
"No files eligible for verification after size and restore-test filters, skipping"
|
|
288
|
+
)
|
|
292
289
|
return result
|
|
293
290
|
|
|
294
291
|
# find Root path in backup definition
|
|
@@ -312,6 +309,13 @@ def verify(args: argparse.Namespace, backup_file: str, backup_definition: str, c
|
|
|
312
309
|
if len(files) < config_settings.no_files_verification:
|
|
313
310
|
no_files_verification = len(files)
|
|
314
311
|
random_files = random.sample(files, no_files_verification)
|
|
312
|
+
|
|
313
|
+
# Ensure restore directory exists for verification restores
|
|
314
|
+
try:
|
|
315
|
+
os.makedirs(config_settings.test_restore_dir, exist_ok=True)
|
|
316
|
+
except OSError as exc:
|
|
317
|
+
raise BackupError(f"Cannot create restore directory '{config_settings.test_restore_dir}': {exc}") from exc
|
|
318
|
+
|
|
315
319
|
for restored_file_path in random_files:
|
|
316
320
|
try:
|
|
317
321
|
args.verbose and logger.info(f"Restoring file: '{restored_file_path}' from backup to: '{config_settings.test_restore_dir}' for file comparing")
|
|
@@ -465,6 +469,100 @@ def create_backup_command(backup_type: str, backup_file: str, darrc: str, backup
|
|
|
465
469
|
return base_command
|
|
466
470
|
|
|
467
471
|
|
|
472
|
+
def validate_required_directories(config_settings: ConfigSettings) -> None:
|
|
473
|
+
"""
|
|
474
|
+
Ensure configured directories exist; raise if any are missing.
|
|
475
|
+
"""
|
|
476
|
+
required = [
|
|
477
|
+
("BACKUP_DIR", config_settings.backup_dir),
|
|
478
|
+
("BACKUP.D_DIR", config_settings.backup_d_dir),
|
|
479
|
+
("TEST_RESTORE_DIR", config_settings.test_restore_dir),
|
|
480
|
+
]
|
|
481
|
+
manager_db_dir = getattr(config_settings, "manager_db_dir", None)
|
|
482
|
+
if manager_db_dir:
|
|
483
|
+
required.append(("MANAGER_DB_DIR", manager_db_dir))
|
|
484
|
+
|
|
485
|
+
missing = [(name, path) for name, path in required if not path or not os.path.isdir(path)]
|
|
486
|
+
if missing:
|
|
487
|
+
details = "; ".join(f"{name}={path}" for name, path in missing)
|
|
488
|
+
raise RuntimeError(f"Required directories missing or not accessible: {details}")
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def preflight_check(args: argparse.Namespace, config_settings: ConfigSettings) -> bool:
|
|
492
|
+
"""
|
|
493
|
+
Run preflight checks to validate environment before backup.
|
|
494
|
+
"""
|
|
495
|
+
errors = []
|
|
496
|
+
|
|
497
|
+
def check_dir(name: str, path: str, require_write: bool = True):
|
|
498
|
+
if not path:
|
|
499
|
+
errors.append(f"{name} is not set")
|
|
500
|
+
return
|
|
501
|
+
if not os.path.isdir(path):
|
|
502
|
+
errors.append(f"{name} does not exist: {path}")
|
|
503
|
+
return
|
|
504
|
+
if require_write and not os.access(path, os.W_OK):
|
|
505
|
+
errors.append(f"{name} is not writable: {path}")
|
|
506
|
+
|
|
507
|
+
# Directories and permissions
|
|
508
|
+
check_dir("BACKUP_DIR", config_settings.backup_dir)
|
|
509
|
+
check_dir("BACKUP.D_DIR", config_settings.backup_d_dir)
|
|
510
|
+
check_dir("TEST_RESTORE_DIR", config_settings.test_restore_dir)
|
|
511
|
+
if getattr(config_settings, "manager_db_dir", None):
|
|
512
|
+
check_dir("MANAGER_DB_DIR", config_settings.manager_db_dir)
|
|
513
|
+
|
|
514
|
+
# Log directory write access
|
|
515
|
+
log_dir = os.path.dirname(config_settings.logfile_location)
|
|
516
|
+
check_dir("LOGFILE_LOCATION directory", log_dir)
|
|
517
|
+
|
|
518
|
+
# Binaries present
|
|
519
|
+
for cmd in ("dar",):
|
|
520
|
+
if shutil.which(cmd) is None:
|
|
521
|
+
errors.append(f"Binary not found on PATH: {cmd}")
|
|
522
|
+
if getattr(config_settings, "par2_enabled", False):
|
|
523
|
+
if shutil.which("par2") is None:
|
|
524
|
+
errors.append("Binary not found on PATH: par2 (required when PAR2.ENABLED is true)")
|
|
525
|
+
|
|
526
|
+
# Binaries respond to --version (basic health)
|
|
527
|
+
for cmd in ("dar",):
|
|
528
|
+
if shutil.which(cmd):
|
|
529
|
+
try:
|
|
530
|
+
subprocess.run([cmd, "--version"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True)
|
|
531
|
+
except Exception:
|
|
532
|
+
errors.append(f"Failed to run '{cmd} --version'")
|
|
533
|
+
if getattr(config_settings, "par2_enabled", False) and shutil.which("par2"):
|
|
534
|
+
try:
|
|
535
|
+
subprocess.run(["par2", "--version"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True)
|
|
536
|
+
except Exception:
|
|
537
|
+
errors.append("Failed to run 'par2 --version'")
|
|
538
|
+
|
|
539
|
+
# Restore scratch: can create/clean temp file
|
|
540
|
+
scratch_test_file = os.path.join(config_settings.test_restore_dir, ".dar-backup-preflight")
|
|
541
|
+
try:
|
|
542
|
+
os.makedirs(config_settings.test_restore_dir, exist_ok=True)
|
|
543
|
+
with open(scratch_test_file, "w") as f:
|
|
544
|
+
f.write("ok")
|
|
545
|
+
os.remove(scratch_test_file)
|
|
546
|
+
except Exception as exc:
|
|
547
|
+
errors.append(f"Cannot write to TEST_RESTORE_DIR ({config_settings.test_restore_dir}): {exc}")
|
|
548
|
+
|
|
549
|
+
# Config sanity: backup definition exists if provided
|
|
550
|
+
if args.backup_definition:
|
|
551
|
+
candidate = os.path.join(config_settings.backup_d_dir, args.backup_definition)
|
|
552
|
+
if not os.path.isfile(candidate):
|
|
553
|
+
errors.append(f"Backup definition not found: {candidate}")
|
|
554
|
+
|
|
555
|
+
if errors:
|
|
556
|
+
print("Preflight checks failed:")
|
|
557
|
+
for err in errors:
|
|
558
|
+
print(f" - {err}")
|
|
559
|
+
return False
|
|
560
|
+
|
|
561
|
+
if os.environ.get("PYTEST_CURRENT_TEST"):
|
|
562
|
+
print("Preflight checks passed.")
|
|
563
|
+
|
|
564
|
+
return True
|
|
565
|
+
|
|
468
566
|
|
|
469
567
|
def perform_backup(args: argparse.Namespace, config_settings: ConfigSettings, backup_type: str) -> List[str]:
|
|
470
568
|
"""
|
|
@@ -500,14 +598,16 @@ def perform_backup(args: argparse.Namespace, config_settings: ConfigSettings, ba
|
|
|
500
598
|
backup_definitions.append((file.split('.')[0], os.path.join(root, file)))
|
|
501
599
|
|
|
502
600
|
for backup_definition, backup_definition_path in backup_definitions:
|
|
601
|
+
start_len = len(results)
|
|
602
|
+
success = True
|
|
503
603
|
try:
|
|
504
604
|
date = datetime.now().strftime('%Y-%m-%d')
|
|
505
605
|
backup_file = os.path.join(config_settings.backup_dir, f"{backup_definition}_{backup_type}_{date}")
|
|
506
606
|
|
|
507
607
|
if os.path.exists(backup_file + '.1.dar'):
|
|
508
608
|
msg = f"Backup file {backup_file}.1.dar already exists. Skipping backup [1]."
|
|
509
|
-
logger.
|
|
510
|
-
results.append((msg,
|
|
609
|
+
logger.warning(msg)
|
|
610
|
+
results.append((msg, 2))
|
|
511
611
|
continue
|
|
512
612
|
|
|
513
613
|
latest_base_backup = None
|
|
@@ -551,19 +651,125 @@ def perform_backup(args: argparse.Namespace, config_settings: ConfigSettings, ba
|
|
|
551
651
|
else:
|
|
552
652
|
msg = f"Verification of '{backup_file}' failed."
|
|
553
653
|
logger.error(msg)
|
|
554
|
-
results.append((msg,
|
|
654
|
+
results.append((msg, 2))
|
|
555
655
|
logger.info("Generate par2 redundancy files.")
|
|
556
|
-
generate_par2_files(backup_file, config_settings, args)
|
|
656
|
+
generate_par2_files(backup_file, config_settings, args, backup_definition=backup_definition)
|
|
557
657
|
logger.info("par2 files completed successfully.")
|
|
558
658
|
|
|
559
659
|
except Exception as e:
|
|
560
660
|
results.append((repr(e), 1))
|
|
561
661
|
logger.exception(f"Error during {backup_type} backup process, continuing to next backup definition.")
|
|
662
|
+
success = False
|
|
663
|
+
finally:
|
|
664
|
+
# Determine status based on new results for this backup definition
|
|
665
|
+
new_results = results[start_len:]
|
|
666
|
+
has_error = any(code == 1 for _, code in new_results)
|
|
667
|
+
has_warning = any(code == 2 for _, code in new_results)
|
|
668
|
+
if has_error:
|
|
669
|
+
success = False
|
|
670
|
+
|
|
671
|
+
# Avoid spamming from example/demo backup definitions
|
|
672
|
+
if backup_definition.lower() == "example":
|
|
673
|
+
logger.debug("Skipping Discord notification for example backup definition.")
|
|
674
|
+
continue
|
|
675
|
+
|
|
676
|
+
if has_error:
|
|
677
|
+
status = "FAILURE"
|
|
678
|
+
elif has_warning:
|
|
679
|
+
status = "WARNING"
|
|
680
|
+
else:
|
|
681
|
+
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}")
|
|
562
686
|
|
|
563
687
|
logger.trace(f"perform_backup() results[]: {results}")
|
|
564
688
|
return results
|
|
565
689
|
|
|
566
|
-
def
|
|
690
|
+
def _parse_archive_base(backup_file: str) -> str:
|
|
691
|
+
return os.path.basename(backup_file)
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def _list_dar_slices(archive_dir: str, archive_base: str) -> List[str]:
|
|
695
|
+
pattern = re.compile(rf"{re.escape(archive_base)}\.([0-9]+)\.dar$")
|
|
696
|
+
dar_slices: List[str] = []
|
|
697
|
+
|
|
698
|
+
for filename in os.listdir(archive_dir):
|
|
699
|
+
match = pattern.match(filename)
|
|
700
|
+
if match:
|
|
701
|
+
dar_slices.append(filename)
|
|
702
|
+
|
|
703
|
+
dar_slices.sort(key=lambda x: int(pattern.match(x).group(1)))
|
|
704
|
+
return dar_slices
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def _validate_slice_sequence(dar_slices: List[str], archive_base: str) -> None:
|
|
708
|
+
pattern = re.compile(rf"{re.escape(archive_base)}\.([0-9]+)\.dar$")
|
|
709
|
+
if not dar_slices:
|
|
710
|
+
raise RuntimeError(f"No dar slices found for archive base: {archive_base}")
|
|
711
|
+
slice_numbers = [int(pattern.match(s).group(1)) for s in dar_slices]
|
|
712
|
+
expected = list(range(1, max(slice_numbers) + 1))
|
|
713
|
+
if slice_numbers != expected:
|
|
714
|
+
raise RuntimeError(f"Missing dar slices for archive {archive_base}: expected {expected}, got {slice_numbers}")
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
def _get_backup_type_from_archive_base(archive_base: str) -> str:
|
|
718
|
+
parts = archive_base.split('_')
|
|
719
|
+
if len(parts) < 3:
|
|
720
|
+
raise RuntimeError(f"Unexpected archive name format: {archive_base}")
|
|
721
|
+
return parts[1]
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def _get_par2_ratio(backup_type: str, par2_config: dict, default_ratio: int) -> int:
|
|
725
|
+
backup_type = backup_type.upper()
|
|
726
|
+
if backup_type == "FULL" and par2_config.get("par2_ratio_full") is not None:
|
|
727
|
+
return par2_config["par2_ratio_full"]
|
|
728
|
+
if backup_type == "DIFF" and par2_config.get("par2_ratio_diff") is not None:
|
|
729
|
+
return par2_config["par2_ratio_diff"]
|
|
730
|
+
if backup_type == "INCR" and par2_config.get("par2_ratio_incr") is not None:
|
|
731
|
+
return par2_config["par2_ratio_incr"]
|
|
732
|
+
return default_ratio
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def _write_par2_manifest(
|
|
736
|
+
manifest_path: str,
|
|
737
|
+
archive_dir_relative: str,
|
|
738
|
+
archive_base: str,
|
|
739
|
+
archive_files: List[str],
|
|
740
|
+
dar_backup_version: str,
|
|
741
|
+
dar_version: str
|
|
742
|
+
) -> None:
|
|
743
|
+
config = configparser.ConfigParser()
|
|
744
|
+
config["MANIFEST"] = {
|
|
745
|
+
"archive_dir_relative": archive_dir_relative,
|
|
746
|
+
"archive_base": archive_base,
|
|
747
|
+
"dar_backup_version": dar_backup_version,
|
|
748
|
+
"dar_version": dar_version,
|
|
749
|
+
"created_utc": datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
750
|
+
}
|
|
751
|
+
config["ARCHIVE_FILES"] = {
|
|
752
|
+
"files": "\n".join(archive_files)
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
with open(manifest_path, "w", encoding="utf-8") as f:
|
|
756
|
+
config.write(f)
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
def _default_par2_config(config_settings: ConfigSettings) -> dict:
|
|
760
|
+
return {
|
|
761
|
+
"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
|
+
"par2_ratio_full": getattr(config_settings, "par2_ratio_full", None),
|
|
765
|
+
"par2_ratio_diff": getattr(config_settings, "par2_ratio_diff", None),
|
|
766
|
+
"par2_ratio_incr": getattr(config_settings, "par2_ratio_incr", None),
|
|
767
|
+
"par2_run_verify": getattr(config_settings, "par2_run_verify", None),
|
|
768
|
+
"par2_enabled": getattr(config_settings, "par2_enabled", True),
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
def generate_par2_files(backup_file: str, config_settings: ConfigSettings, args, backup_definition: str = None):
|
|
567
773
|
"""
|
|
568
774
|
Generate PAR2 files for a given backup file in the specified backup directory.
|
|
569
775
|
|
|
@@ -571,6 +777,7 @@ def generate_par2_files(backup_file: str, config_settings: ConfigSettings, args)
|
|
|
571
777
|
backup_file (str): The name of the backup file.
|
|
572
778
|
config_settings: The configuration settings object.
|
|
573
779
|
args: The command-line arguments object.
|
|
780
|
+
backup_definition (str): The backup definition name used for per-backup overrides.
|
|
574
781
|
|
|
575
782
|
Raises:
|
|
576
783
|
subprocess.CalledProcessError: If the par2 command fails to execute.
|
|
@@ -578,38 +785,58 @@ def generate_par2_files(backup_file: str, config_settings: ConfigSettings, args)
|
|
|
578
785
|
Returns:
|
|
579
786
|
None
|
|
580
787
|
"""
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
788
|
+
if hasattr(config_settings, "get_par2_config"):
|
|
789
|
+
par2_config = config_settings.get_par2_config(backup_definition)
|
|
790
|
+
else:
|
|
791
|
+
par2_config = _default_par2_config(config_settings)
|
|
792
|
+
if not par2_config.get("par2_enabled", False):
|
|
793
|
+
logger.debug("PAR2 disabled for this backup definition, skipping.")
|
|
794
|
+
return
|
|
795
|
+
|
|
796
|
+
archive_dir = config_settings.backup_dir
|
|
797
|
+
archive_base = _parse_archive_base(backup_file)
|
|
798
|
+
backup_type = _get_backup_type_from_archive_base(archive_base)
|
|
799
|
+
par2_dir = par2_config.get("par2_dir")
|
|
800
|
+
if par2_dir:
|
|
801
|
+
par2_dir = os.path.expanduser(os.path.expandvars(par2_dir))
|
|
802
|
+
os.makedirs(par2_dir, exist_ok=True)
|
|
803
|
+
|
|
804
|
+
ratio = _get_par2_ratio(backup_type, par2_config, config_settings.error_correction_percent)
|
|
805
|
+
|
|
806
|
+
dar_slices = _list_dar_slices(archive_dir, archive_base)
|
|
807
|
+
_validate_slice_sequence(dar_slices, archive_base)
|
|
594
808
|
number_of_slices = len(dar_slices)
|
|
595
|
-
counter = 1
|
|
596
|
-
|
|
597
|
-
for slice_file in dar_slices:
|
|
598
|
-
file_path = os.path.join(config_settings.backup_dir, slice_file)
|
|
599
|
-
|
|
600
|
-
logger.info(f"{counter}/{number_of_slices}: Now generating par2 files for {file_path}")
|
|
601
|
-
|
|
602
|
-
# Run the par2 command to generate redundancy files with error correction
|
|
603
|
-
command = ['par2', 'create', f'-r{config_settings.error_correction_percent}', '-q', '-q', file_path]
|
|
604
|
-
process = runner.run(command, timeout = config_settings.command_timeout_secs)
|
|
605
809
|
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
810
|
+
par2_output_dir = par2_dir or archive_dir
|
|
811
|
+
par2_path = os.path.join(par2_output_dir, f"{archive_base}.par2")
|
|
812
|
+
dar_slice_paths = [os.path.join(archive_dir, slice_file) for slice_file in dar_slices]
|
|
813
|
+
logger.info(f"Generating par2 set for archive: {archive_base}")
|
|
814
|
+
command = ['par2', 'create', '-B', archive_dir, f'-r{ratio}', '-q', '-q', par2_path] + dar_slice_paths
|
|
815
|
+
process = runner.run(command, timeout=config_settings.command_timeout_secs)
|
|
816
|
+
if process.returncode != 0:
|
|
817
|
+
logger.error(f"Error generating par2 files for {archive_base}")
|
|
818
|
+
raise subprocess.CalledProcessError(process.returncode, command)
|
|
819
|
+
|
|
820
|
+
if par2_dir:
|
|
821
|
+
archive_dir_relative = os.path.relpath(archive_dir, par2_dir)
|
|
822
|
+
manifest_path = f"{par2_path}.manifest.ini"
|
|
823
|
+
_write_par2_manifest(
|
|
824
|
+
manifest_path=manifest_path,
|
|
825
|
+
archive_dir_relative=archive_dir_relative,
|
|
826
|
+
archive_base=archive_base,
|
|
827
|
+
archive_files=dar_slices,
|
|
828
|
+
dar_backup_version=about.__version__,
|
|
829
|
+
dar_version=getattr(args, "dar_version", "unknown")
|
|
830
|
+
)
|
|
831
|
+
logger.info(f"Wrote par2 manifest: {manifest_path}")
|
|
612
832
|
|
|
833
|
+
if par2_config.get("par2_run_verify"):
|
|
834
|
+
logger.info(f"Verifying par2 set for archive: {archive_base}")
|
|
835
|
+
verify_command = ['par2', 'verify', '-B', archive_dir, par2_path]
|
|
836
|
+
verify_process = runner.run(verify_command, timeout=config_settings.command_timeout_secs)
|
|
837
|
+
if verify_process.returncode != 0:
|
|
838
|
+
raise subprocess.CalledProcessError(verify_process.returncode, verify_command)
|
|
839
|
+
return
|
|
613
840
|
|
|
614
841
|
|
|
615
842
|
def filter_darrc_file(darrc_path):
|
|
@@ -765,6 +992,14 @@ def print_readme(path: str = None, pretty: bool = True):
|
|
|
765
992
|
path = Path(__file__).parent / "README.md"
|
|
766
993
|
print_markdown(str(path), pretty=pretty)
|
|
767
994
|
|
|
995
|
+
def list_definitions(backup_d_dir: str) -> List[str]:
|
|
996
|
+
"""
|
|
997
|
+
Return backup definition filenames from BACKUP.D_DIR, sorted by name.
|
|
998
|
+
"""
|
|
999
|
+
dir_path = Path(backup_d_dir)
|
|
1000
|
+
if not dir_path.is_dir():
|
|
1001
|
+
raise RuntimeError(f"BACKUP.D_DIR does not exist or is not a directory: {backup_d_dir}")
|
|
1002
|
+
return sorted([entry.name for entry in dir_path.iterdir() if entry.is_file()])
|
|
768
1003
|
|
|
769
1004
|
|
|
770
1005
|
def main():
|
|
@@ -782,15 +1017,24 @@ def main():
|
|
|
782
1017
|
parser.add_argument('-I', '--incremental-backup', action='store_true', help="Perform incremental backup.")
|
|
783
1018
|
parser.add_argument('-d', '--backup-definition', help="Specific 'recipe' to select directories and files.").completer = backup_definition_completer
|
|
784
1019
|
parser.add_argument('--alternate-reference-archive', help="DIFF or INCR compared to specified archive.").completer = list_archive_completer
|
|
785
|
-
parser.add_argument('-c', '--config-file', type=str, help="Path to 'dar-backup.conf'", default=
|
|
1020
|
+
parser.add_argument('-c', '--config-file', type=str, help="Path to 'dar-backup.conf'", default=None)
|
|
786
1021
|
parser.add_argument('--darrc', type=str, help='Optional path to .darrc')
|
|
787
|
-
parser.add_argument(
|
|
1022
|
+
parser.add_argument(
|
|
1023
|
+
'-l',
|
|
1024
|
+
'--list',
|
|
1025
|
+
nargs='?',
|
|
1026
|
+
const=True,
|
|
1027
|
+
default=False,
|
|
1028
|
+
help="List available archives.",
|
|
1029
|
+
).completer = list_archive_completer
|
|
788
1030
|
parser.add_argument('--list-contents', help="List the contents of the specified archive.").completer = list_archive_completer
|
|
1031
|
+
parser.add_argument('--list-definitions', action='store_true', help="List available backup definitions from BACKUP.D_DIR.")
|
|
789
1032
|
parser.add_argument('--selection', type=str, help="Selection string to pass to 'dar', e.g. --selection=\"-I '*.NEF'\"")
|
|
790
1033
|
# parser.add_argument('-r', '--restore', nargs=1, type=str, help="Restore specified archive.")
|
|
791
1034
|
parser.add_argument('-r', '--restore', type=str, help="Restore specified archive.").completer = list_archive_completer
|
|
792
1035
|
parser.add_argument('--restore-dir', type=str, help="Directory to restore files to.")
|
|
793
1036
|
parser.add_argument('--verbose', action='store_true', help="Print various status messages to screen")
|
|
1037
|
+
parser.add_argument('--preflight-check', action='store_true', help="Run preflight checks and exit")
|
|
794
1038
|
parser.add_argument('--suppress-dar-msg', action='store_true', help="cancel dar options in .darrc: -vt, -vs, -vd, -vf and -va")
|
|
795
1039
|
parser.add_argument('--log-level', type=str, help="`debug` or `trace`", default="info")
|
|
796
1040
|
parser.add_argument('--log-stdout', action='store_true', help='also print log messages to stdout')
|
|
@@ -804,6 +1048,11 @@ def main():
|
|
|
804
1048
|
|
|
805
1049
|
argcomplete.autocomplete(parser)
|
|
806
1050
|
args = parser.parse_args()
|
|
1051
|
+
# Ensure new flags are present when parse_args is mocked in tests
|
|
1052
|
+
if not hasattr(args, "preflight_check"):
|
|
1053
|
+
args.preflight_check = False
|
|
1054
|
+
if not hasattr(args, "list_definitions"):
|
|
1055
|
+
args.list_definitions = False
|
|
807
1056
|
|
|
808
1057
|
if args.version:
|
|
809
1058
|
show_version()
|
|
@@ -825,19 +1074,56 @@ def main():
|
|
|
825
1074
|
exit(0)
|
|
826
1075
|
|
|
827
1076
|
|
|
1077
|
+
# be backwards compatible with older versions
|
|
1078
|
+
DEFAULT_CONFIG_FILE = "~/.config/dar-backup/dar-backup.conf"
|
|
1079
|
+
|
|
1080
|
+
env_cf = os.getenv("DAR_BACKUP_CONFIG_FILE")
|
|
1081
|
+
env_cf = env_cf.strip() if env_cf else None
|
|
828
1082
|
|
|
829
|
-
|
|
830
|
-
print(f"Config file not specified, exiting", file=stderr)
|
|
831
|
-
exit(1)
|
|
1083
|
+
cli_cf = args.config_file.strip() if args.config_file else None
|
|
832
1084
|
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
1085
|
+
raw_config = (
|
|
1086
|
+
cli_cf
|
|
1087
|
+
or env_cf
|
|
1088
|
+
or DEFAULT_CONFIG_FILE
|
|
1089
|
+
)
|
|
1090
|
+
|
|
1091
|
+
config_settings_path = get_config_file(args)
|
|
1092
|
+
|
|
1093
|
+
if not (os.path.isfile(config_settings_path) and os.access(config_settings_path, os.R_OK)):
|
|
1094
|
+
print(f"Config file {config_settings_path} must exist and be readable.", file=stderr)
|
|
1095
|
+
raise SystemExit(127)
|
|
837
1096
|
|
|
838
1097
|
args.config_file = config_settings_path
|
|
839
1098
|
config_settings = ConfigSettings(args.config_file)
|
|
840
1099
|
|
|
1100
|
+
if args.list_definitions:
|
|
1101
|
+
try:
|
|
1102
|
+
for name in list_definitions(config_settings.backup_d_dir):
|
|
1103
|
+
print(name)
|
|
1104
|
+
except RuntimeError as exc:
|
|
1105
|
+
print(str(exc), file=stderr)
|
|
1106
|
+
exit(127)
|
|
1107
|
+
exit(0)
|
|
1108
|
+
|
|
1109
|
+
try:
|
|
1110
|
+
validate_required_directories(config_settings)
|
|
1111
|
+
except RuntimeError as exc:
|
|
1112
|
+
ts = datetime.now().strftime("%Y-%m-%d_%H:%M")
|
|
1113
|
+
send_discord_message(f"{ts} - dar-backup: FAILURE - {exc}", config_settings=config_settings)
|
|
1114
|
+
print(str(exc), file=stderr)
|
|
1115
|
+
exit(127)
|
|
1116
|
+
|
|
1117
|
+
# Run preflight checks always; if --preflight-check is set, exit afterward.
|
|
1118
|
+
ok = preflight_check(args, config_settings)
|
|
1119
|
+
if not ok:
|
|
1120
|
+
ts = datetime.now().strftime("%Y-%m-%d_%H:%M")
|
|
1121
|
+
send_discord_message(f"{ts} - dar-backup: FAILURE - preflight checks failed", config_settings=config_settings)
|
|
1122
|
+
exit_code = 127 if args.backup_definition else 1
|
|
1123
|
+
exit(exit_code)
|
|
1124
|
+
if args.preflight_check:
|
|
1125
|
+
exit(0)
|
|
1126
|
+
|
|
841
1127
|
command_output_log = config_settings.logfile_location.replace("dar-backup.log", "dar-backup-commands.log")
|
|
842
1128
|
if command_output_log == config_settings.logfile_location:
|
|
843
1129
|
print(f"Error: logfile_location in {args.config_file} does not end at 'dar-backup.log', exiting", file=stderr)
|
|
@@ -873,6 +1159,7 @@ def main():
|
|
|
873
1159
|
logger.debug(f"`Args`:\n{args}")
|
|
874
1160
|
logger.debug(f"`Config_settings`:\n{config_settings}")
|
|
875
1161
|
dar_properties = get_binary_info(command='dar')
|
|
1162
|
+
args.dar_version = dar_properties.get('version', 'unknown')
|
|
876
1163
|
start_msgs.append(('dar path:', dar_properties['path']))
|
|
877
1164
|
start_msgs.append(('dar version:', dar_properties['version']))
|
|
878
1165
|
|
|
@@ -915,7 +1202,14 @@ def main():
|
|
|
915
1202
|
requirements('PREREQ', config_settings)
|
|
916
1203
|
|
|
917
1204
|
if args.list:
|
|
918
|
-
|
|
1205
|
+
list_filter = args.backup_definition
|
|
1206
|
+
if isinstance(args.list, str):
|
|
1207
|
+
if list_filter:
|
|
1208
|
+
if args.list.startswith(list_filter):
|
|
1209
|
+
list_filter = args.list
|
|
1210
|
+
else:
|
|
1211
|
+
list_filter = args.list
|
|
1212
|
+
list_backups(config_settings.backup_dir, list_filter)
|
|
919
1213
|
elif args.full_backup and not args.differential_backup and not args.incremental_backup:
|
|
920
1214
|
results.extend(perform_backup(args, config_settings, "FULL"))
|
|
921
1215
|
elif args.differential_backup and not args.full_backup and not args.incremental_backup:
|
|
@@ -951,6 +1245,7 @@ def main():
|
|
|
951
1245
|
|
|
952
1246
|
# Determine exit code
|
|
953
1247
|
error = False
|
|
1248
|
+
final_exit_code = 0
|
|
954
1249
|
logger.debug(f"results[]: {results}")
|
|
955
1250
|
if results:
|
|
956
1251
|
i = 0
|
|
@@ -961,15 +1256,21 @@ def main():
|
|
|
961
1256
|
if exit_code > 0:
|
|
962
1257
|
error = True
|
|
963
1258
|
args.verbose and print(msg)
|
|
1259
|
+
if exit_code == 1:
|
|
1260
|
+
final_exit_code = 1
|
|
1261
|
+
elif exit_code == 2 and final_exit_code == 0:
|
|
1262
|
+
final_exit_code = 2
|
|
964
1263
|
else:
|
|
965
1264
|
logger.error(f"not correct result type: {result}, which must be a tuple (<msg>, <exit_code>)")
|
|
1265
|
+
error = True
|
|
1266
|
+
final_exit_code = 1
|
|
966
1267
|
i=i+1
|
|
967
1268
|
|
|
968
1269
|
console = Console()
|
|
969
1270
|
if error:
|
|
970
1271
|
if args.verbose:
|
|
971
1272
|
console.print(Text("Errors encountered", style="bold red"))
|
|
972
|
-
exit(1)
|
|
1273
|
+
exit(final_exit_code or 1)
|
|
973
1274
|
else:
|
|
974
1275
|
if args.verbose:
|
|
975
1276
|
console.print(Text("Success: all backups completed", style="bold green"))
|