dar-backup 0.6.16__py3-none-any.whl → 0.6.18__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 +217 -0
- dar_backup/README.md +1082 -0
- dar_backup/__about__.py +1 -1
- dar_backup/cleanup.py +42 -57
- dar_backup/command_runner.py +81 -0
- dar_backup/config_settings.py +7 -1
- dar_backup/dar-backup.conf +1 -1
- dar_backup/dar_backup.py +152 -49
- dar_backup/dar_backup_systemd.py +119 -0
- dar_backup/installer.py +39 -23
- dar_backup/manager.py +74 -44
- dar_backup/rich_progress.py +101 -0
- dar_backup/util.py +29 -134
- {dar_backup-0.6.16.dist-info → dar_backup-0.6.18.dist-info}/METADATA +283 -80
- dar_backup-0.6.18.dist-info/RECORD +21 -0
- {dar_backup-0.6.16.dist-info → dar_backup-0.6.18.dist-info}/entry_points.txt +1 -0
- dar_backup-0.6.16.dist-info/RECORD +0 -16
- {dar_backup-0.6.16.dist-info → dar_backup-0.6.18.dist-info}/WHEEL +0 -0
- {dar_backup-0.6.16.dist-info → dar_backup-0.6.18.dist-info}/licenses/LICENSE +0 -0
dar_backup/__about__.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "0.6.
|
|
1
|
+
__version__ = "0.6.18"
|
dar_backup/cleanup.py
CHANGED
|
@@ -27,16 +27,16 @@ from typing import Dict, List, NamedTuple
|
|
|
27
27
|
|
|
28
28
|
from . import __about__ as about
|
|
29
29
|
from dar_backup.config_settings import ConfigSettings
|
|
30
|
-
from dar_backup.util import extract_error_lines
|
|
31
30
|
from dar_backup.util import list_backups
|
|
32
|
-
from dar_backup.util import run_command
|
|
33
31
|
from dar_backup.util import setup_logging
|
|
32
|
+
from dar_backup.util import get_logger
|
|
33
|
+
from dar_backup.util import requirements
|
|
34
34
|
|
|
35
|
-
from dar_backup.
|
|
36
|
-
|
|
37
|
-
|
|
35
|
+
from dar_backup.command_runner import CommandRunner
|
|
36
|
+
from dar_backup.command_runner import CommandResult
|
|
38
37
|
|
|
39
38
|
logger = None
|
|
39
|
+
runner = None
|
|
40
40
|
|
|
41
41
|
def delete_old_backups(backup_dir, age, backup_type, args, backup_definition=None):
|
|
42
42
|
"""
|
|
@@ -134,7 +134,7 @@ def delete_catalog(catalog_name: str, args: NamedTuple) -> bool:
|
|
|
134
134
|
command = [f"manager", "--remove-specific-archive", catalog_name, "--config-file", args.config_file, '--log-level', 'debug', '--log-stdout']
|
|
135
135
|
logger.info(f"Deleting catalog '{catalog_name}' using config file: '{args.config_file}'")
|
|
136
136
|
try:
|
|
137
|
-
result:CommandResult =
|
|
137
|
+
result:CommandResult = runner.run(command)
|
|
138
138
|
if result.returncode == 0:
|
|
139
139
|
logger.info(f"Deleted catalog '{catalog_name}', using config file: '{args.config_file}'")
|
|
140
140
|
logger.debug(f"Stdout: manager.py --remove-specific-archive output:\n{result.stdout}")
|
|
@@ -158,8 +158,32 @@ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW,
|
|
|
158
158
|
See section 15 and section 16 in the supplied "LICENSE" file.''')
|
|
159
159
|
|
|
160
160
|
|
|
161
|
+
def confirm_full_archive_deletion(archive_name: str, test_mode=False) -> bool:
|
|
162
|
+
try:
|
|
163
|
+
if test_mode:
|
|
164
|
+
confirmation = os.environ.get("CLEANUP_TEST_DELETE_FULL")
|
|
165
|
+
if confirmation is None:
|
|
166
|
+
raise RuntimeError("envvar 'CLEANUP_TEST_DELETE_FULL' not set")
|
|
167
|
+
print(f"Simulated confirmation for FULL archive: {confirmation}")
|
|
168
|
+
else:
|
|
169
|
+
confirmation = inputimeout(
|
|
170
|
+
prompt=f"Are you sure you want to delete the FULL archive '{archive_name}'? (yes/no): ",
|
|
171
|
+
timeout=30)
|
|
172
|
+
if confirmation is None:
|
|
173
|
+
logger.info(f"No confirmation received for FULL archive: {archive_name}. Skipping deletion.")
|
|
174
|
+
return False
|
|
175
|
+
return confirmation.strip().lower() == "yes"
|
|
176
|
+
except TimeoutOccurred:
|
|
177
|
+
logger.info(f"Timeout waiting for confirmation for FULL archive: {archive_name}. Skipping deletion.")
|
|
178
|
+
return False
|
|
179
|
+
except KeyboardInterrupt:
|
|
180
|
+
logger.info(f"User interrupted confirmation for FULL archive: {archive_name}. Skipping deletion.")
|
|
181
|
+
return False
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
|
|
161
185
|
def main():
|
|
162
|
-
global logger
|
|
186
|
+
global logger, runner
|
|
163
187
|
|
|
164
188
|
parser = argparse.ArgumentParser(description="Cleanup old archives according to AGE configuration.")
|
|
165
189
|
parser.add_argument('-d', '--backup-definition', help="Specific backup definition to cleanup.")
|
|
@@ -188,7 +212,8 @@ def main():
|
|
|
188
212
|
# command_output_log = os.path.join(config_settings.logfile_location.removesuffix("dar-backup.log"), "dar-backup-commands.log")
|
|
189
213
|
command_output_log = config_settings.logfile_location.replace("dar-backup.log", "dar-backup-commands.log")
|
|
190
214
|
logger = setup_logging(config_settings.logfile_location, command_output_log, args.log_level, args.log_stdout)
|
|
191
|
-
|
|
215
|
+
command_logger = get_logger(command_output_logger = True)
|
|
216
|
+
runner = CommandRunner(logger=logger, command_logger=command_logger)
|
|
192
217
|
|
|
193
218
|
logger.info(f"=====================================")
|
|
194
219
|
logger.info(f"cleanup.py started, version: {about.__version__}")
|
|
@@ -205,21 +230,9 @@ def main():
|
|
|
205
230
|
args.verbose and (print(f"--alternate-archive-dir: {args.alternate_archive_dir}"))
|
|
206
231
|
args.verbose and (print(f"--cleanup-specific-archives:{args.cleanup_specific_archives}"))
|
|
207
232
|
|
|
208
|
-
# run PREREQ scripts
|
|
209
|
-
if 'PREREQ' in config_settings.config:
|
|
210
|
-
for key in sorted(config_settings.config['PREREQ'].keys()):
|
|
211
|
-
script = config_settings.config['PREREQ'][key]
|
|
212
|
-
try:
|
|
213
|
-
result = subprocess.run(script, shell=True, check=True)
|
|
214
|
-
logger.info(f"PREREQ {key}: '{script}' run, return code: {result.returncode}")
|
|
215
|
-
logger.info(f"PREREQ stdout:\n{result.stdout}")
|
|
216
|
-
except subprocess.CalledProcessError as e:
|
|
217
|
-
logger.error(f"Error executing {key}: '{script}': {e}")
|
|
218
|
-
if result:
|
|
219
|
-
logger.error(f"PREREQ stderr:\n{result.stderr}")
|
|
220
|
-
print(f"Error executing {script}: {e}")
|
|
221
|
-
sys.exit(1)
|
|
222
233
|
|
|
234
|
+
# run PREREQ scripts
|
|
235
|
+
requirements('PREREQ', config_settings)
|
|
223
236
|
|
|
224
237
|
if args.alternate_archive_dir:
|
|
225
238
|
if not os.path.exists(args.alternate_archive_dir):
|
|
@@ -230,37 +243,15 @@ def main():
|
|
|
230
243
|
sys.exit(1)
|
|
231
244
|
config_settings.backup_dir = args.alternate_archive_dir
|
|
232
245
|
|
|
246
|
+
if args.cleanup_specific_archives is None and args.test_mode:
|
|
247
|
+
logger.info("No --cleanup-specific-archives provided; skipping specific archive deletion in test mode.")
|
|
233
248
|
|
|
234
249
|
if args.cleanup_specific_archives:
|
|
235
250
|
logger.info(f"Cleaning up specific archives: {args.cleanup_specific_archives}")
|
|
236
251
|
archive_names = args.cleanup_specific_archives.split(',')
|
|
237
252
|
for archive_name in archive_names:
|
|
238
253
|
if "_FULL_" in archive_name:
|
|
239
|
-
|
|
240
|
-
try:
|
|
241
|
-
# used for pytest cases
|
|
242
|
-
if args.test_mode:
|
|
243
|
-
confirmation = os.environ.get("CLEANUP_TEST_DELETE_FULL")
|
|
244
|
-
if confirmation == None:
|
|
245
|
-
raise RuntimeError("envvar 'CLEANUP_TEST_DELETE_FULL' not set")
|
|
246
|
-
|
|
247
|
-
else:
|
|
248
|
-
confirmation = inputimeout(
|
|
249
|
-
prompt=f"Are you sure you want to delete the FULL archive '{archive_name}'? (yes/no): ",
|
|
250
|
-
timeout=30)
|
|
251
|
-
if confirmation == None:
|
|
252
|
-
continue
|
|
253
|
-
else:
|
|
254
|
-
confirmation = confirmation.strip().lower()
|
|
255
|
-
except TimeoutOccurred:
|
|
256
|
-
logger.info(f"Timeout waiting for confirmation for FULL archive: {archive_name}. Skipping deletion.")
|
|
257
|
-
continue
|
|
258
|
-
except KeyboardInterrupt:
|
|
259
|
-
logger.info(f"User interrupted confirmation for FULL archive: {archive_name}. Skipping deletion.")
|
|
260
|
-
continue
|
|
261
|
-
|
|
262
|
-
if confirmation != 'yes':
|
|
263
|
-
logger.info(f"User did not answer 'yes' to confirm deletion of FULL archive: {archive_name}. Skipping deletion.")
|
|
254
|
+
if not confirm_full_archive_deletion(archive_name, args.test_mode):
|
|
264
255
|
continue
|
|
265
256
|
logger.info(f"Deleting archive: {archive_name}")
|
|
266
257
|
delete_archive(config_settings.backup_dir, archive_name.strip(), args)
|
|
@@ -279,19 +270,13 @@ def main():
|
|
|
279
270
|
delete_old_backups(config_settings.backup_dir, config_settings.diff_age, 'DIFF', args, definition)
|
|
280
271
|
delete_old_backups(config_settings.backup_dir, config_settings.incr_age, 'INCR', args, definition)
|
|
281
272
|
|
|
273
|
+
# run POST scripts
|
|
274
|
+
requirements('POSTREQ', config_settings)
|
|
275
|
+
|
|
282
276
|
|
|
283
277
|
end_time=int(time())
|
|
284
278
|
logger.info(f"END TIME: {end_time}")
|
|
285
|
-
|
|
286
|
-
# error_lines = extract_error_lines(config_settings.logfile_location, start_time, end_time)
|
|
287
|
-
# if len(error_lines) > 0:
|
|
288
|
-
# args.verbose and print("\033[1m\033[31mErrors\033[0m encountered")
|
|
289
|
-
# for line in error_lines:
|
|
290
|
-
# args.verbose and print(line)
|
|
291
|
-
# sys.exit(1)
|
|
292
|
-
# else:
|
|
293
|
-
# args.verbose and print("\033[1m\033[32mSUCCESS\033[0m No errors encountered")
|
|
294
|
-
# sys.exit(0)
|
|
279
|
+
sys.exit(0)
|
|
295
280
|
|
|
296
281
|
if __name__ == "__main__":
|
|
297
282
|
main()
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
import logging
|
|
3
|
+
import threading
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../src")))
|
|
7
|
+
from typing import List, Optional
|
|
8
|
+
|
|
9
|
+
class CommandResult:
|
|
10
|
+
def __init__(self, returncode: int, stdout: str, stderr: str):
|
|
11
|
+
self.returncode = returncode
|
|
12
|
+
self.stdout = stdout
|
|
13
|
+
self.stderr = stderr
|
|
14
|
+
|
|
15
|
+
def __repr__(self):
|
|
16
|
+
return f"<CommandResult returncode={self.returncode}>"
|
|
17
|
+
|
|
18
|
+
class CommandRunner:
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
logger: Optional[logging.Logger] = None,
|
|
22
|
+
command_logger: Optional[logging.Logger] = None,
|
|
23
|
+
default_timeout: int = 30
|
|
24
|
+
):
|
|
25
|
+
self.logger = logger or logging.getLogger(__name__)
|
|
26
|
+
self.command_logger = command_logger or self.logger
|
|
27
|
+
self.default_timeout = default_timeout
|
|
28
|
+
|
|
29
|
+
def run(
|
|
30
|
+
self,
|
|
31
|
+
cmd: List[str],
|
|
32
|
+
*,
|
|
33
|
+
timeout: Optional[int] = None,
|
|
34
|
+
check: bool = False,
|
|
35
|
+
capture_output: bool = True,
|
|
36
|
+
text: bool = True
|
|
37
|
+
) -> CommandResult:
|
|
38
|
+
timeout = timeout or self.default_timeout
|
|
39
|
+
self.logger.debug(f"Executing command: {' '.join(cmd)} (timeout={timeout}s)")
|
|
40
|
+
|
|
41
|
+
process = subprocess.Popen(
|
|
42
|
+
cmd,
|
|
43
|
+
stdout=subprocess.PIPE if capture_output else None,
|
|
44
|
+
stderr=subprocess.PIPE if capture_output else None,
|
|
45
|
+
text=text,
|
|
46
|
+
bufsize=1
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
stdout_lines = []
|
|
50
|
+
stderr_lines = []
|
|
51
|
+
|
|
52
|
+
def stream_output(stream, lines, level):
|
|
53
|
+
for line in iter(stream.readline, ''):
|
|
54
|
+
lines.append(line)
|
|
55
|
+
self.command_logger.log(level, line.strip())
|
|
56
|
+
stream.close()
|
|
57
|
+
|
|
58
|
+
threads = []
|
|
59
|
+
if capture_output and process.stdout:
|
|
60
|
+
t_out = threading.Thread(target=stream_output, args=(process.stdout, stdout_lines, logging.INFO))
|
|
61
|
+
t_out.start()
|
|
62
|
+
threads.append(t_out)
|
|
63
|
+
if capture_output and process.stderr:
|
|
64
|
+
t_err = threading.Thread(target=stream_output, args=(process.stderr, stderr_lines, logging.ERROR))
|
|
65
|
+
t_err.start()
|
|
66
|
+
threads.append(t_err)
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
process.wait(timeout=timeout)
|
|
70
|
+
except subprocess.TimeoutExpired:
|
|
71
|
+
process.kill()
|
|
72
|
+
self.logger.error(f"Command timed out: {' '.join(cmd)}")
|
|
73
|
+
return CommandResult(-1, ''.join(stdout_lines), ''.join(stderr_lines))
|
|
74
|
+
|
|
75
|
+
for t in threads:
|
|
76
|
+
t.join()
|
|
77
|
+
|
|
78
|
+
if check and process.returncode != 0:
|
|
79
|
+
self.logger.error(f"Command failed with exit code {process.returncode}")
|
|
80
|
+
|
|
81
|
+
return CommandResult(process.returncode, ''.join(stdout_lines), ''.join(stderr_lines))
|
dar_backup/config_settings.py
CHANGED
|
@@ -63,7 +63,13 @@ class ConfigSettings:
|
|
|
63
63
|
self.diff_age = int(self.config['AGE']['DIFF_AGE'])
|
|
64
64
|
self.incr_age = int(self.config['AGE']['INCR_AGE'])
|
|
65
65
|
self.error_correction_percent = int(self.config['PAR2']['ERROR_CORRECTION_PERCENT'])
|
|
66
|
-
|
|
66
|
+
val = self.config['PAR2']['ENABLED'].strip().lower()
|
|
67
|
+
if val in ('true', '1', 'yes'):
|
|
68
|
+
self.par2_enabled = True
|
|
69
|
+
elif val in ('false', '0', 'no'):
|
|
70
|
+
self.par2_enabled = False
|
|
71
|
+
else:
|
|
72
|
+
raise ValueError(f"Invalid boolean value for 'ENABLED' in [PAR2]: '{val}'")
|
|
67
73
|
|
|
68
74
|
# Ensure the directories exist
|
|
69
75
|
Path(self.backup_dir).mkdir(parents=True, exist_ok=True)
|
dar_backup/dar-backup.conf
CHANGED
|
@@ -9,7 +9,7 @@ MIN_SIZE_VERIFICATION_MB = 1
|
|
|
9
9
|
NO_FILES_VERIFICATION = 5
|
|
10
10
|
# timeout in seconds for backup, test, restore and par2 operations
|
|
11
11
|
# The author has such `dar` tasks running for 10-15 hours on the yearly backups, so a value of 24 hours is used.
|
|
12
|
-
# If a timeout is not specified when using the
|
|
12
|
+
# If a timeout is not specified when using the CommandRunner, a default timeout of 30 secs is used.
|
|
13
13
|
COMMAND_TIMEOUT_SECS = 86400
|
|
14
14
|
|
|
15
15
|
[DIRECTORIES]
|
dar_backup/dar_backup.py
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
|
|
3
|
+
"""
|
|
4
|
+
installer.py source code is here: https://github.com/per2jensen/dar-backup/tree/main/v2/src/dar_backup/installer.py
|
|
5
|
+
This script is part of dar-backup, a backup solution for Linux using dar and systemd.
|
|
6
|
+
|
|
7
|
+
Licensed under GNU GENERAL PUBLIC LICENSE v3, see the supplied file "LICENSE" for details.
|
|
8
|
+
|
|
9
|
+
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW,
|
|
10
|
+
not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
|
11
|
+
See section 15 and section 16 in the supplied "LICENSE" file
|
|
12
|
+
|
|
13
|
+
This script can be used to control `dar` to backup parts of or the whole system.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
|
|
3
17
|
import argparse
|
|
4
18
|
import filecmp
|
|
5
19
|
|
|
@@ -11,6 +25,7 @@ import shutil
|
|
|
11
25
|
import subprocess
|
|
12
26
|
import xml.etree.ElementTree as ET
|
|
13
27
|
import tempfile
|
|
28
|
+
import threading
|
|
14
29
|
|
|
15
30
|
from argparse import ArgumentParser
|
|
16
31
|
from datetime import datetime
|
|
@@ -20,19 +35,26 @@ from sys import stderr
|
|
|
20
35
|
from sys import argv
|
|
21
36
|
from sys import version_info
|
|
22
37
|
from time import time
|
|
38
|
+
from threading import Event
|
|
23
39
|
from typing import List
|
|
24
40
|
|
|
25
41
|
from . import __about__ as about
|
|
26
42
|
from dar_backup.config_settings import ConfigSettings
|
|
27
43
|
from dar_backup.util import list_backups
|
|
28
|
-
from dar_backup.util import run_command
|
|
29
44
|
from dar_backup.util import setup_logging
|
|
30
45
|
from dar_backup.util import get_logger
|
|
31
46
|
from dar_backup.util import BackupError
|
|
32
47
|
from dar_backup.util import RestoreError
|
|
48
|
+
from dar_backup.util import requirements
|
|
49
|
+
|
|
50
|
+
from dar_backup.command_runner import CommandRunner
|
|
51
|
+
from dar_backup.command_runner import CommandResult
|
|
52
|
+
|
|
53
|
+
from dar_backup.rich_progress import show_log_driven_bar
|
|
33
54
|
|
|
34
55
|
|
|
35
56
|
logger = None
|
|
57
|
+
runner = None
|
|
36
58
|
|
|
37
59
|
def generic_backup(type: str, command: List[str], backup_file: str, backup_definition: str, darrc: str, config_settings: ConfigSettings, args: argparse.Namespace) -> List[str]:
|
|
38
60
|
"""
|
|
@@ -63,9 +85,32 @@ def generic_backup(type: str, command: List[str], backup_file: str, backup_defin
|
|
|
63
85
|
result: List[tuple] = []
|
|
64
86
|
|
|
65
87
|
logger.info(f"===> Starting {type} backup for {backup_definition}")
|
|
66
|
-
logger.info(f"Running command: {' '.join(map(shlex.quote, command))}")
|
|
67
88
|
try:
|
|
68
|
-
|
|
89
|
+
log_basename = os.path. dirname(config_settings.logfile_location)
|
|
90
|
+
logfile = os.path.basename(config_settings.logfile_location)[:-4] + "-commands.log"
|
|
91
|
+
log_path = os.path.join( log_basename, logfile)
|
|
92
|
+
logger.debug(f"Commands log file: {log_path}")
|
|
93
|
+
|
|
94
|
+
# wrap a progress bar around the dar command
|
|
95
|
+
stop_event = Event()
|
|
96
|
+
session_marker = f"=== START BACKUP SESSION: {int(time())} ==="
|
|
97
|
+
get_logger(command_output_logger=True).info(session_marker)
|
|
98
|
+
|
|
99
|
+
progress_thread = threading.Thread(
|
|
100
|
+
target=show_log_driven_bar,
|
|
101
|
+
args=(log_path, stop_event, session_marker),
|
|
102
|
+
daemon=True
|
|
103
|
+
)
|
|
104
|
+
progress_thread.start()
|
|
105
|
+
try:
|
|
106
|
+
process = runner.run(command, timeout = config_settings.command_timeout_secs)
|
|
107
|
+
except Exception as e:
|
|
108
|
+
print(f"[!] Backup failed: {e}")
|
|
109
|
+
raise
|
|
110
|
+
finally:
|
|
111
|
+
stop_event.set()
|
|
112
|
+
progress_thread.join()
|
|
113
|
+
|
|
69
114
|
if process.returncode == 0:
|
|
70
115
|
logger.info(f"{type} backup completed successfully.")
|
|
71
116
|
elif process.returncode == 5:
|
|
@@ -75,7 +120,7 @@ def generic_backup(type: str, command: List[str], backup_file: str, backup_defin
|
|
|
75
120
|
|
|
76
121
|
if process.returncode == 0 or process.returncode == 5:
|
|
77
122
|
add_catalog_command = ['manager', '--add-specific-archive' ,backup_file, '--config-file', args.config_file]
|
|
78
|
-
command_result =
|
|
123
|
+
command_result = runner.run(add_catalog_command, timeout = config_settings.command_timeout_secs)
|
|
79
124
|
if command_result.returncode == 0:
|
|
80
125
|
logger.info(f"Catalog for archive '{backup_file}' added successfully to its manager.")
|
|
81
126
|
else:
|
|
@@ -192,8 +237,33 @@ def verify(args: argparse.Namespace, backup_file: str, backup_definition: str, c
|
|
|
192
237
|
"""
|
|
193
238
|
result = True
|
|
194
239
|
command = ['dar', '-t', backup_file, '-Q']
|
|
195
|
-
|
|
196
|
-
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
log_basename = os.path. dirname(config_settings.logfile_location)
|
|
243
|
+
logfile = os.path.basename(config_settings.logfile_location)[:-4] + "-commands.log"
|
|
244
|
+
log_path = os.path.join( log_basename, logfile)
|
|
245
|
+
|
|
246
|
+
# wrap a progress bar around the dar command
|
|
247
|
+
stop_event = Event()
|
|
248
|
+
session_marker = f"=== START BACKUP SESSION: {int(time())} ==="
|
|
249
|
+
get_logger(command_output_logger=True).info(session_marker)
|
|
250
|
+
|
|
251
|
+
progress_thread = threading.Thread(
|
|
252
|
+
target=show_log_driven_bar,
|
|
253
|
+
args=(log_path, stop_event, session_marker),
|
|
254
|
+
daemon=True
|
|
255
|
+
)
|
|
256
|
+
progress_thread.start()
|
|
257
|
+
try:
|
|
258
|
+
process = runner.run(command, timeout = config_settings.command_timeout_secs)
|
|
259
|
+
except Exception as e:
|
|
260
|
+
print(f"[!] Backup failed: {e}")
|
|
261
|
+
raise
|
|
262
|
+
finally:
|
|
263
|
+
stop_event.set()
|
|
264
|
+
progress_thread.join()
|
|
265
|
+
|
|
266
|
+
|
|
197
267
|
if process.returncode == 0:
|
|
198
268
|
logger.info("Archive integrity test passed.")
|
|
199
269
|
else:
|
|
@@ -235,7 +305,7 @@ def verify(args: argparse.Namespace, backup_file: str, backup_definition: str, c
|
|
|
235
305
|
args.verbose and logger.info(f"Restoring file: '{restored_file_path}' from backup to: '{config_settings.test_restore_dir}' for file comparing")
|
|
236
306
|
command = ['dar', '-x', backup_file, '-g', restored_file_path.lstrip("/"), '-R', config_settings.test_restore_dir, '-Q', '-B', args.darrc, 'restore-options']
|
|
237
307
|
args.verbose and logger.info(f"Running command: {' '.join(map(shlex.quote, command))}")
|
|
238
|
-
process =
|
|
308
|
+
process = runner.run(command, timeout = config_settings.command_timeout_secs)
|
|
239
309
|
if process.returncode != 0:
|
|
240
310
|
raise Exception(str(process))
|
|
241
311
|
|
|
@@ -276,7 +346,7 @@ def restore_backup(backup_name: str, config_settings: ConfigSettings, restore_di
|
|
|
276
346
|
command.extend(selection_criteria)
|
|
277
347
|
command.extend(['-B', darrc, 'restore-options']) # the .darrc `restore-options` section
|
|
278
348
|
logger.info(f"Running restore command: {' '.join(map(shlex.quote, command))}")
|
|
279
|
-
process =
|
|
349
|
+
process = runner.run(command, timeout = config_settings.command_timeout_secs)
|
|
280
350
|
if process.returncode == 0:
|
|
281
351
|
logger.info(f"Restore completed successfully to: '{restore_dir}'")
|
|
282
352
|
else:
|
|
@@ -309,7 +379,7 @@ def get_backed_up_files(backup_name: str, backup_dir: str):
|
|
|
309
379
|
try:
|
|
310
380
|
command = ['dar', '-l', backup_path, '-am', '-as', "-Txml" , '-Q']
|
|
311
381
|
logger.debug(f"Running command: {' '.join(map(shlex.quote, command))}")
|
|
312
|
-
command_result =
|
|
382
|
+
command_result = runner.run(command)
|
|
313
383
|
# Parse the XML data
|
|
314
384
|
file_paths = find_files_with_paths(command_result.stdout)
|
|
315
385
|
return file_paths
|
|
@@ -339,8 +409,7 @@ def list_contents(backup_name, backup_dir, selection=None):
|
|
|
339
409
|
if selection:
|
|
340
410
|
selection_criteria = shlex.split(selection)
|
|
341
411
|
command.extend(selection_criteria)
|
|
342
|
-
|
|
343
|
-
process = run_command(command)
|
|
412
|
+
process = runner.run(command)
|
|
344
413
|
stdout,stderr = process.stdout, process.stderr
|
|
345
414
|
if process.returncode != 0:
|
|
346
415
|
logger.error(f"Error listing contents of backup: '{backup_name}'")
|
|
@@ -402,7 +471,8 @@ def perform_backup(args: argparse.Namespace, config_settings: ConfigSettings, ba
|
|
|
402
471
|
if '_' in args.backup_definition:
|
|
403
472
|
msg = f"Skipping backup definition: '{args.backup_definition}' due to '_' in name"
|
|
404
473
|
logger.error(msg)
|
|
405
|
-
|
|
474
|
+
results.append((msg, 1))
|
|
475
|
+
return results
|
|
406
476
|
backup_definitions.append((os.path.basename(args.backup_definition).split('.')[0], os.path.join(config_settings.backup_d_dir, args.backup_definition)))
|
|
407
477
|
else:
|
|
408
478
|
for root, _, files in os.walk(config_settings.backup_d_dir):
|
|
@@ -512,7 +582,7 @@ def generate_par2_files(backup_file: str, config_settings: ConfigSettings, args)
|
|
|
512
582
|
|
|
513
583
|
# Run the par2 command to generate redundancy files with error correction
|
|
514
584
|
command = ['par2', 'create', f'-r{config_settings.error_correction_percent}', '-q', '-q', file_path]
|
|
515
|
-
process =
|
|
585
|
+
process = runner.run(command, timeout = config_settings.command_timeout_secs)
|
|
516
586
|
|
|
517
587
|
if process.returncode == 0:
|
|
518
588
|
logger.info(f"{counter}/{number_of_slices}: Done")
|
|
@@ -620,45 +690,57 @@ INCR back of a single backup definition in backup.d
|
|
|
620
690
|
|
|
621
691
|
|
|
622
692
|
|
|
623
|
-
def
|
|
693
|
+
def print_markdown(source: str, from_string: bool = False, pretty: bool = True):
|
|
624
694
|
"""
|
|
625
|
-
|
|
626
|
-
|
|
695
|
+
Print Markdown content either from a file or directly from a string.
|
|
696
|
+
|
|
627
697
|
Args:
|
|
628
|
-
|
|
629
|
-
|
|
698
|
+
source: Path to the file or Markdown string itself.
|
|
699
|
+
from_string: If True, treat `source` as Markdown string instead of file path.
|
|
700
|
+
pretty: If True, render with rich formatting if available.
|
|
701
|
+
"""
|
|
702
|
+
import os
|
|
703
|
+
import sys
|
|
630
704
|
|
|
631
|
-
|
|
632
|
-
|
|
705
|
+
content = ""
|
|
706
|
+
if from_string:
|
|
707
|
+
content = source
|
|
708
|
+
else:
|
|
709
|
+
if not os.path.exists(source):
|
|
710
|
+
print(f"❌ File not found: {source}")
|
|
711
|
+
sys.exit(1)
|
|
712
|
+
with open(source, "r", encoding="utf-8") as f:
|
|
713
|
+
content = f.read()
|
|
714
|
+
|
|
715
|
+
if pretty:
|
|
716
|
+
try:
|
|
717
|
+
from rich.console import Console
|
|
718
|
+
from rich.markdown import Markdown
|
|
719
|
+
console = Console()
|
|
720
|
+
console.print(Markdown(content))
|
|
721
|
+
except ImportError:
|
|
722
|
+
print("⚠️ 'rich' not installed. Falling back to plain text.\n")
|
|
723
|
+
print(content)
|
|
724
|
+
else:
|
|
725
|
+
print(content)
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def print_changelog(path: str = None, pretty: bool = True):
|
|
730
|
+
if path is None:
|
|
731
|
+
path = Path(__file__).parent / "Changelog.md"
|
|
732
|
+
print_markdown(str(path), pretty=pretty)
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def print_readme(path: str = None, pretty: bool = True):
|
|
736
|
+
if path is None:
|
|
737
|
+
path = Path(__file__).parent / "README.md"
|
|
738
|
+
print_markdown(str(path), pretty=pretty)
|
|
633
739
|
|
|
634
|
-
subprocess.CalledProcessError: if CalledProcessError is raised in subprocess.run(), let it bobble up.
|
|
635
|
-
"""
|
|
636
|
-
if type is None or config_setting is None:
|
|
637
|
-
raise RuntimeError(f"requirements: 'type' or config_setting is None")
|
|
638
|
-
|
|
639
|
-
allowed_types = ['PREREQ', 'POSTREQ']
|
|
640
|
-
if type not in allowed_types:
|
|
641
|
-
raise RuntimeError(f"requirements: {type} not in: {allowed_types}")
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
logger.info(f"Performing {type}")
|
|
645
|
-
if type in config_setting.config:
|
|
646
|
-
for key in sorted(config_setting.config[type].keys()):
|
|
647
|
-
script = config_setting.config[type][key]
|
|
648
|
-
try:
|
|
649
|
-
result = subprocess.run(script, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, shell=True, check=True)
|
|
650
|
-
logger.debug(f"{type} {key}: '{script}' run, return code: {result.returncode}")
|
|
651
|
-
logger.debug(f"{type} stdout:\n{result.stdout}")
|
|
652
|
-
if result.returncode != 0:
|
|
653
|
-
logger.error(f"{type} stderr:\n{result.stderr}")
|
|
654
|
-
raise RuntimeError(f"{type} {key}: '{script}' failed, return code: {result.returncode}")
|
|
655
|
-
except subprocess.CalledProcessError as e:
|
|
656
|
-
logger.error(f"Error executing {key}: '{script}': {e}")
|
|
657
|
-
raise e
|
|
658
740
|
|
|
659
741
|
|
|
660
742
|
def main():
|
|
661
|
-
global logger
|
|
743
|
+
global logger, runner
|
|
662
744
|
results: List[(str,int)] = [] # a list op tuples (<msg>, <exit code>)
|
|
663
745
|
|
|
664
746
|
MIN_PYTHON_VERSION = (3, 9)
|
|
@@ -674,7 +756,6 @@ def main():
|
|
|
674
756
|
parser.add_argument('--alternate-reference-archive', help="DIFF or INCR compared to specified archive.")
|
|
675
757
|
parser.add_argument('-c', '--config-file', type=str, help="Path to 'dar-backup.conf'", default='~/.config/dar-backup/dar-backup.conf')
|
|
676
758
|
parser.add_argument('--darrc', type=str, help='Optional path to .darrc')
|
|
677
|
-
parser.add_argument('--examples', action="store_true", help="Examples of using dar-backup.py.")
|
|
678
759
|
parser.add_argument('-l', '--list', action='store_true', help="List available archives.")
|
|
679
760
|
parser.add_argument('--list-contents', help="List the contents of the specified archive.")
|
|
680
761
|
parser.add_argument('--selection', help="dar file selection for listing/restoring specific files/directories.")
|
|
@@ -686,6 +767,11 @@ def main():
|
|
|
686
767
|
parser.add_argument('--log-level', type=str, help="`debug` or `trace`", default="info")
|
|
687
768
|
parser.add_argument('--log-stdout', action='store_true', help='also print log messages to stdout')
|
|
688
769
|
parser.add_argument('--do-not-compare', action='store_true', help="do not compare restores to file system")
|
|
770
|
+
parser.add_argument('--examples', action="store_true", help="Examples of using dar-backup.py.")
|
|
771
|
+
parser.add_argument("--readme", action="store_true", help="Print README.md to stdout and exit.")
|
|
772
|
+
parser.add_argument("--readme-pretty", action="store_true", help="Print README.md to stdout with Markdown styling and exit.")
|
|
773
|
+
parser.add_argument("--changelog", action="store_true", help="Print Changelog.md to stdout and exit.")
|
|
774
|
+
parser.add_argument("--changelog-pretty", action="store_true", help="Print Changelog.md to stdout with Markdown styling and exit.")
|
|
689
775
|
parser.add_argument('-v', '--version', action='store_true', help="Show version and license information.")
|
|
690
776
|
args = parser.parse_args()
|
|
691
777
|
|
|
@@ -695,6 +781,20 @@ def main():
|
|
|
695
781
|
elif args.examples:
|
|
696
782
|
show_examples()
|
|
697
783
|
exit(0)
|
|
784
|
+
elif args.readme:
|
|
785
|
+
print_readme(None, pretty=False)
|
|
786
|
+
exit(0)
|
|
787
|
+
elif args.readme_pretty:
|
|
788
|
+
print_readme(None, pretty=True)
|
|
789
|
+
exit(0)
|
|
790
|
+
elif args.changelog:
|
|
791
|
+
print_changelog(None, pretty=False)
|
|
792
|
+
exit(0)
|
|
793
|
+
elif args.changelog_pretty:
|
|
794
|
+
print_changelog(None, pretty=True)
|
|
795
|
+
exit(0)
|
|
796
|
+
|
|
797
|
+
|
|
698
798
|
|
|
699
799
|
if not args.config_file:
|
|
700
800
|
print(f"Config file not specified, exiting", file=stderr)
|
|
@@ -713,6 +813,9 @@ def main():
|
|
|
713
813
|
print(f"Error: logfile_location in {args.config_file} does not end at 'dar-backup.log', exiting", file=stderr)
|
|
714
814
|
|
|
715
815
|
logger = setup_logging(config_settings.logfile_location, command_output_log, args.log_level, args.log_stdout)
|
|
816
|
+
command_logger = get_logger(command_output_logger = True)
|
|
817
|
+
runner = CommandRunner(logger=logger, command_logger=command_logger)
|
|
818
|
+
|
|
716
819
|
|
|
717
820
|
try:
|
|
718
821
|
if not args.darrc:
|
|
@@ -741,9 +844,9 @@ def main():
|
|
|
741
844
|
file_dir = os.path.normpath(os.path.dirname(__file__))
|
|
742
845
|
args.verbose and (print(f"Script directory: {file_dir}"))
|
|
743
846
|
args.verbose and (print(f"Config file: {args.config_file}"))
|
|
744
|
-
args.verbose and args.full_backup and (print(f"Type of backup:
|
|
745
|
-
args.verbose and args.differential_backup and (print(f"Type of backup:
|
|
746
|
-
args.verbose and args.incremental_backup and (print(f"Type of backup:
|
|
847
|
+
args.verbose and args.full_backup and (print(f"Type of backup: FULL"))
|
|
848
|
+
args.verbose and args.differential_backup and (print(f"Type of backup: DIFF"))
|
|
849
|
+
args.verbose and args.incremental_backup and (print(f"Type of backup: INCR"))
|
|
747
850
|
args.verbose and args.backup_definition and (print(f"Backup definition: '{args.backup_definition}'"))
|
|
748
851
|
if args.alternate_reference_archive:
|
|
749
852
|
args.verbose and (print(f"Alternate ref archive: {args.alternate_reference_archive}"))
|