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/Changelog.md +29 -0
- dar_backup/README.md +75 -15
- dar_backup/__about__.py +1 -2
- dar_backup/clean_log.py +102 -63
- dar_backup/cleanup.py +136 -102
- dar_backup/command_runner.py +75 -13
- dar_backup/config_settings.py +25 -11
- dar_backup/dar-backup.conf +7 -0
- dar_backup/dar-backup.conf.j2 +3 -1
- dar_backup/dar_backup.py +438 -64
- dar_backup/demo.py +18 -9
- dar_backup/installer.py +18 -1
- dar_backup/manager.py +295 -88
- dar_backup/util.py +119 -11
- {dar_backup-1.0.1.dist-info → dar_backup-1.0.2.dist-info}/METADATA +78 -18
- dar_backup-1.0.2.dist-info/RECORD +25 -0
- dar_backup-1.0.1.dist-info/RECORD +0 -25
- {dar_backup-1.0.1.dist-info → dar_backup-1.0.2.dist-info}/WHEEL +0 -0
- {dar_backup-1.0.1.dist-info → dar_backup-1.0.2.dist-info}/entry_points.txt +0 -0
- {dar_backup-1.0.1.dist-info → dar_backup-1.0.2.dist-info}/licenses/LICENSE +0 -0
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
|
|
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
|
|
196
|
-
if
|
|
197
|
-
logger.trace("tuple from dar xml list: {
|
|
198
|
-
match = re.match(pattern,
|
|
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: {
|
|
205
|
-
files.append(
|
|
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
|
|
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
|
-
|
|
221
|
-
|
|
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(
|
|
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 =
|
|
284
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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,
|
|
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
|
-
|
|
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((
|
|
661
|
-
logger.
|
|
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
|
|
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
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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())
|