f5-backup 0.1.0__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.
f5/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ __all__ = [
2
+ "backup",
3
+ "cli",
4
+ ]
5
+
6
+ from . import backup
7
+ from . import cli
f5/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import cli
2
+
3
+ cli()
f5/backup/__init__.py ADDED
@@ -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
+ )
f5/backup/filekey.py ADDED
@@ -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
+ )
f5/backup/s3config.py ADDED
@@ -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())
f5/backup/tskreader.py ADDED
@@ -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
+ ...
f5/cli.py ADDED
@@ -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()
f5/devices/__init__.py ADDED
@@ -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,7 @@
1
+ from pydantic import BaseModel
2
+
3
+
4
+ class MonitorConfig(BaseModel):
5
+ allow_fixed: bool
6
+ include_devices: set[str]
7
+ exclude_devices: set[str]
@@ -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,4 @@
1
+ class PartitionRemoved(RuntimeError):
2
+ """
3
+ Raised when a partition has been removed and check_removed() is called
4
+ """
f5/logging.py ADDED
@@ -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,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,20 @@
1
+ f5/__init__.py,sha256=vKPm2qexU40UVsb7Eh-wbVVzJqEEl5eukpNlYYrCPAU,79
2
+ f5/__main__.py,sha256=Huz0dExiaH0XTMLhfe3skkFD9-VH7ZUiWMbDR_J8TIY,28
3
+ f5/backup/__init__.py,sha256=WXsU2vDlNU0HB3-miGV5Q7ZdKICHtKVH6b6G2Lzb59I,6127
4
+ f5/backup/filekey.py,sha256=RQSWRHbqYSRTDVvWYCdJto_ekfMe3A5JbNr3o5Aqc7o,483
5
+ f5/backup/s3config.py,sha256=43D3bcp-neUgWyMdpsFi00XF-8V2LwrhkiQ1RSUjrME,636
6
+ f5/backup/s3filesystem.py,sha256=6kmwBGh9au6gc69xbjw25y5lJT7mW4JhnS1BjM5wsSc,3627
7
+ f5/backup/tskfilesystem.py,sha256=-38VZeRnUb1htUNJ8uDS--O3_4RGGQYAmmVRPbNGBcY,1941
8
+ f5/backup/tskreader.py,sha256=mLBCCNvj6b8n05ss3du1jTLkI1aulHmmLcVin15PAwY,2183
9
+ f5/backup/uploadprogress.py,sha256=zlNVxzR87g1Kol_FZl3xypUJmUIZmpw9WTeFDDTfodI,964
10
+ f5/cli.py,sha256=frd90fPHEaynrAl642j2RnKPMpqqaXBFl5lu8iBTl8w,2533
11
+ f5/devices/__init__.py,sha256=IHanX_ClUplOWx2s33tAFhsT6yUMGSSpawYBOdMjuQ0,527
12
+ f5/devices/monitorconfig.py,sha256=WGiuG5XDA_pow3ureX1sgvQ4khr0ooZMtAiTedwhxUM,147
13
+ f5/devices/monitorworker.py,sha256=loSJyFeyJRykov6FTwKyZqmu0pioOLQv64YLFHzIWrU,6183
14
+ f5/devices/partition.py,sha256=4knEOYDDGnbBycwYqB5HXDa7xwcJm7HQmrCgFG2s5qw,582
15
+ f5/devices/partitionremoved.py,sha256=zcM_DeTkPX7rPGVFL-KbMgHhIqb4MI7pwnXtff2Mnmg,129
16
+ f5/logging.py,sha256=Qp6LIsCS6Zi4Hj4PJfIVDwg-HDy0LR6oiXQlA8cMIDQ,560
17
+ f5_backup-0.1.0.dist-info/METADATA,sha256=CZRCitBI0m15gfxrSbxsaeWbUuln-0MpwHK5HevwIxY,866
18
+ f5_backup-0.1.0.dist-info/WHEEL,sha256=L-WvLdSBvJWHXv5zrQxDvNRnIgoBzLUpcbuiw19KCuQ,88
19
+ f5_backup-0.1.0.dist-info/entry_points.txt,sha256=W2j9iOh2U5garKHFVQ1ak2XPrAS4g6KXvP93SxThorU,33
20
+ f5_backup-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 2.5.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ f5=f5.cli:cli
3
+