f5-backup 0.1.0__tar.gz
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.
- f5_backup-0.1.0/PKG-INFO +26 -0
- f5_backup-0.1.0/README.md +1 -0
- f5_backup-0.1.0/f5/__init__.py +7 -0
- f5_backup-0.1.0/f5/__main__.py +3 -0
- f5_backup-0.1.0/f5/backup/__init__.py +212 -0
- f5_backup-0.1.0/f5/backup/filekey.py +22 -0
- f5_backup-0.1.0/f5/backup/s3config.py +25 -0
- f5_backup-0.1.0/f5/backup/s3filesystem.py +121 -0
- f5_backup-0.1.0/f5/backup/tskfilesystem.py +67 -0
- f5_backup-0.1.0/f5/backup/tskreader.py +73 -0
- f5_backup-0.1.0/f5/backup/uploadprogress.py +35 -0
- f5_backup-0.1.0/f5/cli.py +112 -0
- f5_backup-0.1.0/f5/devices/__init__.py +25 -0
- f5_backup-0.1.0/f5/devices/monitorconfig.py +7 -0
- f5_backup-0.1.0/f5/devices/monitorworker.py +170 -0
- f5_backup-0.1.0/f5/devices/partition.py +22 -0
- f5_backup-0.1.0/f5/devices/partitionremoved.py +4 -0
- f5_backup-0.1.0/f5/logging.py +23 -0
- f5_backup-0.1.0/pyproject.toml +40 -0
f5_backup-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: f5-backup
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Automatic backups to S3-compatible storage
|
|
5
|
+
License: MIT
|
|
6
|
+
Author: Will Hinson
|
|
7
|
+
Author-email: will@hinson.sh
|
|
8
|
+
Requires-Python: >=3.13
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.15
|
|
14
|
+
Requires-Dist: boto3 (>=1.43.105,<2.0.0)
|
|
15
|
+
Requires-Dist: click (>=8.5.0,<9.0.0)
|
|
16
|
+
Requires-Dist: humanize (>=4.16.0,<5.0.0)
|
|
17
|
+
Requires-Dist: insights-core (>=3.8.2,<4.0.0)
|
|
18
|
+
Requires-Dist: pydantic (>=2.13.5,<3.0.0)
|
|
19
|
+
Requires-Dist: pytsk3 (>=20260715,<20260716)
|
|
20
|
+
Requires-Dist: pyudev (>=0.24.5,<0.25.0)
|
|
21
|
+
Requires-Dist: rich (>=15.0.0,<16.0.0)
|
|
22
|
+
Requires-Dist: tenacity (>=9.1.4,<10.0.0)
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# f5
|
|
26
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# f5
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from queue import Queue
|
|
3
|
+
from threading import Thread
|
|
4
|
+
|
|
5
|
+
import humanize
|
|
6
|
+
import rich
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.progress import (
|
|
9
|
+
Progress,
|
|
10
|
+
TextColumn,
|
|
11
|
+
BarColumn,
|
|
12
|
+
DownloadColumn,
|
|
13
|
+
TransferSpeedColumn,
|
|
14
|
+
TimeRemainingColumn,
|
|
15
|
+
TaskID,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
from ..devices import Partition, PartitionRemoved
|
|
19
|
+
from .filekey import FileKey
|
|
20
|
+
from .s3config import S3Config
|
|
21
|
+
from .s3filesystem import S3Filesystem
|
|
22
|
+
from .tskfilesystem import TskFilesystem
|
|
23
|
+
from .uploadprogress import UploadProgress
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
_logger: logging.Logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _make_progress() -> Progress:
|
|
30
|
+
console: Console = rich.get_console()
|
|
31
|
+
|
|
32
|
+
return Progress(
|
|
33
|
+
TextColumn("[bold]{task.description}", justify="right"),
|
|
34
|
+
BarColumn(),
|
|
35
|
+
DownloadColumn(binary_units=True),
|
|
36
|
+
TransferSpeedColumn(),
|
|
37
|
+
TimeRemainingColumn(),
|
|
38
|
+
console=console,
|
|
39
|
+
disable=not console.is_terminal, # no bars when piped or run under a service
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _get_target_files(
|
|
44
|
+
partition: Partition,
|
|
45
|
+
tsk: TskFilesystem,
|
|
46
|
+
s3: S3Filesystem,
|
|
47
|
+
) -> tuple[set[FileKey], int]:
|
|
48
|
+
# discover all the files on the local filesystem
|
|
49
|
+
local_files: set[FileKey] = tsk.discover_files()
|
|
50
|
+
_logger.info(
|
|
51
|
+
"Discovered %s total file%s on %s",
|
|
52
|
+
f"{len(local_files):,}",
|
|
53
|
+
("s" if len(local_files) != 1 else ""),
|
|
54
|
+
partition.device.device_node,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
# check which of the objects exist on the remote and only return those that don't
|
|
58
|
+
target_files: set[FileKey] = local_files - s3.get_partition_files(
|
|
59
|
+
partition=partition
|
|
60
|
+
)
|
|
61
|
+
total_size: int = sum(
|
|
62
|
+
map(
|
|
63
|
+
lambda file_key: file_key.size,
|
|
64
|
+
target_files,
|
|
65
|
+
)
|
|
66
|
+
)
|
|
67
|
+
_logger.info(
|
|
68
|
+
"Found %s file%s to sync from %s to remote storage (%s)",
|
|
69
|
+
f"{len(target_files)}",
|
|
70
|
+
("s" if len(target_files) != 1 else ""),
|
|
71
|
+
partition.device.device_node,
|
|
72
|
+
humanize.naturalsize(
|
|
73
|
+
total_size,
|
|
74
|
+
binary=True,
|
|
75
|
+
),
|
|
76
|
+
)
|
|
77
|
+
return target_files, total_size
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _upload_with_progress(
|
|
81
|
+
progress: Progress,
|
|
82
|
+
partition: Partition,
|
|
83
|
+
target_file_key: FileKey,
|
|
84
|
+
index: int,
|
|
85
|
+
total_files: int,
|
|
86
|
+
overall_task: TaskID,
|
|
87
|
+
s3: S3Filesystem,
|
|
88
|
+
tsk: TskFilesystem,
|
|
89
|
+
) -> None:
|
|
90
|
+
try:
|
|
91
|
+
file_task: TaskID = progress.add_task(
|
|
92
|
+
f"{index + 1}/{total_files} {target_file_key.path.name}",
|
|
93
|
+
total=target_file_key.size,
|
|
94
|
+
)
|
|
95
|
+
_logger.info(
|
|
96
|
+
"Uploading %s of size %s from %s (%s/%s)",
|
|
97
|
+
target_file_key.path.as_posix(),
|
|
98
|
+
humanize.naturalsize(
|
|
99
|
+
target_file_key.size,
|
|
100
|
+
binary=True,
|
|
101
|
+
),
|
|
102
|
+
partition.device.device_node,
|
|
103
|
+
index + 1,
|
|
104
|
+
total_files,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
s3.upload_partition_file(
|
|
108
|
+
partition=partition,
|
|
109
|
+
filesystem=tsk,
|
|
110
|
+
file_key=target_file_key,
|
|
111
|
+
callback=UploadProgress(
|
|
112
|
+
progress,
|
|
113
|
+
overall_task,
|
|
114
|
+
file_task,
|
|
115
|
+
),
|
|
116
|
+
)
|
|
117
|
+
progress.remove_task(file_task)
|
|
118
|
+
|
|
119
|
+
finally:
|
|
120
|
+
# always remove any remaining task entries to clear the console up
|
|
121
|
+
for task_id in list(progress.task_ids):
|
|
122
|
+
try:
|
|
123
|
+
progress.remove_task(task_id)
|
|
124
|
+
except KeyError:
|
|
125
|
+
...
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _backup_partition(
|
|
129
|
+
partition: Partition,
|
|
130
|
+
s3_config: S3Config,
|
|
131
|
+
) -> None:
|
|
132
|
+
tsk: TskFilesystem = TskFilesystem(partition=partition)
|
|
133
|
+
s3: S3Filesystem = S3Filesystem(config=s3_config)
|
|
134
|
+
|
|
135
|
+
# get the target files to sync and the total size of all of them
|
|
136
|
+
target_files, total_size = _get_target_files(partition, tsk, s3)
|
|
137
|
+
with _make_progress() as progress:
|
|
138
|
+
overall_task: TaskID = progress.add_task(
|
|
139
|
+
"Total",
|
|
140
|
+
total=total_size,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
for index, target_file_key in enumerate(
|
|
144
|
+
sorted(
|
|
145
|
+
# iterate over all of the file in reverse size order, starting
|
|
146
|
+
# with the largest file first
|
|
147
|
+
target_files,
|
|
148
|
+
key=lambda file_key: file_key.size,
|
|
149
|
+
reverse=True,
|
|
150
|
+
)
|
|
151
|
+
):
|
|
152
|
+
_upload_with_progress(
|
|
153
|
+
progress=progress,
|
|
154
|
+
s3=s3,
|
|
155
|
+
tsk=tsk,
|
|
156
|
+
partition=partition,
|
|
157
|
+
target_file_key=target_file_key,
|
|
158
|
+
index=index,
|
|
159
|
+
total_files=len(target_files),
|
|
160
|
+
overall_task=overall_task,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
try:
|
|
164
|
+
progress.remove_task(overall_task)
|
|
165
|
+
except KeyError:
|
|
166
|
+
...
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def make_backup_thread(
|
|
170
|
+
partition_queue: Queue[Partition],
|
|
171
|
+
s3_config: S3Config,
|
|
172
|
+
) -> Thread:
|
|
173
|
+
"""
|
|
174
|
+
Start a thread to handle backups for new partitions.
|
|
175
|
+
"""
|
|
176
|
+
|
|
177
|
+
def backup_worker() -> None:
|
|
178
|
+
while True:
|
|
179
|
+
partition = partition_queue.get()
|
|
180
|
+
if partition is None:
|
|
181
|
+
break
|
|
182
|
+
|
|
183
|
+
partition_name: str = partition.device.device_node
|
|
184
|
+
try:
|
|
185
|
+
partition.check_removed()
|
|
186
|
+
_backup_partition(
|
|
187
|
+
partition,
|
|
188
|
+
s3_config=s3_config,
|
|
189
|
+
)
|
|
190
|
+
except (PartitionRemoved, OSError) as exc:
|
|
191
|
+
if isinstance(exc, OSError) and not partition.removed.is_set():
|
|
192
|
+
_logger.exception(f"Backup of partition {partition_name} failed")
|
|
193
|
+
else:
|
|
194
|
+
_logger.warning(
|
|
195
|
+
f"Backup of partition {partition_name} stopped: device removed"
|
|
196
|
+
)
|
|
197
|
+
except Exception:
|
|
198
|
+
_logger.exception(f"Backup of partition {partition_name} failed")
|
|
199
|
+
else:
|
|
200
|
+
_logger.info(
|
|
201
|
+
"Partition %s of device %s is fully backed up",
|
|
202
|
+
partition.device.device_node,
|
|
203
|
+
partition.parent_device.device_node,
|
|
204
|
+
)
|
|
205
|
+
finally:
|
|
206
|
+
partition_queue.task_done()
|
|
207
|
+
|
|
208
|
+
return Thread(
|
|
209
|
+
target=backup_worker,
|
|
210
|
+
name="backup-worker",
|
|
211
|
+
daemon=True,
|
|
212
|
+
)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class FileKey(BaseModel):
|
|
7
|
+
path: Path
|
|
8
|
+
size: int
|
|
9
|
+
|
|
10
|
+
def __eq__(self: "FileKey", other: object) -> bool:
|
|
11
|
+
if not isinstance(other, FileKey):
|
|
12
|
+
return NotImplemented
|
|
13
|
+
|
|
14
|
+
return self.path == other.path and self.size == other.size
|
|
15
|
+
|
|
16
|
+
def __hash__(self: "FileKey") -> int:
|
|
17
|
+
return hash(
|
|
18
|
+
(
|
|
19
|
+
self.path.as_posix(),
|
|
20
|
+
self.size,
|
|
21
|
+
)
|
|
22
|
+
)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from pathlib import Path, PurePosixPath
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
|
|
5
|
+
from ..devices import Partition
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class S3Config(BaseModel):
|
|
9
|
+
profile: str
|
|
10
|
+
bucket: str
|
|
11
|
+
prefix: str
|
|
12
|
+
|
|
13
|
+
def get_partition_prefix(self: "S3Config", partition: Partition) -> str:
|
|
14
|
+
return (
|
|
15
|
+
(PurePosixPath("/") / self.prefix / partition.uuid)
|
|
16
|
+
.relative_to("/")
|
|
17
|
+
.as_posix()
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
def get_partition_file_key(
|
|
21
|
+
self: "S3Config", partition: Partition, file: Path
|
|
22
|
+
) -> str:
|
|
23
|
+
return (
|
|
24
|
+
PurePosixPath("/") / self.prefix / partition.uuid / file.relative_to("/")
|
|
25
|
+
).as_posix()
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
import boto3
|
|
6
|
+
from boto3.exceptions import S3UploadFailedError
|
|
7
|
+
from botocore.exceptions import BotoCoreError, ClientError
|
|
8
|
+
from tenacity import (
|
|
9
|
+
RetryCallState,
|
|
10
|
+
before_sleep_log,
|
|
11
|
+
retry,
|
|
12
|
+
retry_base,
|
|
13
|
+
retry_if_exception,
|
|
14
|
+
retry_if_exception_type,
|
|
15
|
+
stop_after_attempt,
|
|
16
|
+
wait_exponential,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
from ..devices import Partition
|
|
21
|
+
from .filekey import FileKey
|
|
22
|
+
from .s3config import S3Config
|
|
23
|
+
from .tskfilesystem import TskFilesystem
|
|
24
|
+
from .tskreader import TskReader
|
|
25
|
+
from .uploadprogress import UploadProgress
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from mypy_boto3_s3 import S3Client
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
_logger: logging.Logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class _retry_unless_partition_removed(retry_base):
|
|
35
|
+
def __call__(self, retry_state: RetryCallState) -> bool:
|
|
36
|
+
partition: Partition = retry_state.kwargs["partition"]
|
|
37
|
+
return not partition.removed.is_set()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class S3Filesystem:
|
|
41
|
+
_client: S3Client
|
|
42
|
+
_config: S3Config
|
|
43
|
+
|
|
44
|
+
def __init__(self: "S3Filesystem", config: S3Config) -> None:
|
|
45
|
+
self._config = config
|
|
46
|
+
self._client = boto3.Session(
|
|
47
|
+
profile_name=config.profile,
|
|
48
|
+
).client("s3")
|
|
49
|
+
|
|
50
|
+
def get_partition_files(
|
|
51
|
+
self: "S3Filesystem",
|
|
52
|
+
partition: Partition,
|
|
53
|
+
) -> set[FileKey]:
|
|
54
|
+
remote_files: set[FileKey] = set()
|
|
55
|
+
|
|
56
|
+
for page in self._client.get_paginator("list_objects_v2").paginate(
|
|
57
|
+
Bucket=self._config.bucket,
|
|
58
|
+
Prefix=self._config.get_partition_prefix(partition),
|
|
59
|
+
):
|
|
60
|
+
for obj in page.get("Contents", []):
|
|
61
|
+
if "Key" not in obj or "Size" not in obj:
|
|
62
|
+
continue
|
|
63
|
+
|
|
64
|
+
remote_files.add(
|
|
65
|
+
FileKey(
|
|
66
|
+
path=(
|
|
67
|
+
# normalize the path to how it will appear in the actual
|
|
68
|
+
# filesystem (i.e., replace the partition prefix with /)
|
|
69
|
+
Path("/")
|
|
70
|
+
/ Path(obj["Key"]).relative_to(
|
|
71
|
+
self._config.get_partition_prefix(partition)
|
|
72
|
+
)
|
|
73
|
+
),
|
|
74
|
+
size=obj["Size"],
|
|
75
|
+
)
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
return remote_files
|
|
79
|
+
|
|
80
|
+
@retry(
|
|
81
|
+
stop=stop_after_attempt(5),
|
|
82
|
+
wait=wait_exponential(multiplier=2, max=60),
|
|
83
|
+
retry=(
|
|
84
|
+
retry_if_exception_type((S3UploadFailedError, BotoCoreError, ClientError))
|
|
85
|
+
& _retry_unless_partition_removed()
|
|
86
|
+
),
|
|
87
|
+
before_sleep=before_sleep_log(_logger, logging.WARNING),
|
|
88
|
+
reraise=True,
|
|
89
|
+
)
|
|
90
|
+
def upload_partition_file(
|
|
91
|
+
self: "S3Filesystem",
|
|
92
|
+
partition: Partition,
|
|
93
|
+
filesystem: TskFilesystem,
|
|
94
|
+
file_key: FileKey,
|
|
95
|
+
callback: UploadProgress | None = None,
|
|
96
|
+
) -> None:
|
|
97
|
+
partition.check_removed()
|
|
98
|
+
|
|
99
|
+
s3_key: str = self._config.get_partition_file_key(
|
|
100
|
+
partition,
|
|
101
|
+
file_key.path,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
with TskReader(
|
|
105
|
+
filesystem.open(file_key.path),
|
|
106
|
+
cancel_event=partition.removed,
|
|
107
|
+
) as reader:
|
|
108
|
+
self._client.upload_fileobj(
|
|
109
|
+
reader, # type: ignore
|
|
110
|
+
Bucket=self._config.bucket,
|
|
111
|
+
Key=s3_key,
|
|
112
|
+
Callback=callback,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
# check that the file actually exists. head_object() will raise
|
|
116
|
+
# ClientError for us if the file doesn't exist which @retry
|
|
117
|
+
# will catch
|
|
118
|
+
self._client.head_object(
|
|
119
|
+
Bucket=self._config.bucket,
|
|
120
|
+
Key=s3_key,
|
|
121
|
+
)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from collections.abc import Generator
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import pytsk3
|
|
5
|
+
|
|
6
|
+
from ..devices import Partition
|
|
7
|
+
from .filekey import FileKey
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TskFilesystem:
|
|
11
|
+
_partition: Partition
|
|
12
|
+
_filesystem: pytsk3.FS_Info
|
|
13
|
+
|
|
14
|
+
def __init__(self: "TskFilesystem", partition: Partition) -> None:
|
|
15
|
+
self._partition = partition
|
|
16
|
+
self._filesystem = pytsk3.FS_Info(
|
|
17
|
+
pytsk3.Img_Info(
|
|
18
|
+
partition.device.device_node,
|
|
19
|
+
)
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
def _walk_files(
|
|
23
|
+
self: "TskFilesystem",
|
|
24
|
+
directory: pytsk3.Directory,
|
|
25
|
+
path: Path = Path("/"),
|
|
26
|
+
) -> Generator[FileKey]:
|
|
27
|
+
"""
|
|
28
|
+
Discover files in a filesystem.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
for entry in directory:
|
|
32
|
+
# get the name for this entry as string. if this is the current directory
|
|
33
|
+
# or parent directory, ignore it
|
|
34
|
+
entry_name: str = entry.info.name.name.decode("utf-8", "replace")
|
|
35
|
+
if entry_name in [".", ".."]:
|
|
36
|
+
continue
|
|
37
|
+
|
|
38
|
+
# ignore deleted/unallocated entries with no metadata
|
|
39
|
+
if entry.info.meta is None:
|
|
40
|
+
continue
|
|
41
|
+
|
|
42
|
+
entry_path: Path = path / entry_name
|
|
43
|
+
|
|
44
|
+
# if this is a directory, recurse into it
|
|
45
|
+
if entry.info.meta.type == pytsk3.TSK_FS_META_TYPE_DIR:
|
|
46
|
+
yield from self._walk_files(
|
|
47
|
+
entry.as_directory(),
|
|
48
|
+
entry_path,
|
|
49
|
+
)
|
|
50
|
+
else:
|
|
51
|
+
yield FileKey(
|
|
52
|
+
path=entry_path,
|
|
53
|
+
size=entry.info.meta.size,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
def discover_files(
|
|
57
|
+
self: "TskFilesystem",
|
|
58
|
+
root_path: Path = Path("/"),
|
|
59
|
+
) -> set[FileKey]:
|
|
60
|
+
return set(
|
|
61
|
+
self._walk_files(
|
|
62
|
+
self._filesystem.open_dir(str(root_path)),
|
|
63
|
+
)
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
def open(self: "TskFilesystem", path: Path):
|
|
67
|
+
return self._filesystem.open(path.as_posix())
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import io
|
|
2
|
+
from threading import Event, Lock
|
|
3
|
+
from typing import Annotated, override, TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
import pytsk3
|
|
6
|
+
|
|
7
|
+
from ..backup import PartitionRemoved
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from _typeshed import MaybeNone, WriteableBuffer
|
|
11
|
+
|
|
12
|
+
_tsk_lock: Annotated[
|
|
13
|
+
Lock, "Lock for pytsk3 operations since pytsk3 is not threadsafe"
|
|
14
|
+
] = Lock()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TskReader(io.RawIOBase):
|
|
18
|
+
_file: pytsk3.File
|
|
19
|
+
_size: int
|
|
20
|
+
_position: int
|
|
21
|
+
_cancel_event: Event | None
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self: "TskReader", file: pytsk3.File, cancel_event: Event | None = None
|
|
25
|
+
) -> None:
|
|
26
|
+
self._file = file
|
|
27
|
+
self._size = file.info.meta.size
|
|
28
|
+
self._position = 0
|
|
29
|
+
self._cancel_event = cancel_event
|
|
30
|
+
|
|
31
|
+
@override
|
|
32
|
+
def readable(self: "TskReader") -> bool:
|
|
33
|
+
return True
|
|
34
|
+
|
|
35
|
+
@override
|
|
36
|
+
def seekable(self: "TskReader") -> bool:
|
|
37
|
+
return True
|
|
38
|
+
|
|
39
|
+
def seek(self: "TskReader", offset: int, whence: int = io.SEEK_SET) -> int:
|
|
40
|
+
match whence:
|
|
41
|
+
case io.SEEK_SET:
|
|
42
|
+
self._position = 0 + offset
|
|
43
|
+
case io.SEEK_CUR:
|
|
44
|
+
self._position += offset
|
|
45
|
+
case io.SEEK_END:
|
|
46
|
+
self._position = self._size
|
|
47
|
+
case _:
|
|
48
|
+
raise ValueError(f"Unknown whence value {whence}")
|
|
49
|
+
|
|
50
|
+
return self._position
|
|
51
|
+
|
|
52
|
+
@override
|
|
53
|
+
def readinto(self, buffer: WriteableBuffer, /) -> int | MaybeNone:
|
|
54
|
+
if self._cancel_event is not None and self._cancel_event.is_set():
|
|
55
|
+
raise PartitionRemoved("Partition was removed during read operation")
|
|
56
|
+
|
|
57
|
+
# get the target number of bytes for this read. it's either the
|
|
58
|
+
# remaining bytes available of length of the target buffer,
|
|
59
|
+
# whichever's smaller
|
|
60
|
+
byte_count: int = min(len(buffer), self._size - self._position)
|
|
61
|
+
if byte_count <= 0:
|
|
62
|
+
return 0
|
|
63
|
+
|
|
64
|
+
# read the bytes with a mutex since pytsk3 isn't threadsafe
|
|
65
|
+
data: bytes
|
|
66
|
+
with _tsk_lock:
|
|
67
|
+
data = self._file.read_random(self._position, byte_count)
|
|
68
|
+
|
|
69
|
+
# put the result in the buffer and seek our position ahead
|
|
70
|
+
buffer[: len(data)] = data
|
|
71
|
+
self._position += len(data)
|
|
72
|
+
|
|
73
|
+
return len(data)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from threading import Lock
|
|
2
|
+
|
|
3
|
+
from rich.progress import Progress, TaskID
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class UploadProgress:
|
|
7
|
+
"""boto3 Callback that feeds a per-file task and an overall task."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, progress: Progress, overall: TaskID, task: TaskID) -> None:
|
|
10
|
+
self._progress = progress
|
|
11
|
+
self._overall = overall
|
|
12
|
+
self._task = task
|
|
13
|
+
self._sent = 0
|
|
14
|
+
self._lock = Lock()
|
|
15
|
+
|
|
16
|
+
def __call__(self, n: int) -> None:
|
|
17
|
+
with self._lock:
|
|
18
|
+
self._sent += n
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
self._progress.advance(self._task, n)
|
|
22
|
+
self._progress.advance(self._overall, n)
|
|
23
|
+
except KeyError:
|
|
24
|
+
...
|
|
25
|
+
|
|
26
|
+
def reset(self) -> None:
|
|
27
|
+
"""Undo this file's contribution before a retry."""
|
|
28
|
+
with self._lock:
|
|
29
|
+
sent, self._sent = self._sent, 0
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
self._progress.advance(self._overall, -sent)
|
|
33
|
+
self._progress.reset(self._task)
|
|
34
|
+
except KeyError:
|
|
35
|
+
...
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from queue import Queue
|
|
3
|
+
from threading import Thread
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from .backup import make_backup_thread, S3Config
|
|
8
|
+
from .devices import MonitorConfig, Partition, make_monitor_thread
|
|
9
|
+
from .logging import setup_logging
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
_logger: logging.Logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@click.command()
|
|
16
|
+
@click.option(
|
|
17
|
+
"--exclude-device",
|
|
18
|
+
"-e",
|
|
19
|
+
multiple=True,
|
|
20
|
+
help="Exclude a specific block device",
|
|
21
|
+
required=False,
|
|
22
|
+
type=click.Path(
|
|
23
|
+
exists=False,
|
|
24
|
+
file_okay=True,
|
|
25
|
+
dir_okay=False,
|
|
26
|
+
readable=False,
|
|
27
|
+
),
|
|
28
|
+
)
|
|
29
|
+
@click.option(
|
|
30
|
+
"--include-device",
|
|
31
|
+
"-i",
|
|
32
|
+
multiple=True,
|
|
33
|
+
help="Include a specific block device",
|
|
34
|
+
required=False,
|
|
35
|
+
type=click.Path(
|
|
36
|
+
exists=False,
|
|
37
|
+
file_okay=True,
|
|
38
|
+
dir_okay=False,
|
|
39
|
+
readable=True,
|
|
40
|
+
),
|
|
41
|
+
)
|
|
42
|
+
@click.option(
|
|
43
|
+
"--profile",
|
|
44
|
+
"-p",
|
|
45
|
+
help="AWS profile to use for S3 access",
|
|
46
|
+
required=False,
|
|
47
|
+
type=str,
|
|
48
|
+
default="",
|
|
49
|
+
)
|
|
50
|
+
@click.option(
|
|
51
|
+
"--bucket",
|
|
52
|
+
"-b",
|
|
53
|
+
help="S3 bucket to use for backups",
|
|
54
|
+
required=True,
|
|
55
|
+
type=str,
|
|
56
|
+
)
|
|
57
|
+
@click.option(
|
|
58
|
+
"--prefix",
|
|
59
|
+
"-x",
|
|
60
|
+
help="S3 prefix to use for backups",
|
|
61
|
+
required=False,
|
|
62
|
+
type=str,
|
|
63
|
+
default="f5",
|
|
64
|
+
)
|
|
65
|
+
@click.option(
|
|
66
|
+
"--allow-fixed/--no-allow-fixed",
|
|
67
|
+
help="Allow partitions from fixed devices to be backed up",
|
|
68
|
+
required=False,
|
|
69
|
+
type=bool,
|
|
70
|
+
default=False,
|
|
71
|
+
)
|
|
72
|
+
@click.version_option()
|
|
73
|
+
def cli(
|
|
74
|
+
include_device: tuple[str, ...],
|
|
75
|
+
exclude_device: tuple[str, ...],
|
|
76
|
+
profile: str,
|
|
77
|
+
bucket: str,
|
|
78
|
+
prefix: str,
|
|
79
|
+
allow_fixed: bool,
|
|
80
|
+
) -> None:
|
|
81
|
+
"""
|
|
82
|
+
f5 - Automatic backups to S3-compatible storage
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
setup_logging()
|
|
86
|
+
|
|
87
|
+
# start a monitor thread to watch for new partitions and a
|
|
88
|
+
# backup thread to handle backups of those partitions
|
|
89
|
+
partition_queue: Queue[Partition] = Queue()
|
|
90
|
+
threads: list[Thread] = [
|
|
91
|
+
make_backup_thread(
|
|
92
|
+
partition_queue=partition_queue,
|
|
93
|
+
s3_config=S3Config(
|
|
94
|
+
profile=profile,
|
|
95
|
+
bucket=bucket,
|
|
96
|
+
prefix=prefix,
|
|
97
|
+
),
|
|
98
|
+
),
|
|
99
|
+
make_monitor_thread(
|
|
100
|
+
partition_queue=partition_queue,
|
|
101
|
+
config=MonitorConfig(
|
|
102
|
+
allow_fixed=allow_fixed,
|
|
103
|
+
include_devices=set(include_device),
|
|
104
|
+
exclude_devices=set(exclude_device),
|
|
105
|
+
),
|
|
106
|
+
),
|
|
107
|
+
]
|
|
108
|
+
for thread in threads:
|
|
109
|
+
thread.start()
|
|
110
|
+
_logger.info(f"Started thread {thread.name}")
|
|
111
|
+
for thread in threads:
|
|
112
|
+
thread.join()
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from queue import Queue
|
|
2
|
+
from threading import Thread
|
|
3
|
+
|
|
4
|
+
from .monitorconfig import MonitorConfig
|
|
5
|
+
from .partition import Partition
|
|
6
|
+
from .partitionremoved import PartitionRemoved
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"MonitorConfig",
|
|
10
|
+
"Partition",
|
|
11
|
+
"PartitionRemoved",
|
|
12
|
+
"make_monitor_thread",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def make_monitor_thread(
|
|
17
|
+
partition_queue: Queue[Partition],
|
|
18
|
+
config: MonitorConfig,
|
|
19
|
+
) -> Thread:
|
|
20
|
+
from .monitorworker import MonitorWorker
|
|
21
|
+
|
|
22
|
+
return MonitorWorker(
|
|
23
|
+
partition_queue=partition_queue,
|
|
24
|
+
config=config,
|
|
25
|
+
)
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from queue import Queue
|
|
3
|
+
from threading import Lock, Thread
|
|
4
|
+
|
|
5
|
+
import pyudev
|
|
6
|
+
|
|
7
|
+
from .monitorconfig import MonitorConfig
|
|
8
|
+
from ..devices import Partition
|
|
9
|
+
|
|
10
|
+
_logger: logging.Logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _get_device_names(device: pyudev.Device) -> set[str]:
|
|
14
|
+
"""All names a config entry might use to refer to this device."""
|
|
15
|
+
|
|
16
|
+
# get all of /dev/disk/by-{id,path,uuid,partuuid,label}/...
|
|
17
|
+
names: set[str] = set(device.device_links)
|
|
18
|
+
|
|
19
|
+
if device.device_node:
|
|
20
|
+
names.add(device.device_node) # /dev/sdb, /dev/sdb1
|
|
21
|
+
|
|
22
|
+
# add the ID_PATH if it exists which is a unique identifier for the port
|
|
23
|
+
# the device is on
|
|
24
|
+
if id_path := device.properties.get("ID_PATH"):
|
|
25
|
+
names.add(id_path) # pci-0000:00:14.0-usb-0:1:1.0-scsi-0:0:0:0
|
|
26
|
+
|
|
27
|
+
return names
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class MonitorWorker(Thread):
|
|
31
|
+
_active_partitions: dict[str, Partition]
|
|
32
|
+
_config: MonitorConfig
|
|
33
|
+
_device_context: pyudev.Context
|
|
34
|
+
_monitor: pyudev.Monitor
|
|
35
|
+
_partition_queue_mutex: Lock
|
|
36
|
+
_partition_queue: Queue[Partition]
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self: "MonitorWorker",
|
|
40
|
+
partition_queue: Queue[Partition],
|
|
41
|
+
config: MonitorConfig,
|
|
42
|
+
daemon: bool = True,
|
|
43
|
+
**kwargs,
|
|
44
|
+
) -> None:
|
|
45
|
+
super().__init__(daemon=daemon, **kwargs)
|
|
46
|
+
|
|
47
|
+
self._config = config
|
|
48
|
+
self._device_context = pyudev.Context()
|
|
49
|
+
self._monitor = pyudev.Monitor.from_netlink(self._device_context)
|
|
50
|
+
self._monitor.filter_by(subsystem="block")
|
|
51
|
+
|
|
52
|
+
self._active_partitions = {}
|
|
53
|
+
self._partition_queue_mutex = Lock()
|
|
54
|
+
self._partition_queue = partition_queue
|
|
55
|
+
|
|
56
|
+
def run(self: "MonitorWorker") -> None:
|
|
57
|
+
# push events for all connected partitions on startup
|
|
58
|
+
for device in self._device_context.list_devices(
|
|
59
|
+
subsystem="block",
|
|
60
|
+
DEVTYPE="partition",
|
|
61
|
+
):
|
|
62
|
+
self._handle_device(device)
|
|
63
|
+
|
|
64
|
+
# then, start an observer to listen for new connection events
|
|
65
|
+
observer: pyudev.MonitorObserver = pyudev.MonitorObserver(
|
|
66
|
+
self._monitor,
|
|
67
|
+
callback=self._handle_device,
|
|
68
|
+
)
|
|
69
|
+
observer.start()
|
|
70
|
+
observer.join()
|
|
71
|
+
|
|
72
|
+
def _device_included(self: "MonitorWorker", *devices: pyudev.Device | None) -> bool:
|
|
73
|
+
# collate all of the names for the given devices into a single set
|
|
74
|
+
device_names: set[str] = set()
|
|
75
|
+
for device in devices:
|
|
76
|
+
if device is not None:
|
|
77
|
+
device_names |= _get_device_names(device)
|
|
78
|
+
|
|
79
|
+
# if the include list is non-empty, then the device must be in it to be included
|
|
80
|
+
if self._config.include_devices and device_names.isdisjoint(
|
|
81
|
+
self._config.include_devices
|
|
82
|
+
):
|
|
83
|
+
return False
|
|
84
|
+
|
|
85
|
+
# otherwise, check that the device is not in the exclude list
|
|
86
|
+
return device_names.isdisjoint(self._config.exclude_devices)
|
|
87
|
+
|
|
88
|
+
def _handle_device(self: "MonitorWorker", device: pyudev.Device) -> None:
|
|
89
|
+
# ignore things that aren't partitions with filesystems
|
|
90
|
+
if device.device_type != "partition":
|
|
91
|
+
return
|
|
92
|
+
|
|
93
|
+
if not self._device_included(device, self._find_disk_parent(device)):
|
|
94
|
+
_logger.info(
|
|
95
|
+
f"Detected device {device.device_node} is excluded by config, ignoring"
|
|
96
|
+
)
|
|
97
|
+
return
|
|
98
|
+
|
|
99
|
+
if (
|
|
100
|
+
# device.action will be None when we feed in the partitions at startup
|
|
101
|
+
device.action is None or device.action == "add"
|
|
102
|
+
) and device.device_type == "partition":
|
|
103
|
+
if not self._device_is_removable(device) and not self._config.allow_fixed:
|
|
104
|
+
_logger.info(
|
|
105
|
+
f"Detected non-removable device {device.device_node}, ignoring"
|
|
106
|
+
)
|
|
107
|
+
return
|
|
108
|
+
|
|
109
|
+
self._enqueue_partition(device=device)
|
|
110
|
+
elif device.action == "remove" and device.device_type == "partition":
|
|
111
|
+
self._dequeue_partition(device=device)
|
|
112
|
+
|
|
113
|
+
def _device_is_removable(self: "MonitorWorker", device: pyudev.Device) -> bool:
|
|
114
|
+
# seek up to the parent device that is the actual disk
|
|
115
|
+
while device is not None and device.get("DEVTYPE") != "disk":
|
|
116
|
+
device = device.parent
|
|
117
|
+
|
|
118
|
+
if device is None:
|
|
119
|
+
return False
|
|
120
|
+
|
|
121
|
+
return bool(device.attributes.asint("removable"))
|
|
122
|
+
|
|
123
|
+
def _find_disk_parent(
|
|
124
|
+
self: "MonitorWorker", device: pyudev.Device
|
|
125
|
+
) -> pyudev.Device | None:
|
|
126
|
+
return device.find_parent("block", "disk")
|
|
127
|
+
|
|
128
|
+
def _enqueue_partition(
|
|
129
|
+
self: "MonitorWorker",
|
|
130
|
+
device: pyudev.Device,
|
|
131
|
+
) -> None:
|
|
132
|
+
parent_device: pyudev.Device = self._find_disk_parent(device)
|
|
133
|
+
|
|
134
|
+
_logger.info(
|
|
135
|
+
f"Detected new partition {device.device_node} on device "
|
|
136
|
+
f"{parent_device.device_node if parent_device else 'unknown'}"
|
|
137
|
+
)
|
|
138
|
+
new_partition: Partition = Partition(
|
|
139
|
+
device=device,
|
|
140
|
+
parent_device=parent_device,
|
|
141
|
+
uuid=device.properties["ID_FS_UUID"],
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
with self._partition_queue_mutex:
|
|
145
|
+
self._partition_queue.put(new_partition)
|
|
146
|
+
self._active_partitions[new_partition.uuid] = new_partition
|
|
147
|
+
|
|
148
|
+
def _dequeue_partition(
|
|
149
|
+
self: "MonitorWorker",
|
|
150
|
+
device: pyudev.Device,
|
|
151
|
+
) -> None:
|
|
152
|
+
parent_device: pyudev.Device | None = self._find_disk_parent(device)
|
|
153
|
+
|
|
154
|
+
_logger.info(
|
|
155
|
+
f"Detected removal of partition {device.device_node} on device "
|
|
156
|
+
f"{parent_device.device_node if parent_device else 'unknown'}"
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
with self._partition_queue_mutex:
|
|
160
|
+
# remove the partition from the queue if it exists
|
|
161
|
+
for partition in list(self._partition_queue.queue):
|
|
162
|
+
if partition.device.device_node == device.device_node:
|
|
163
|
+
self._partition_queue.queue.remove(partition)
|
|
164
|
+
_logger.info(f"Removed partition {device.device_node} from queue")
|
|
165
|
+
|
|
166
|
+
# set the removed event for this partition
|
|
167
|
+
fs_uuid: str = device.properties["ID_FS_UUID"]
|
|
168
|
+
if fs_uuid in self._active_partitions:
|
|
169
|
+
self._active_partitions[fs_uuid].removed.set()
|
|
170
|
+
self._active_partitions.pop(fs_uuid)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from threading import Event
|
|
2
|
+
from typing import ClassVar
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
5
|
+
import pyudev
|
|
6
|
+
|
|
7
|
+
from .partitionremoved import PartitionRemoved
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Partition(BaseModel):
|
|
11
|
+
device: pyudev.Device
|
|
12
|
+
parent_device: pyudev.Device
|
|
13
|
+
uuid: str
|
|
14
|
+
removed: Event = Field(default_factory=Event)
|
|
15
|
+
|
|
16
|
+
model_config: ClassVar[ConfigDict] = ConfigDict(
|
|
17
|
+
arbitrary_types_allowed=True,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
def check_removed(self: "Partition") -> None:
|
|
21
|
+
if self.removed.is_set():
|
|
22
|
+
raise PartitionRemoved(f"{self.device.device_node} was removed")
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from rich.logging import RichHandler
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def setup_logging(level: logging._Level = logging.INFO) -> None:
|
|
6
|
+
"""
|
|
7
|
+
Setup logging configuration with RichHandler for better console output.
|
|
8
|
+
|
|
9
|
+
Args:
|
|
10
|
+
level (logging._Level): The logging level to set. Default is logging.INFO.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
logging.basicConfig(
|
|
14
|
+
level=level,
|
|
15
|
+
format="[%(name)s] %(message)s",
|
|
16
|
+
datefmt="[%X]",
|
|
17
|
+
handlers=[
|
|
18
|
+
RichHandler(
|
|
19
|
+
rich_tracebacks=True,
|
|
20
|
+
markup=False,
|
|
21
|
+
)
|
|
22
|
+
],
|
|
23
|
+
)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "f5-backup"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Automatic backups to S3-compatible storage"
|
|
5
|
+
authors = [
|
|
6
|
+
{name = "Will Hinson",email = "will@hinson.sh"}
|
|
7
|
+
]
|
|
8
|
+
license = {text = "MIT"}
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.13"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"click (>=8.5.0,<9.0.0)",
|
|
13
|
+
"pydantic (>=2.13.5,<3.0.0)",
|
|
14
|
+
"boto3 (>=1.43.105,<2.0.0)",
|
|
15
|
+
"pyudev (>=0.24.5,<0.25.0)",
|
|
16
|
+
"rich (>=15.0.0,<16.0.0)",
|
|
17
|
+
"insights-core (>=3.8.2,<4.0.0)",
|
|
18
|
+
"pytsk3 (>=20260715,<20260716)",
|
|
19
|
+
"humanize (>=4.16.0,<5.0.0)",
|
|
20
|
+
"tenacity (>=9.1.4,<10.0.0)",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[tool.poetry]
|
|
24
|
+
packages = [{ include = "f5" }]
|
|
25
|
+
|
|
26
|
+
[tool.poetry.scripts]
|
|
27
|
+
f5 = "f5.cli:cli"
|
|
28
|
+
|
|
29
|
+
[dependency-groups]
|
|
30
|
+
dev = [
|
|
31
|
+
"pytest (>=9.1.1,<10.0.0)",
|
|
32
|
+
"mypy (>=2.3.1,<3.0.0)",
|
|
33
|
+
"ruff (>=0.16.9,<0.17.0)",
|
|
34
|
+
"boto3-stubs[s3] (>=1.43.105,<2.0.0)"
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
[build-system]
|
|
39
|
+
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
|
40
|
+
build-backend = "poetry.core.masonry.api"
|