dar-backup 0.6.17__py3-none-any.whl → 0.6.19__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 +232 -0
- dar_backup/README.md +1117 -0
- dar_backup/__about__.py +1 -1
- dar_backup/clean_log.py +14 -7
- dar_backup/cleanup.py +41 -43
- dar_backup/command_runner.py +59 -9
- dar_backup/dar_backup.py +156 -41
- dar_backup/dar_backup_systemd.py +119 -0
- dar_backup/installer.py +39 -23
- dar_backup/manager.py +210 -89
- dar_backup/rich_progress.py +101 -0
- dar_backup/util.py +289 -46
- {dar_backup-0.6.17.dist-info → dar_backup-0.6.19.dist-info}/METADATA +212 -27
- dar_backup-0.6.19.dist-info/RECORD +21 -0
- {dar_backup-0.6.17.dist-info → dar_backup-0.6.19.dist-info}/entry_points.txt +1 -0
- dar_backup-0.6.17.dist-info/RECORD +0 -17
- {dar_backup-0.6.17.dist-info → dar_backup-0.6.19.dist-info}/WHEEL +0 -0
- {dar_backup-0.6.17.dist-info → dar_backup-0.6.19.dist-info}/licenses/LICENSE +0 -0
dar_backup/__about__.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "0.6.
|
|
1
|
+
__version__ = "0.6.19"
|
dar_backup/clean_log.py
CHANGED
|
@@ -11,6 +11,8 @@ See section 15 and section 16 in the supplied "LICENSE" file
|
|
|
11
11
|
|
|
12
12
|
This script can be used to remove (much of) the logged output from `dar`.
|
|
13
13
|
When `dar` verbose options are enabled, quite a lot of information is emitted.
|
|
14
|
+
|
|
15
|
+
If a rerex is matched, the entire line is removed (change in v2-beta-0.6.19).
|
|
14
16
|
"""
|
|
15
17
|
|
|
16
18
|
|
|
@@ -19,7 +21,7 @@ import re
|
|
|
19
21
|
import os
|
|
20
22
|
import sys
|
|
21
23
|
|
|
22
|
-
from
|
|
24
|
+
from dar_backup import __about__ as about
|
|
23
25
|
from dar_backup.config_settings import ConfigSettings
|
|
24
26
|
|
|
25
27
|
LICENSE = '''Licensed under GNU GENERAL PUBLIC LICENSE v3, see the supplied file "LICENSE" for details.
|
|
@@ -32,7 +34,7 @@ def clean_log_file(log_file_path, dry_run=False):
|
|
|
32
34
|
|
|
33
35
|
if not os.path.isfile(log_file_path):
|
|
34
36
|
print(f"File '{log_file_path}' not found!")
|
|
35
|
-
sys.exit(
|
|
37
|
+
sys.exit(127)
|
|
36
38
|
|
|
37
39
|
if not os.access(log_file_path, os.R_OK):
|
|
38
40
|
print(f"No read permission for '{log_file_path}'")
|
|
@@ -49,13 +51,18 @@ def clean_log_file(log_file_path, dry_run=False):
|
|
|
49
51
|
temp_file_path = log_file_path + ".tmp"
|
|
50
52
|
|
|
51
53
|
patterns = [
|
|
54
|
+
r"INFO\s*-\s*Inspecting\s*directory",
|
|
55
|
+
r"INFO\s*-\s*Finished\s*Inspecting",
|
|
52
56
|
r"INFO\s*-\s*<File",
|
|
57
|
+
r"INFO\s*-\s*</File",
|
|
53
58
|
r"INFO\s*-\s*<Attributes",
|
|
59
|
+
r"INFO\s*-\s*</Attributes",
|
|
54
60
|
r"INFO\s*-\s*</Directory",
|
|
55
61
|
r"INFO\s*-\s*<Directory",
|
|
56
|
-
r"INFO\s*-\s
|
|
57
|
-
r"INFO\s*-\s
|
|
58
|
-
r"INFO\s*-\s
|
|
62
|
+
r"INFO\s*-\s*<Catalog",
|
|
63
|
+
r"INFO\s*-\s*</Catalog",
|
|
64
|
+
r"INFO\s*-\s*<Symlink",
|
|
65
|
+
r"INFO\s*-\s*</Symlink",
|
|
59
66
|
]
|
|
60
67
|
|
|
61
68
|
try:
|
|
@@ -70,9 +77,9 @@ def clean_log_file(log_file_path, dry_run=False):
|
|
|
70
77
|
if dry_run:
|
|
71
78
|
print(f"Would remove: {original_line.strip()}") # Print full line for dry-run
|
|
72
79
|
matched = True # Mark that a pattern matched
|
|
73
|
-
|
|
80
|
+
break # No need to check other patterns if one matches
|
|
74
81
|
|
|
75
|
-
if not dry_run and
|
|
82
|
+
if not dry_run and not matched: # In normal mode, only write non-empty lines
|
|
76
83
|
outfile.write(line.rstrip() + "\n")
|
|
77
84
|
|
|
78
85
|
if dry_run and matched:
|
dar_backup/cleanup.py
CHANGED
|
@@ -13,6 +13,7 @@ This script removes old DIFF and INCR archives + accompanying .par2 files accord
|
|
|
13
13
|
[AGE] settings in the configuration file.
|
|
14
14
|
"""
|
|
15
15
|
|
|
16
|
+
import argcomplete
|
|
16
17
|
import argparse
|
|
17
18
|
import logging
|
|
18
19
|
import os
|
|
@@ -27,10 +28,11 @@ from typing import Dict, List, NamedTuple
|
|
|
27
28
|
|
|
28
29
|
from . import __about__ as about
|
|
29
30
|
from dar_backup.config_settings import ConfigSettings
|
|
30
|
-
from dar_backup.util import extract_error_lines
|
|
31
31
|
from dar_backup.util import list_backups
|
|
32
32
|
from dar_backup.util import setup_logging
|
|
33
33
|
from dar_backup.util import get_logger
|
|
34
|
+
from dar_backup.util import requirements
|
|
35
|
+
from dar_backup.util import backup_definition_completer, list_archive_completer
|
|
34
36
|
|
|
35
37
|
from dar_backup.command_runner import CommandRunner
|
|
36
38
|
from dar_backup.command_runner import CommandResult
|
|
@@ -158,20 +160,47 @@ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW,
|
|
|
158
160
|
See section 15 and section 16 in the supplied "LICENSE" file.''')
|
|
159
161
|
|
|
160
162
|
|
|
163
|
+
def confirm_full_archive_deletion(archive_name: str, test_mode=False) -> bool:
|
|
164
|
+
try:
|
|
165
|
+
if test_mode:
|
|
166
|
+
confirmation = os.environ.get("CLEANUP_TEST_DELETE_FULL")
|
|
167
|
+
if confirmation is None:
|
|
168
|
+
raise RuntimeError("envvar 'CLEANUP_TEST_DELETE_FULL' not set")
|
|
169
|
+
print(f"Simulated confirmation for FULL archive: {confirmation}")
|
|
170
|
+
else:
|
|
171
|
+
confirmation = inputimeout(
|
|
172
|
+
prompt=f"Are you sure you want to delete the FULL archive '{archive_name}'? (yes/no): ",
|
|
173
|
+
timeout=30)
|
|
174
|
+
if confirmation is None:
|
|
175
|
+
logger.info(f"No confirmation received for FULL archive: {archive_name}. Skipping deletion.")
|
|
176
|
+
return False
|
|
177
|
+
return confirmation.strip().lower() == "yes"
|
|
178
|
+
except TimeoutOccurred:
|
|
179
|
+
logger.info(f"Timeout waiting for confirmation for FULL archive: {archive_name}. Skipping deletion.")
|
|
180
|
+
return False
|
|
181
|
+
except KeyboardInterrupt:
|
|
182
|
+
logger.info(f"User interrupted confirmation for FULL archive: {archive_name}. Skipping deletion.")
|
|
183
|
+
return False
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
|
|
161
187
|
def main():
|
|
162
188
|
global logger, runner
|
|
163
189
|
|
|
164
190
|
parser = argparse.ArgumentParser(description="Cleanup old archives according to AGE configuration.")
|
|
165
|
-
parser.add_argument('-d', '--backup-definition', help="Specific backup definition to cleanup.")
|
|
191
|
+
parser.add_argument('-d', '--backup-definition', help="Specific backup definition to cleanup.").completer = backup_definition_completer
|
|
166
192
|
parser.add_argument('-c', '--config-file', '-c', type=str, help="Path to 'dar-backup.conf'", default='~/.config/dar-backup/dar-backup.conf')
|
|
167
193
|
parser.add_argument('-v', '--version', action='store_true', help="Show version information.")
|
|
168
194
|
parser.add_argument('--alternate-archive-dir', type=str, help="Cleanup in this directory instead of the default one.")
|
|
169
|
-
parser.add_argument('--cleanup-specific-archives', type=str, help="Comma separated list of archives to cleanup")
|
|
195
|
+
parser.add_argument('--cleanup-specific-archives', type=str, help="Comma separated list of archives to cleanup").completer = list_archive_completer
|
|
170
196
|
parser.add_argument('-l', '--list', action='store_true', help="List available archives.")
|
|
171
197
|
parser.add_argument('--verbose', action='store_true', help="Print various status messages to screen")
|
|
172
198
|
parser.add_argument('--log-level', type=str, help="`debug` or `trace`, default is `info`", default="info")
|
|
173
199
|
parser.add_argument('--log-stdout', action='store_true', help='also print log messages to stdout')
|
|
174
200
|
parser.add_argument('--test-mode', action='store_true', help='Read envvars in order to run some pytest cases')
|
|
201
|
+
|
|
202
|
+
argcomplete.autocomplete(parser)
|
|
203
|
+
|
|
175
204
|
args = parser.parse_args()
|
|
176
205
|
|
|
177
206
|
args.config_file = os.path.expanduser(os.path.expandvars(args.config_file))
|
|
@@ -206,21 +235,9 @@ def main():
|
|
|
206
235
|
args.verbose and (print(f"--alternate-archive-dir: {args.alternate_archive_dir}"))
|
|
207
236
|
args.verbose and (print(f"--cleanup-specific-archives:{args.cleanup_specific_archives}"))
|
|
208
237
|
|
|
209
|
-
# run PREREQ scripts
|
|
210
|
-
if 'PREREQ' in config_settings.config:
|
|
211
|
-
for key in sorted(config_settings.config['PREREQ'].keys()):
|
|
212
|
-
script = config_settings.config['PREREQ'][key]
|
|
213
|
-
try:
|
|
214
|
-
result = subprocess.run(script, shell=True, check=True)
|
|
215
|
-
logger.info(f"PREREQ {key}: '{script}' run, return code: {result.returncode}")
|
|
216
|
-
logger.info(f"PREREQ stdout:\n{result.stdout}")
|
|
217
|
-
except subprocess.CalledProcessError as e:
|
|
218
|
-
logger.error(f"Error executing {key}: '{script}': {e}")
|
|
219
|
-
if result:
|
|
220
|
-
logger.error(f"PREREQ stderr:\n{result.stderr}")
|
|
221
|
-
print(f"Error executing {script}: {e}")
|
|
222
|
-
sys.exit(1)
|
|
223
238
|
|
|
239
|
+
# run PREREQ scripts
|
|
240
|
+
requirements('PREREQ', config_settings)
|
|
224
241
|
|
|
225
242
|
if args.alternate_archive_dir:
|
|
226
243
|
if not os.path.exists(args.alternate_archive_dir):
|
|
@@ -231,37 +248,15 @@ def main():
|
|
|
231
248
|
sys.exit(1)
|
|
232
249
|
config_settings.backup_dir = args.alternate_archive_dir
|
|
233
250
|
|
|
251
|
+
if args.cleanup_specific_archives is None and args.test_mode:
|
|
252
|
+
logger.info("No --cleanup-specific-archives provided; skipping specific archive deletion in test mode.")
|
|
234
253
|
|
|
235
254
|
if args.cleanup_specific_archives:
|
|
236
255
|
logger.info(f"Cleaning up specific archives: {args.cleanup_specific_archives}")
|
|
237
256
|
archive_names = args.cleanup_specific_archives.split(',')
|
|
238
257
|
for archive_name in archive_names:
|
|
239
258
|
if "_FULL_" in archive_name:
|
|
240
|
-
|
|
241
|
-
try:
|
|
242
|
-
# used for pytest cases
|
|
243
|
-
if args.test_mode:
|
|
244
|
-
confirmation = os.environ.get("CLEANUP_TEST_DELETE_FULL")
|
|
245
|
-
if confirmation == None:
|
|
246
|
-
raise RuntimeError("envvar 'CLEANUP_TEST_DELETE_FULL' not set")
|
|
247
|
-
|
|
248
|
-
else:
|
|
249
|
-
confirmation = inputimeout(
|
|
250
|
-
prompt=f"Are you sure you want to delete the FULL archive '{archive_name}'? (yes/no): ",
|
|
251
|
-
timeout=30)
|
|
252
|
-
if confirmation == None:
|
|
253
|
-
continue
|
|
254
|
-
else:
|
|
255
|
-
confirmation = confirmation.strip().lower()
|
|
256
|
-
except TimeoutOccurred:
|
|
257
|
-
logger.info(f"Timeout waiting for confirmation for FULL archive: {archive_name}. Skipping deletion.")
|
|
258
|
-
continue
|
|
259
|
-
except KeyboardInterrupt:
|
|
260
|
-
logger.info(f"User interrupted confirmation for FULL archive: {archive_name}. Skipping deletion.")
|
|
261
|
-
continue
|
|
262
|
-
|
|
263
|
-
if confirmation != 'yes':
|
|
264
|
-
logger.info(f"User did not answer 'yes' to confirm deletion of FULL archive: {archive_name}. Skipping deletion.")
|
|
259
|
+
if not confirm_full_archive_deletion(archive_name, args.test_mode):
|
|
265
260
|
continue
|
|
266
261
|
logger.info(f"Deleting archive: {archive_name}")
|
|
267
262
|
delete_archive(config_settings.backup_dir, archive_name.strip(), args)
|
|
@@ -280,10 +275,13 @@ def main():
|
|
|
280
275
|
delete_old_backups(config_settings.backup_dir, config_settings.diff_age, 'DIFF', args, definition)
|
|
281
276
|
delete_old_backups(config_settings.backup_dir, config_settings.incr_age, 'INCR', args, definition)
|
|
282
277
|
|
|
278
|
+
# run POST scripts
|
|
279
|
+
requirements('POSTREQ', config_settings)
|
|
280
|
+
|
|
283
281
|
|
|
284
282
|
end_time=int(time())
|
|
285
283
|
logger.info(f"END TIME: {end_time}")
|
|
286
|
-
|
|
284
|
+
sys.exit(0)
|
|
287
285
|
|
|
288
286
|
if __name__ == "__main__":
|
|
289
287
|
main()
|
dar_backup/command_runner.py
CHANGED
|
@@ -3,8 +3,11 @@ import logging
|
|
|
3
3
|
import threading
|
|
4
4
|
import os
|
|
5
5
|
import sys
|
|
6
|
+
import tempfile
|
|
6
7
|
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../src")))
|
|
7
8
|
from typing import List, Optional
|
|
9
|
+
from dar_backup.util import get_logger
|
|
10
|
+
|
|
8
11
|
|
|
9
12
|
class CommandResult:
|
|
10
13
|
def __init__(self, returncode: int, stdout: str, stderr: str):
|
|
@@ -15,6 +18,7 @@ class CommandResult:
|
|
|
15
18
|
def __repr__(self):
|
|
16
19
|
return f"<CommandResult returncode={self.returncode}>"
|
|
17
20
|
|
|
21
|
+
|
|
18
22
|
class CommandRunner:
|
|
19
23
|
def __init__(
|
|
20
24
|
self,
|
|
@@ -22,10 +26,41 @@ class CommandRunner:
|
|
|
22
26
|
command_logger: Optional[logging.Logger] = None,
|
|
23
27
|
default_timeout: int = 30
|
|
24
28
|
):
|
|
25
|
-
self.logger = logger or
|
|
26
|
-
self.command_logger = command_logger or
|
|
29
|
+
self.logger = logger or get_logger()
|
|
30
|
+
self.command_logger = command_logger or get_logger(command_output_logger=True)
|
|
27
31
|
self.default_timeout = default_timeout
|
|
28
32
|
|
|
33
|
+
if not self.logger or not self.command_logger:
|
|
34
|
+
self.logger_fallback()
|
|
35
|
+
|
|
36
|
+
def logger_fallback(self):
|
|
37
|
+
"""
|
|
38
|
+
Setup temporary log files
|
|
39
|
+
"""
|
|
40
|
+
main_log = tempfile.NamedTemporaryFile(delete=False)
|
|
41
|
+
command_log = tempfile.NamedTemporaryFile(delete=False)
|
|
42
|
+
|
|
43
|
+
logger = logging.getLogger("command_runner_fallback_main_logger")
|
|
44
|
+
command_logger = logging.getLogger("command_runner_fallback_command_logger")
|
|
45
|
+
logger.setLevel(logging.DEBUG)
|
|
46
|
+
command_logger.setLevel(logging.DEBUG)
|
|
47
|
+
|
|
48
|
+
main_handler = logging.FileHandler(main_log.name)
|
|
49
|
+
command_handler = logging.FileHandler(command_log.name)
|
|
50
|
+
|
|
51
|
+
logger.addHandler(main_handler)
|
|
52
|
+
command_logger.addHandler(command_handler)
|
|
53
|
+
|
|
54
|
+
self.logger = logger
|
|
55
|
+
self.command_logger = command_logger
|
|
56
|
+
self.default_timeout = 30
|
|
57
|
+
self.logger.info("CommandRunner initialized with fallback loggers")
|
|
58
|
+
self.command_logger.info("CommandRunner initialized with fallback loggers")
|
|
59
|
+
|
|
60
|
+
print(f"[WARN] Using fallback loggers:\n Main log: {main_log.name}\n Command log: {command_log.name}", file=sys.stderr)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
|
|
29
64
|
def run(
|
|
30
65
|
self,
|
|
31
66
|
cmd: List[str],
|
|
@@ -36,24 +71,39 @@ class CommandRunner:
|
|
|
36
71
|
text: bool = True
|
|
37
72
|
) -> CommandResult:
|
|
38
73
|
timeout = timeout or self.default_timeout
|
|
39
|
-
|
|
74
|
+
|
|
75
|
+
#log the command to be executed
|
|
76
|
+
command = f"Executing command: {' '.join(cmd)} (timeout={timeout}s)"
|
|
77
|
+
self.command_logger.info(command) # log to command logger
|
|
78
|
+
self.logger.debug(command) # log to main logger if "--log-level debug"
|
|
40
79
|
|
|
41
80
|
process = subprocess.Popen(
|
|
42
81
|
cmd,
|
|
43
82
|
stdout=subprocess.PIPE if capture_output else None,
|
|
44
83
|
stderr=subprocess.PIPE if capture_output else None,
|
|
45
|
-
text=
|
|
46
|
-
bufsize
|
|
84
|
+
text=False,
|
|
85
|
+
bufsize=-1
|
|
47
86
|
)
|
|
48
87
|
|
|
49
88
|
stdout_lines = []
|
|
50
89
|
stderr_lines = []
|
|
51
90
|
|
|
91
|
+
|
|
52
92
|
def stream_output(stream, lines, level):
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
93
|
+
try:
|
|
94
|
+
while True:
|
|
95
|
+
chunk = stream.read(1024)
|
|
96
|
+
if not chunk:
|
|
97
|
+
break
|
|
98
|
+
decoded = chunk.decode('utf-8', errors='replace')
|
|
99
|
+
lines.append(decoded)
|
|
100
|
+
self.command_logger.log(level, decoded.strip())
|
|
101
|
+
except Exception as e:
|
|
102
|
+
self.logger.warning(f"stream_output decode error: {e}")
|
|
103
|
+
finally:
|
|
104
|
+
stream.close()
|
|
105
|
+
|
|
106
|
+
|
|
57
107
|
|
|
58
108
|
threads = []
|
|
59
109
|
if capture_output and process.stdout:
|
dar_backup/dar_backup.py
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
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
|
+
|
|
17
|
+
|
|
18
|
+
import argcomplete
|
|
3
19
|
import argparse
|
|
4
20
|
import filecmp
|
|
5
21
|
|
|
@@ -11,6 +27,7 @@ import shutil
|
|
|
11
27
|
import subprocess
|
|
12
28
|
import xml.etree.ElementTree as ET
|
|
13
29
|
import tempfile
|
|
30
|
+
import threading
|
|
14
31
|
|
|
15
32
|
from argparse import ArgumentParser
|
|
16
33
|
from datetime import datetime
|
|
@@ -20,6 +37,7 @@ from sys import stderr
|
|
|
20
37
|
from sys import argv
|
|
21
38
|
from sys import version_info
|
|
22
39
|
from time import time
|
|
40
|
+
from threading import Event
|
|
23
41
|
from typing import List
|
|
24
42
|
|
|
25
43
|
from . import __about__ as about
|
|
@@ -29,10 +47,16 @@ from dar_backup.util import setup_logging
|
|
|
29
47
|
from dar_backup.util import get_logger
|
|
30
48
|
from dar_backup.util import BackupError
|
|
31
49
|
from dar_backup.util import RestoreError
|
|
50
|
+
from dar_backup.util import requirements
|
|
51
|
+
from dar_backup.util import get_binary_info
|
|
52
|
+
from dar_backup.util import backup_definition_completer, list_archive_completer
|
|
32
53
|
|
|
33
54
|
from dar_backup.command_runner import CommandRunner
|
|
34
55
|
from dar_backup.command_runner import CommandResult
|
|
35
56
|
|
|
57
|
+
from dar_backup.rich_progress import show_log_driven_bar
|
|
58
|
+
|
|
59
|
+
from argcomplete.completers import FilesCompleter
|
|
36
60
|
|
|
37
61
|
logger = None
|
|
38
62
|
runner = None
|
|
@@ -67,7 +91,31 @@ def generic_backup(type: str, command: List[str], backup_file: str, backup_defin
|
|
|
67
91
|
|
|
68
92
|
logger.info(f"===> Starting {type} backup for {backup_definition}")
|
|
69
93
|
try:
|
|
70
|
-
|
|
94
|
+
log_basename = os.path. dirname(config_settings.logfile_location)
|
|
95
|
+
logfile = os.path.basename(config_settings.logfile_location)[:-4] + "-commands.log"
|
|
96
|
+
log_path = os.path.join( log_basename, logfile)
|
|
97
|
+
logger.debug(f"Commands log file: {log_path}")
|
|
98
|
+
|
|
99
|
+
# wrap a progress bar around the dar command
|
|
100
|
+
stop_event = Event()
|
|
101
|
+
session_marker = f"=== START BACKUP SESSION: {int(time())} ==="
|
|
102
|
+
get_logger(command_output_logger=True).info(session_marker)
|
|
103
|
+
|
|
104
|
+
progress_thread = threading.Thread(
|
|
105
|
+
target=show_log_driven_bar,
|
|
106
|
+
args=(log_path, stop_event, session_marker),
|
|
107
|
+
daemon=True
|
|
108
|
+
)
|
|
109
|
+
progress_thread.start()
|
|
110
|
+
try:
|
|
111
|
+
process = runner.run(command, timeout = config_settings.command_timeout_secs)
|
|
112
|
+
except Exception as e:
|
|
113
|
+
print(f"[!] Backup failed: {e}")
|
|
114
|
+
raise
|
|
115
|
+
finally:
|
|
116
|
+
stop_event.set()
|
|
117
|
+
progress_thread.join()
|
|
118
|
+
|
|
71
119
|
if process.returncode == 0:
|
|
72
120
|
logger.info(f"{type} backup completed successfully.")
|
|
73
121
|
elif process.returncode == 5:
|
|
@@ -194,7 +242,33 @@ def verify(args: argparse.Namespace, backup_file: str, backup_definition: str, c
|
|
|
194
242
|
"""
|
|
195
243
|
result = True
|
|
196
244
|
command = ['dar', '-t', backup_file, '-Q']
|
|
197
|
-
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
log_basename = os.path. dirname(config_settings.logfile_location)
|
|
248
|
+
logfile = os.path.basename(config_settings.logfile_location)[:-4] + "-commands.log"
|
|
249
|
+
log_path = os.path.join( log_basename, logfile)
|
|
250
|
+
|
|
251
|
+
# wrap a progress bar around the dar command
|
|
252
|
+
stop_event = Event()
|
|
253
|
+
session_marker = f"=== START BACKUP SESSION: {int(time())} ==="
|
|
254
|
+
get_logger(command_output_logger=True).info(session_marker)
|
|
255
|
+
|
|
256
|
+
progress_thread = threading.Thread(
|
|
257
|
+
target=show_log_driven_bar,
|
|
258
|
+
args=(log_path, stop_event, session_marker),
|
|
259
|
+
daemon=True
|
|
260
|
+
)
|
|
261
|
+
progress_thread.start()
|
|
262
|
+
try:
|
|
263
|
+
process = runner.run(command, timeout = config_settings.command_timeout_secs)
|
|
264
|
+
except Exception as e:
|
|
265
|
+
print(f"[!] Backup failed: {e}")
|
|
266
|
+
raise
|
|
267
|
+
finally:
|
|
268
|
+
stop_event.set()
|
|
269
|
+
progress_thread.join()
|
|
270
|
+
|
|
271
|
+
|
|
198
272
|
if process.returncode == 0:
|
|
199
273
|
logger.info("Archive integrity test passed.")
|
|
200
274
|
else:
|
|
@@ -243,7 +317,8 @@ def verify(args: argparse.Namespace, backup_file: str, backup_definition: str, c
|
|
|
243
317
|
if filecmp.cmp(os.path.join(config_settings.test_restore_dir, restored_file_path.lstrip("/")), os.path.join(root_path, restored_file_path.lstrip("/")), shallow=False):
|
|
244
318
|
args.verbose and logger.info(f"Success: file '{restored_file_path}' matches the original")
|
|
245
319
|
else:
|
|
246
|
-
|
|
320
|
+
result = False
|
|
321
|
+
logger.error(f"Failure: file '{restored_file_path}' did not match the original")
|
|
247
322
|
except PermissionError:
|
|
248
323
|
result = False
|
|
249
324
|
logger.exception(f"Permission error while comparing files, continuing....")
|
|
@@ -402,7 +477,8 @@ def perform_backup(args: argparse.Namespace, config_settings: ConfigSettings, ba
|
|
|
402
477
|
if '_' in args.backup_definition:
|
|
403
478
|
msg = f"Skipping backup definition: '{args.backup_definition}' due to '_' in name"
|
|
404
479
|
logger.error(msg)
|
|
405
|
-
|
|
480
|
+
results.append((msg, 1))
|
|
481
|
+
return results
|
|
406
482
|
backup_definitions.append((os.path.basename(args.backup_definition).split('.')[0], os.path.join(config_settings.backup_d_dir, args.backup_definition)))
|
|
407
483
|
else:
|
|
408
484
|
for root, _, files in os.walk(config_settings.backup_d_dir):
|
|
@@ -453,6 +529,10 @@ def perform_backup(args: argparse.Namespace, config_settings: ConfigSettings, ba
|
|
|
453
529
|
|
|
454
530
|
# Perform backup
|
|
455
531
|
backup_result = generic_backup(backup_type, command, backup_file, backup_definition_path, args.darrc, config_settings, args)
|
|
532
|
+
if not isinstance(backup_result, list) or not all(isinstance(i, tuple) and len(i) == 2 for i in backup_result):
|
|
533
|
+
logger.error("Unexpected return format from generic_backup")
|
|
534
|
+
backup_result = [("Unexpected return format from generic_backup", 1)]
|
|
535
|
+
|
|
456
536
|
results.extend(backup_result)
|
|
457
537
|
|
|
458
538
|
logger.info("Starting verification...")
|
|
@@ -620,41 +700,53 @@ INCR back of a single backup definition in backup.d
|
|
|
620
700
|
|
|
621
701
|
|
|
622
702
|
|
|
623
|
-
def
|
|
703
|
+
def print_markdown(source: str, from_string: bool = False, pretty: bool = True):
|
|
624
704
|
"""
|
|
625
|
-
|
|
626
|
-
|
|
705
|
+
Print Markdown content either from a file or directly from a string.
|
|
706
|
+
|
|
627
707
|
Args:
|
|
628
|
-
|
|
629
|
-
|
|
708
|
+
source: Path to the file or Markdown string itself.
|
|
709
|
+
from_string: If True, treat `source` as Markdown string instead of file path.
|
|
710
|
+
pretty: If True, render with rich formatting if available.
|
|
711
|
+
"""
|
|
712
|
+
import os
|
|
713
|
+
import sys
|
|
630
714
|
|
|
631
|
-
|
|
632
|
-
|
|
715
|
+
content = ""
|
|
716
|
+
if from_string:
|
|
717
|
+
content = source
|
|
718
|
+
else:
|
|
719
|
+
if not os.path.exists(source):
|
|
720
|
+
print(f"❌ File not found: {source}")
|
|
721
|
+
sys.exit(1)
|
|
722
|
+
with open(source, "r", encoding="utf-8") as f:
|
|
723
|
+
content = f.read()
|
|
724
|
+
|
|
725
|
+
if pretty:
|
|
726
|
+
try:
|
|
727
|
+
from rich.console import Console
|
|
728
|
+
from rich.markdown import Markdown
|
|
729
|
+
console = Console()
|
|
730
|
+
console.print(Markdown(content))
|
|
731
|
+
except ImportError:
|
|
732
|
+
print("⚠️ 'rich' not installed. Falling back to plain text.\n")
|
|
733
|
+
print(content)
|
|
734
|
+
else:
|
|
735
|
+
print(content)
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def print_changelog(path: str = None, pretty: bool = True):
|
|
740
|
+
if path is None:
|
|
741
|
+
path = Path(__file__).parent / "Changelog.md"
|
|
742
|
+
print_markdown(str(path), pretty=pretty)
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
def print_readme(path: str = None, pretty: bool = True):
|
|
746
|
+
if path is None:
|
|
747
|
+
path = Path(__file__).parent / "README.md"
|
|
748
|
+
print_markdown(str(path), pretty=pretty)
|
|
633
749
|
|
|
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.debug(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
750
|
|
|
659
751
|
|
|
660
752
|
def main():
|
|
@@ -670,23 +762,29 @@ def main():
|
|
|
670
762
|
parser.add_argument('-F', '--full-backup', action='store_true', help="Perform a full backup.")
|
|
671
763
|
parser.add_argument('-D', '--differential-backup', action='store_true', help="Perform differential backup.")
|
|
672
764
|
parser.add_argument('-I', '--incremental-backup', action='store_true', help="Perform incremental backup.")
|
|
673
|
-
parser.add_argument('-d', '--backup-definition', help="Specific 'recipe' to select directories and files.")
|
|
674
|
-
parser.add_argument('--alternate-reference-archive', help="DIFF or INCR compared to specified archive.")
|
|
765
|
+
parser.add_argument('-d', '--backup-definition', help="Specific 'recipe' to select directories and files.").completer = backup_definition_completer
|
|
766
|
+
parser.add_argument('--alternate-reference-archive', help="DIFF or INCR compared to specified archive.").completer = list_archive_completer
|
|
675
767
|
parser.add_argument('-c', '--config-file', type=str, help="Path to 'dar-backup.conf'", default='~/.config/dar-backup/dar-backup.conf')
|
|
676
768
|
parser.add_argument('--darrc', type=str, help='Optional path to .darrc')
|
|
677
|
-
parser.add_argument('--
|
|
678
|
-
parser.add_argument('
|
|
679
|
-
parser.add_argument('--list-contents', help="List the contents of the specified archive.")
|
|
769
|
+
parser.add_argument('-l', '--list', action='store_true', help="List available archives.").completer = list_archive_completer
|
|
770
|
+
parser.add_argument('--list-contents', help="List the contents of the specified archive.").completer = list_archive_completer
|
|
680
771
|
parser.add_argument('--selection', help="dar file selection for listing/restoring specific files/directories.")
|
|
681
772
|
# parser.add_argument('-r', '--restore', nargs=1, type=str, help="Restore specified archive.")
|
|
682
|
-
parser.add_argument('-r', '--restore', type=str, help="Restore specified archive.")
|
|
773
|
+
parser.add_argument('-r', '--restore', type=str, help="Restore specified archive.").completer = list_archive_completer
|
|
683
774
|
parser.add_argument('--restore-dir', type=str, help="Directory to restore files to.")
|
|
684
775
|
parser.add_argument('--verbose', action='store_true', help="Print various status messages to screen")
|
|
685
776
|
parser.add_argument('--suppress-dar-msg', action='store_true', help="cancel dar options in .darrc: -vt, -vs, -vd, -vf and -va")
|
|
686
777
|
parser.add_argument('--log-level', type=str, help="`debug` or `trace`", default="info")
|
|
687
778
|
parser.add_argument('--log-stdout', action='store_true', help='also print log messages to stdout')
|
|
688
779
|
parser.add_argument('--do-not-compare', action='store_true', help="do not compare restores to file system")
|
|
780
|
+
parser.add_argument('--examples', action="store_true", help="Examples of using dar-backup.py.")
|
|
781
|
+
parser.add_argument("--readme", action="store_true", help="Print README.md to stdout and exit.")
|
|
782
|
+
parser.add_argument("--readme-pretty", action="store_true", help="Print README.md to stdout with Markdown styling and exit.")
|
|
783
|
+
parser.add_argument("--changelog", action="store_true", help="Print Changelog.md to stdout and exit.")
|
|
784
|
+
parser.add_argument("--changelog-pretty", action="store_true", help="Print Changelog.md to stdout with Markdown styling and exit.")
|
|
689
785
|
parser.add_argument('-v', '--version', action='store_true', help="Show version and license information.")
|
|
786
|
+
|
|
787
|
+
argcomplete.autocomplete(parser)
|
|
690
788
|
args = parser.parse_args()
|
|
691
789
|
|
|
692
790
|
if args.version:
|
|
@@ -695,6 +793,20 @@ def main():
|
|
|
695
793
|
elif args.examples:
|
|
696
794
|
show_examples()
|
|
697
795
|
exit(0)
|
|
796
|
+
elif args.readme:
|
|
797
|
+
print_readme(None, pretty=False)
|
|
798
|
+
exit(0)
|
|
799
|
+
elif args.readme_pretty:
|
|
800
|
+
print_readme(None, pretty=True)
|
|
801
|
+
exit(0)
|
|
802
|
+
elif args.changelog:
|
|
803
|
+
print_changelog(None, pretty=False)
|
|
804
|
+
exit(0)
|
|
805
|
+
elif args.changelog_pretty:
|
|
806
|
+
print_changelog(None, pretty=True)
|
|
807
|
+
exit(0)
|
|
808
|
+
|
|
809
|
+
|
|
698
810
|
|
|
699
811
|
if not args.config_file:
|
|
700
812
|
print(f"Config file not specified, exiting", file=stderr)
|
|
@@ -740,6 +852,9 @@ def main():
|
|
|
740
852
|
logger.info(f"START TIME: {start_time}")
|
|
741
853
|
logger.debug(f"`args`:\n{args}")
|
|
742
854
|
logger.debug(f"`config_settings`:\n{config_settings}")
|
|
855
|
+
dar_manager_properties = get_binary_info(command='dar')
|
|
856
|
+
logger.debug(f"dar path: {dar_manager_properties['path']}")
|
|
857
|
+
logger.debug(f"dar version: {dar_manager_properties['version']}")
|
|
743
858
|
|
|
744
859
|
file_dir = os.path.normpath(os.path.dirname(__file__))
|
|
745
860
|
args.verbose and (print(f"Script directory: {file_dir}"))
|