dar-backup 0.6.18__py3-none-any.whl → 0.6.20__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 +31 -0
- dar_backup/README.md +49 -14
- dar_backup/__about__.py +1 -1
- dar_backup/clean_log.py +14 -7
- dar_backup/cleanup.py +29 -24
- dar_backup/command_runner.py +59 -9
- dar_backup/config_settings.py +83 -53
- dar_backup/dar-backup.conf +4 -0
- dar_backup/dar_backup.py +56 -35
- dar_backup/demo.py +138 -0
- dar_backup/exceptions.py +3 -0
- dar_backup/installer.py +49 -129
- dar_backup/manager.py +214 -90
- dar_backup/util.py +345 -3
- {dar_backup-0.6.18.dist-info → dar_backup-0.6.20.dist-info}/METADATA +51 -15
- dar_backup-0.6.20.dist-info/RECORD +23 -0
- {dar_backup-0.6.18.dist-info → dar_backup-0.6.20.dist-info}/entry_points.txt +1 -1
- dar_backup-0.6.18.dist-info/RECORD +0 -21
- {dar_backup-0.6.18.dist-info → dar_backup-0.6.20.dist-info}/WHEEL +0 -0
- {dar_backup-0.6.18.dist-info → dar_backup-0.6.20.dist-info}/licenses/LICENSE +0 -0
dar_backup/Changelog.md
CHANGED
|
@@ -1,6 +1,37 @@
|
|
|
1
1
|
<!-- markdownlint-disable MD024 -->
|
|
2
2
|
# dar-backup Changelog
|
|
3
3
|
|
|
4
|
+
## v2-beta-0.6.20 - 2025-05-03
|
|
5
|
+
|
|
6
|
+
Github link: [v2-beta-0.6.20](https://github.com/per2jensen/dar-backup/tree/v2-beta-0.6.20/v2)
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- show_version() moved to util and tests for dar-backup, manager and cleanup
|
|
11
|
+
- startup informational messages now works the same across the scripts
|
|
12
|
+
- Improved ConfigSettings class to handle optional configuration keys
|
|
13
|
+
|
|
14
|
+
-- test cases added
|
|
15
|
+
|
|
16
|
+
- Optional config parameter: MANAGER_DB_DIR, ideally to point to another disk for safe keeping backup catalogs
|
|
17
|
+
|
|
18
|
+
-- test cases added
|
|
19
|
+
|
|
20
|
+
## v2-beta-0.6.19 - 2025-04-21
|
|
21
|
+
|
|
22
|
+
Github link: [v2-beta-0.6.19](https://github.com/per2jensen/dar-backup/tree/v2-beta-0.6.19/v2)
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
- removed a BackupError in the verify() to reduce noise in logs and let the rest of "compares" run.
|
|
27
|
+
- Added bash and zsh auto completion for a nicer CLI experience.
|
|
28
|
+
|
|
29
|
+
-- See [README for details](https://github.com/per2jensen/dar-backup?tab=readme-ov-file#shell-autocompletion)
|
|
30
|
+
|
|
31
|
+
- Improvement to command_runner.run(), more robust decoding
|
|
32
|
+
|
|
33
|
+
- Manager --add-specific-archive now gives a prompt with a warning if user attempts to add a catalog that breaks chronology. The user is allowed to go forward and ignore the warning or can choose to abort. The program times out after a little while and discards the operation.
|
|
34
|
+
|
|
4
35
|
## v2-beta-0.6.18 - 2025-04-05
|
|
5
36
|
|
|
6
37
|
Github link: [v2-beta-0.6.18](https://github.com/per2jensen/dar-backup/tree/v2-beta-0.6.18/v2)
|
dar_backup/README.md
CHANGED
|
@@ -2,12 +2,18 @@
|
|
|
2
2
|
# Full, differential or incremental backups using 'dar'
|
|
3
3
|
|
|
4
4
|
[](https://codecov.io/gh/per2jensen/dar-backup)
|
|
5
|
+
[](https://pypi.org/project/dar-backup/)
|
|
6
|
+
[](https://pypi.org/project/dar-backup/)
|
|
5
7
|
|
|
6
8
|
The wonderful 'dar' [Disk Archiver](https://github.com/Edrusb/DAR) is used for
|
|
7
9
|
the heavy lifting, together with the par2 suite in these scripts.
|
|
8
10
|
|
|
9
11
|
This is the `Python` based **version 2** of `dar-backup`.
|
|
10
12
|
|
|
13
|
+
## TL;DR
|
|
14
|
+
|
|
15
|
+
`dar-backup` is a Python-powered CLI for creating and validating full, differential, and incremental backups using dar and par2. Designed for long-term restore integrity, even on user-space filesystems like FUSE.
|
|
16
|
+
|
|
11
17
|
## Table of Contents
|
|
12
18
|
|
|
13
19
|
- [Full, differential or incremental backups using 'dar'](#full-differential-or-incremental-backups-using-dar)
|
|
@@ -33,6 +39,7 @@ This is the `Python` based **version 2** of `dar-backup`.
|
|
|
33
39
|
- [Generate systemd files](#generate-systemd-files)
|
|
34
40
|
- [Service: dar-back --incremental-backup](#service-dar-backup---incremental-backup)
|
|
35
41
|
- [Timer: dar-back --incremental-backup](#timer-dar-backup---incremental-backup)
|
|
42
|
+
- [Systemd timer note](#systemd-timer-note)
|
|
36
43
|
- [List contents of an archive](#list-contents-of-an-archive)
|
|
37
44
|
- [dar file selection examples](#dar-file-selection-examples)
|
|
38
45
|
- [Select a directory](#select-a-directory)
|
|
@@ -58,7 +65,9 @@ This is the `Python` based **version 2** of `dar-backup`.
|
|
|
58
65
|
- [Skipping cache directories](#skipping-cache-directories)
|
|
59
66
|
- [Progress bar + current directory](#progress-bar-and-current-directory)
|
|
60
67
|
- [Todo](#todo)
|
|
68
|
+
- [Known Limitations / Edge Cases](#known-limitations--edge-cases)
|
|
61
69
|
- [Reference](#reference)
|
|
70
|
+
- [CLI Tools Overview](#cli-tools-overview)
|
|
62
71
|
- [Test coverage report](#test-coverage)
|
|
63
72
|
- [dar-backup](#dar-backup-options)
|
|
64
73
|
- [manager](#manager-options)
|
|
@@ -651,6 +660,10 @@ Persistent=true
|
|
|
651
660
|
WantedBy=timers.target
|
|
652
661
|
````
|
|
653
662
|
|
|
663
|
+
## systemd timer note
|
|
664
|
+
|
|
665
|
+
📅 OnCalendar syntax is flexible — you can tweak backup schedules easily. Run systemd-analyze calendar to preview timers.
|
|
666
|
+
|
|
654
667
|
## list contents of an archive
|
|
655
668
|
|
|
656
669
|
```` bash
|
|
@@ -941,8 +954,27 @@ The indicators are not shown if dar-backup is run from systemd or if it is used
|
|
|
941
954
|
- Look into a way to move the .par2 files away from the `dar` slices, to maximize chance of good redundancy.
|
|
942
955
|
- Add option to dar-backup to use the `dar` option `--fsa-scope none`
|
|
943
956
|
|
|
957
|
+
## Known Limitations / Edge Cases
|
|
958
|
+
|
|
959
|
+
Does not currently encrypt data (by design — relies on encrypted storage)
|
|
960
|
+
|
|
961
|
+
One backup definition per file
|
|
962
|
+
|
|
963
|
+
.par2 files created for each slice (may be moved in future)
|
|
964
|
+
|
|
944
965
|
## Reference
|
|
945
966
|
|
|
967
|
+
### CLI Tools Overview
|
|
968
|
+
|
|
969
|
+
| Command | Description |
|
|
970
|
+
|-----------------------|-------------------------------------------|
|
|
971
|
+
| `dar-backup` | Perform full, differential, or incremental backups with verification and restore testing |
|
|
972
|
+
| `manager` | Maintain and query catalog databases for archives |
|
|
973
|
+
| `cleanup` | Remove outdated DIFF/INCR archives (and optionally FULLs) |
|
|
974
|
+
| `clean-log` | Clean up excessive log output from dar command logs |
|
|
975
|
+
| `installer` | Set up required directories and default config files |
|
|
976
|
+
| `dar-backup-systemd` | Generate (and optionally install) systemd timers and services for automated backups |
|
|
977
|
+
|
|
946
978
|
### test coverage
|
|
947
979
|
|
|
948
980
|
Running
|
|
@@ -951,23 +983,26 @@ Running
|
|
|
951
983
|
pytest --cov=dar_backup tests/
|
|
952
984
|
````
|
|
953
985
|
|
|
954
|
-
results for version 0.6.
|
|
986
|
+
results for a dev version 0.6.19 in this report:
|
|
955
987
|
|
|
956
988
|
```` code
|
|
957
989
|
---------- coverage: platform linux, python 3.12.3-final-0 -----------
|
|
958
|
-
Name
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
990
|
+
Name Stmts Miss Cover
|
|
991
|
+
----------------------------------------------------------
|
|
992
|
+
src/dar_backup/__about__.py 1 0 100%
|
|
993
|
+
src/dar_backup/__init__.py 0 0 100%
|
|
994
|
+
src/dar_backup/clean_log.py 68 13 81%
|
|
995
|
+
src/dar_backup/cleanup.py 193 17 91%
|
|
996
|
+
src/dar_backup/command_runner.py 73 1 99%
|
|
997
|
+
src/dar_backup/config_settings.py 66 8 88%
|
|
998
|
+
src/dar_backup/dar_backup.py 535 56 90%
|
|
999
|
+
src/dar_backup/dar_backup_systemd.py 56 7 88%
|
|
1000
|
+
src/dar_backup/installer.py 59 6 90%
|
|
1001
|
+
src/dar_backup/manager.py 351 56 84%
|
|
1002
|
+
src/dar_backup/rich_progress.py 70 7 90%
|
|
1003
|
+
src/dar_backup/util.py 130 15 88%
|
|
1004
|
+
----------------------------------------------------------
|
|
1005
|
+
TOTAL 1602 186 88%
|
|
971
1006
|
````
|
|
972
1007
|
|
|
973
1008
|
### dar-backup options
|
dar_backup/__about__.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "0.6.
|
|
1
|
+
__version__ = "0.6.20"
|
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,17 +13,22 @@ 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
|
|
19
20
|
import re
|
|
20
21
|
import subprocess
|
|
21
22
|
import sys
|
|
23
|
+
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
|
|
24
|
+
|
|
25
|
+
|
|
22
26
|
|
|
23
27
|
from datetime import datetime, timedelta
|
|
24
28
|
from inputimeout import inputimeout, TimeoutOccurred
|
|
25
29
|
from time import time
|
|
26
|
-
from typing import Dict, List, NamedTuple
|
|
30
|
+
from typing import Dict, List, NamedTuple, Tuple
|
|
31
|
+
|
|
27
32
|
|
|
28
33
|
from . import __about__ as about
|
|
29
34
|
from dar_backup.config_settings import ConfigSettings
|
|
@@ -31,6 +36,9 @@ from dar_backup.util import list_backups
|
|
|
31
36
|
from dar_backup.util import setup_logging
|
|
32
37
|
from dar_backup.util import get_logger
|
|
33
38
|
from dar_backup.util import requirements
|
|
39
|
+
from dar_backup.util import show_version
|
|
40
|
+
from dar_backup.util import print_aligned_settings
|
|
41
|
+
from dar_backup.util import backup_definition_completer, list_archive_completer
|
|
34
42
|
|
|
35
43
|
from dar_backup.command_runner import CommandRunner
|
|
36
44
|
from dar_backup.command_runner import CommandResult
|
|
@@ -150,21 +158,12 @@ def delete_catalog(catalog_name: str, args: NamedTuple) -> bool:
|
|
|
150
158
|
return False
|
|
151
159
|
|
|
152
160
|
|
|
153
|
-
def show_version():
|
|
154
|
-
script_name = os.path.basename(sys.argv[0])
|
|
155
|
-
print(f"{script_name} {about.__version__}")
|
|
156
|
-
print('''Licensed under GNU GENERAL PUBLIC LICENSE v3, see the supplied file "LICENSE" for details.
|
|
157
|
-
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW, not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
|
158
|
-
See section 15 and section 16 in the supplied "LICENSE" file.''')
|
|
159
|
-
|
|
160
|
-
|
|
161
161
|
def confirm_full_archive_deletion(archive_name: str, test_mode=False) -> bool:
|
|
162
162
|
try:
|
|
163
163
|
if test_mode:
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
print(f"Simulated confirmation for FULL archive: {confirmation}")
|
|
164
|
+
answer = os.getenv("CLEANUP_TEST_DELETE_FULL", "").lower()
|
|
165
|
+
print(f"Simulated confirmation for FULL archive '{archive_name}': {answer}")
|
|
166
|
+
return answer == "yes"
|
|
168
167
|
else:
|
|
169
168
|
confirmation = inputimeout(
|
|
170
169
|
prompt=f"Are you sure you want to delete the FULL archive '{archive_name}'? (yes/no): ",
|
|
@@ -186,16 +185,19 @@ def main():
|
|
|
186
185
|
global logger, runner
|
|
187
186
|
|
|
188
187
|
parser = argparse.ArgumentParser(description="Cleanup old archives according to AGE configuration.")
|
|
189
|
-
parser.add_argument('-d', '--backup-definition', help="Specific backup definition to cleanup.")
|
|
188
|
+
parser.add_argument('-d', '--backup-definition', help="Specific backup definition to cleanup.").completer = backup_definition_completer
|
|
190
189
|
parser.add_argument('-c', '--config-file', '-c', type=str, help="Path to 'dar-backup.conf'", default='~/.config/dar-backup/dar-backup.conf')
|
|
191
190
|
parser.add_argument('-v', '--version', action='store_true', help="Show version information.")
|
|
192
191
|
parser.add_argument('--alternate-archive-dir', type=str, help="Cleanup in this directory instead of the default one.")
|
|
193
|
-
parser.add_argument('--cleanup-specific-archives', type=str, help="Comma separated list of archives to cleanup")
|
|
192
|
+
parser.add_argument('--cleanup-specific-archives', type=str, help="Comma separated list of archives to cleanup").completer = list_archive_completer
|
|
194
193
|
parser.add_argument('-l', '--list', action='store_true', help="List available archives.")
|
|
195
194
|
parser.add_argument('--verbose', action='store_true', help="Print various status messages to screen")
|
|
196
195
|
parser.add_argument('--log-level', type=str, help="`debug` or `trace`, default is `info`", default="info")
|
|
197
196
|
parser.add_argument('--log-stdout', action='store_true', help='also print log messages to stdout')
|
|
198
197
|
parser.add_argument('--test-mode', action='store_true', help='Read envvars in order to run some pytest cases')
|
|
198
|
+
|
|
199
|
+
argcomplete.autocomplete(parser)
|
|
200
|
+
|
|
199
201
|
args = parser.parse_args()
|
|
200
202
|
|
|
201
203
|
args.config_file = os.path.expanduser(os.path.expandvars(args.config_file))
|
|
@@ -215,21 +217,24 @@ def main():
|
|
|
215
217
|
command_logger = get_logger(command_output_logger = True)
|
|
216
218
|
runner = CommandRunner(logger=logger, command_logger=command_logger)
|
|
217
219
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
+
start_msgs: List[Tuple[str, str]] = []
|
|
221
|
+
|
|
222
|
+
start_msgs.append(("cleanup.py:", about.__version__))
|
|
220
223
|
|
|
221
224
|
logger.info(f"START TIME: {start_time}")
|
|
222
225
|
logger.debug(f"`args`:\n{args}")
|
|
223
226
|
logger.debug(f"`config_settings`:\n{config_settings}")
|
|
224
227
|
|
|
225
228
|
file_dir = os.path.normpath(os.path.dirname(__file__))
|
|
226
|
-
args.verbose and (
|
|
227
|
-
|
|
228
|
-
args.verbose and (
|
|
229
|
-
|
|
230
|
-
args.verbose and (
|
|
231
|
-
args.verbose and (
|
|
232
|
-
|
|
229
|
+
args.verbose and start_msgs.append(("Script directory:", file_dir))
|
|
230
|
+
start_msgs.append(("Config file:", args.config_file))
|
|
231
|
+
args.verbose and start_msgs.append(("Backup dir:", config_settings.backup_dir))
|
|
232
|
+
start_msgs.append(("Logfile:", config_settings.logfile_location))
|
|
233
|
+
args.verbose and start_msgs.append(("--alternate-archive-dir:", args.alternate_archive_dir))
|
|
234
|
+
args.verbose and start_msgs.append(("--cleanup-specific-archives:", args.cleanup_specific_archives))
|
|
235
|
+
|
|
236
|
+
dangerous_keywords = ["--cleanup", "_FULL_"] # TODO: add more dangerous keywords
|
|
237
|
+
print_aligned_settings(start_msgs, highlight_keywords=dangerous_keywords)
|
|
233
238
|
|
|
234
239
|
# run PREREQ scripts
|
|
235
240
|
requirements('PREREQ', config_settings)
|
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/config_settings.py
CHANGED
|
@@ -1,33 +1,35 @@
|
|
|
1
|
-
|
|
2
1
|
import configparser
|
|
3
|
-
import logging
|
|
4
|
-
import sys
|
|
5
|
-
|
|
6
2
|
from dataclasses import dataclass, field, fields
|
|
7
3
|
from os.path import expandvars, expanduser
|
|
8
4
|
from pathlib import Path
|
|
9
5
|
|
|
6
|
+
from dar_backup.exceptions import ConfigSettingsError
|
|
7
|
+
|
|
10
8
|
@dataclass
|
|
11
9
|
class ConfigSettings:
|
|
12
10
|
"""
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
11
|
+
Parses and holds configuration values from a dar-backup.conf file.
|
|
12
|
+
|
|
13
|
+
Required fields are defined as dataclass attributes and must be present in the config file.
|
|
14
|
+
Optional fields can be declared in the OPTIONAL_CONFIG_FIELDS list. If a key is present in the
|
|
15
|
+
config file, the field is set to its parsed value; otherwise, it defaults to None.
|
|
16
|
+
|
|
17
|
+
The __repr__ method will only include optional fields if their value is not None,
|
|
18
|
+
keeping debug output clean and focused on explicitly configured values.
|
|
19
|
+
|
|
20
|
+
OPTIONAL_CONFIG_FIELDS = [
|
|
21
|
+
{
|
|
22
|
+
"section": "DIRECTORIES",
|
|
23
|
+
"key": "MANAGER_DB_DIR",
|
|
24
|
+
"attr": "manager_db_dir",
|
|
25
|
+
"type": str,
|
|
26
|
+
"default": None,
|
|
27
|
+
}
|
|
28
|
+
]
|
|
28
29
|
"""
|
|
29
30
|
|
|
30
31
|
config_file: str
|
|
32
|
+
|
|
31
33
|
logfile_location: str = field(init=False)
|
|
32
34
|
max_size_verification_mb: int = field(init=False)
|
|
33
35
|
min_size_verification_mb: int = field(init=False)
|
|
@@ -41,17 +43,28 @@ class ConfigSettings:
|
|
|
41
43
|
error_correction_percent: int = field(init=False)
|
|
42
44
|
par2_enabled: bool = field(init=False)
|
|
43
45
|
|
|
46
|
+
|
|
47
|
+
OPTIONAL_CONFIG_FIELDS = [
|
|
48
|
+
{
|
|
49
|
+
"section": "DIRECTORIES",
|
|
50
|
+
"key": "MANAGER_DB_DIR",
|
|
51
|
+
"attr": "manager_db_dir",
|
|
52
|
+
"type": str,
|
|
53
|
+
"default": None,
|
|
54
|
+
},
|
|
55
|
+
# Add more optional fields here
|
|
56
|
+
]
|
|
57
|
+
|
|
44
58
|
def __post_init__(self):
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
"""
|
|
49
|
-
if self.config_file is None:
|
|
50
|
-
raise ValueError("`config_file` must be specified.")
|
|
51
|
-
|
|
52
|
-
self.config = configparser.ConfigParser()
|
|
59
|
+
if not self.config_file:
|
|
60
|
+
raise ConfigSettingsError("`config_file` must be specified.")
|
|
61
|
+
|
|
53
62
|
try:
|
|
54
|
-
self.config.
|
|
63
|
+
self.config = configparser.ConfigParser()
|
|
64
|
+
loaded_files = self.config.read(self.config_file)
|
|
65
|
+
if not loaded_files:
|
|
66
|
+
raise RuntimeError(f"Configuration file not found or unreadable: '{self.config_file}'")
|
|
67
|
+
|
|
55
68
|
self.logfile_location = self.config['MISC']['LOGFILE_LOCATION']
|
|
56
69
|
self.max_size_verification_mb = int(self.config['MISC']['MAX_SIZE_VERIFICATION_MB'])
|
|
57
70
|
self.min_size_verification_mb = int(self.config['MISC']['MIN_SIZE_VERIFICATION_MB'])
|
|
@@ -63,37 +76,54 @@ class ConfigSettings:
|
|
|
63
76
|
self.diff_age = int(self.config['AGE']['DIFF_AGE'])
|
|
64
77
|
self.incr_age = int(self.config['AGE']['INCR_AGE'])
|
|
65
78
|
self.error_correction_percent = int(self.config['PAR2']['ERROR_CORRECTION_PERCENT'])
|
|
79
|
+
|
|
66
80
|
val = self.config['PAR2']['ENABLED'].strip().lower()
|
|
67
81
|
if val in ('true', '1', 'yes'):
|
|
68
82
|
self.par2_enabled = True
|
|
69
83
|
elif val in ('false', '0', 'no'):
|
|
70
84
|
self.par2_enabled = False
|
|
71
85
|
else:
|
|
72
|
-
raise
|
|
73
|
-
|
|
74
|
-
#
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
86
|
+
raise ConfigSettingsError(f"Invalid boolean value for 'ENABLED' in [PAR2]: '{val}'")
|
|
87
|
+
|
|
88
|
+
# Load optional fields
|
|
89
|
+
for opt in self.OPTIONAL_CONFIG_FIELDS:
|
|
90
|
+
if self.config.has_option(opt['section'], opt['key']):
|
|
91
|
+
raw_value = self.config.get(opt['section'], opt['key'])
|
|
92
|
+
try:
|
|
93
|
+
value = opt['type'](raw_value.strip())
|
|
94
|
+
setattr(self, opt['attr'], value)
|
|
95
|
+
except Exception as e:
|
|
96
|
+
raise ConfigSettingsError(
|
|
97
|
+
f"Failed to parse optional config '{opt['section']}::{opt['key']}': {e}"
|
|
98
|
+
)
|
|
99
|
+
else:
|
|
100
|
+
setattr(self, opt['attr'], opt.get('default', None))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# Expand paths in all string fields that exist
|
|
80
104
|
for field in fields(self):
|
|
81
|
-
if
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
except PermissionError as e:
|
|
89
|
-
logging.error(f"Permission error while reading config file {self.config_file}")
|
|
90
|
-
logging.error(f"Error details: {e}")
|
|
91
|
-
sys.exit("Error: Permission error while reading config file.")
|
|
105
|
+
if hasattr(self, field.name):
|
|
106
|
+
value = getattr(self, field.name)
|
|
107
|
+
if isinstance(value, str):
|
|
108
|
+
setattr(self, field.name, expanduser(expandvars(value)))
|
|
109
|
+
|
|
110
|
+
except RuntimeError as e:
|
|
111
|
+
raise ConfigSettingsError(f"RuntimeError: {e}")
|
|
92
112
|
except KeyError as e:
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
113
|
+
raise ConfigSettingsError(f"Missing mandatory configuration key: {e}")
|
|
114
|
+
except ValueError as e:
|
|
115
|
+
raise ConfigSettingsError(f"Invalid value in config: {e}")
|
|
96
116
|
except Exception as e:
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
117
|
+
raise ConfigSettingsError(f"Unexpected error during config initialization: {e}")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def __repr__(self):
|
|
122
|
+
safe_fields = [
|
|
123
|
+
f"{field.name}={getattr(self, field.name)!r}"
|
|
124
|
+
for field in fields(self)
|
|
125
|
+
if hasattr(self, field.name) and getattr(self, field.name) is not None
|
|
126
|
+
]
|
|
127
|
+
return f"<ConfigSettings({', '.join(safe_fields)})>"
|
|
128
|
+
|
|
129
|
+
|
dar_backup/dar-backup.conf
CHANGED
|
@@ -16,8 +16,12 @@ COMMAND_TIMEOUT_SECS = 86400
|
|
|
16
16
|
BACKUP_DIR = ~/dar-backup/backups
|
|
17
17
|
BACKUP.D_DIR = ~/.config/dar-backup/backup.d/
|
|
18
18
|
TEST_RESTORE_DIR = ~/dar-backup/restore/
|
|
19
|
+
# Optional parameter
|
|
20
|
+
# If you want to store the catalog database away from the BACKUP_DIR, use the MANAGER_DB_DIR variable.
|
|
21
|
+
#MANAGER_DB_DIR = /some/where/else/
|
|
19
22
|
|
|
20
23
|
[AGE]
|
|
24
|
+
# DIFF and INCR backups are kept for a configured number of days, then deleted by the `cleanuo`
|
|
21
25
|
# age settings are in days
|
|
22
26
|
DIFF_AGE = 100
|
|
23
27
|
INCR_AGE = 40
|