refactor
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from subprocess import Popen, call, check_output, PIPE
|
||||
|
||||
from zfs_backup.misc import assert_type
|
||||
|
||||
|
||||
class CommandInterface:
|
||||
_BINARY: Path
|
||||
|
||||
@classmethod
|
||||
def _run_command(cls, *args: str) -> None:
|
||||
CommandRunner.run([str(cls._BINARY), *args])
|
||||
|
||||
@classmethod
|
||||
def _get_output(cls, *args: str) -> str:
|
||||
return CommandRunner.get_output([str(cls._BINARY), *args])
|
||||
|
||||
@classmethod
|
||||
def _open_stream(cls, *args: str) -> Popen:
|
||||
return CommandRunner.send_stream([str(cls._BINARY), *args])
|
||||
|
||||
@classmethod
|
||||
def _receive_stream(cls, *args: str, stream: Popen) -> None:
|
||||
CommandRunner.receive_from_stream([str(cls._BINARY), *args], stream=stream)
|
||||
|
||||
|
||||
class CommandRunner:
|
||||
@staticmethod
|
||||
def _sanity_check(cmdline: list[str]) -> None:
|
||||
assert_type(cmdline, list, "Command line must be a list")
|
||||
for item in cmdline:
|
||||
assert_type(item, str, "Only strings allowed in command line")
|
||||
|
||||
@staticmethod
|
||||
def _to_list(command: str | list[str]) -> list[str]:
|
||||
"""If the command is a str, return it as list. Otherwise, return the command."""
|
||||
if isinstance(command, str):
|
||||
return [command]
|
||||
return command
|
||||
|
||||
@classmethod
|
||||
def run(cls, cmdline: str | list[str]) -> None:
|
||||
"""Run the command, wait for it to return."""
|
||||
cmdline = cls._to_list(cmdline)
|
||||
cls._sanity_check(cmdline)
|
||||
call(cmdline)
|
||||
|
||||
@classmethod
|
||||
def get_output(cls, cmdline: str | list[str]) -> str:
|
||||
"""Run the command, return the output of the command."""
|
||||
cmdline = cls._to_list(cmdline)
|
||||
cls._sanity_check(cmdline)
|
||||
return check_output(cmdline).strip().decode()
|
||||
|
||||
@classmethod
|
||||
def receive_from_stream(cls, cmdline: str | list[str], stream: Popen) -> None:
|
||||
"""Run the command, receive data from a pipe to stdin."""
|
||||
cmdline = cls._to_list(cmdline)
|
||||
cls._sanity_check(cmdline)
|
||||
check_output(cmdline, stdin=stream.stdout)
|
||||
|
||||
@classmethod
|
||||
def send_stream(cls, cmdline: str | list[str]) -> Popen:
|
||||
"""Run the command, redirect stdout to a pipe."""
|
||||
cmdline = cls._to_list(cmdline)
|
||||
cls._sanity_check(cmdline)
|
||||
return Popen(cmdline, stdout=PIPE)
|
||||
@@ -0,0 +1,85 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from ._core import CommandInterface
|
||||
from ..messages import MAPPER_ENTRY_ALREADY_EXISTS, CANNOT_FIND_DEVICE_PATH
|
||||
from ..misc import MAPPER_PATH, DISK_BY_UUID
|
||||
|
||||
|
||||
class Cryptsetup(CommandInterface):
|
||||
class _Subcommands:
|
||||
close = "close"
|
||||
status = "status"
|
||||
open = "open"
|
||||
|
||||
_BINARY = Path("/usr/bin/cryptsetup")
|
||||
|
||||
@classmethod
|
||||
def encrypt(cls, mapper_entry: str) -> None:
|
||||
"""Close a previously open LUKS container."""
|
||||
cls._run_command(cls._Subcommands.close, mapper_entry)
|
||||
|
||||
@classmethod
|
||||
def _status(cls, name: str) -> str:
|
||||
"""Return `cryptsetup status` of a device under `/dev/mapper`"""
|
||||
return cls._get_output(cls._Subcommands.status, name)
|
||||
|
||||
@classmethod
|
||||
def decrypt(cls, path: Path, mapper_entry: str) -> bool:
|
||||
"""Decrypt a disk, return whether it was already decrypted."""
|
||||
if cls._mapper_entry_in_use(mapper_entry):
|
||||
if cls._already_decrypted(path, mapper_entry):
|
||||
return True
|
||||
print(MAPPER_ENTRY_ALREADY_EXISTS)
|
||||
raise FileExistsError()
|
||||
cls._run_command(cls._Subcommands.open, str(path), mapper_entry)
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def _mapper_entry_in_use(cls, mapper_entry: str) -> bool:
|
||||
return (MAPPER_PATH / mapper_entry).exists()
|
||||
|
||||
@classmethod
|
||||
def _get_device_by_uuid_from_status_output(cls, status: str) -> Path:
|
||||
"""Given the output of `cryptsetup status [device]`,
|
||||
get the unique name of the disk as path in /dev/disk/by-uuid."""
|
||||
device = cls._get_device_path(status)
|
||||
result = cls._resolve_uuid_of_device(device)
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def _resolve_uuid_of_device(cls, device):
|
||||
"""Given a device like `/dev/sda`, resolve its entry under `/dev/disk/by-uuid`.
|
||||
|
||||
Exit if none can be found."""
|
||||
for item in DISK_BY_UUID.iterdir():
|
||||
if item.readlink() == device:
|
||||
result = item
|
||||
break
|
||||
else:
|
||||
raise FileNotFoundError(
|
||||
f"Could not resolve UUID of cryptsetup device {device}"
|
||||
)
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def _get_device_path(cls, status) -> Path:
|
||||
"""Given the output of `cryptsetup status`, return the path of the device."""
|
||||
for line in status.split("\n"):
|
||||
if line.startswith(" device:"):
|
||||
device = Path(line.split(":")[-1].strip())
|
||||
break
|
||||
else:
|
||||
raise FileNotFoundError(CANNOT_FIND_DEVICE_PATH)
|
||||
# noinspection PyUnboundLocalVariable
|
||||
return device
|
||||
|
||||
@classmethod
|
||||
def _already_decrypted(cls, path: Path, mapper_entry: str) -> bool:
|
||||
"""Given a path in `/dev/disk/by-uuid` and the corresponding name under `/dev/mapper`,
|
||||
check if the disk is already decrypted."""
|
||||
decrypted_disk_path = cls._get_device_by_uuid_from_status_output(
|
||||
cls._status(mapper_entry)
|
||||
)
|
||||
return decrypted_disk_path == path
|
||||
@@ -0,0 +1,109 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from subprocess import Popen
|
||||
|
||||
from .. import Pool
|
||||
from ._core import CommandInterface
|
||||
from ..dataset import Dataset
|
||||
from ..messages import SAME_DATASET
|
||||
from ..misc import assert_type
|
||||
from ..snapshot import Snapshot
|
||||
from ..zfs_path import ZFSPath
|
||||
|
||||
|
||||
class ZFS(CommandInterface):
|
||||
|
||||
"""Interface of `zfs` commands"""
|
||||
|
||||
class _Subcommands:
|
||||
class List:
|
||||
# -H Scripting mode, omit headers
|
||||
# -d1 maximum depth of 1
|
||||
# -o name output column
|
||||
# -t object type
|
||||
# -r recursive
|
||||
_base = ["list", "-H", "-d1", "-o", "name"]
|
||||
dataset = [*_base, "-t", "filesystem"]
|
||||
snapshot = [*_base, "-t", "snapshot", "-r"]
|
||||
|
||||
class Send:
|
||||
# -R Replicate filesystem
|
||||
# -I send all intermediary snapshots
|
||||
absolute = ["send", "-R"]
|
||||
incremental = [*absolute, "-I"]
|
||||
|
||||
destroy = ["destroy"]
|
||||
# -d Discard the first element of the "send" snapshot's file system name
|
||||
# -F Force a rollback of the file system to the most recent snapshot before performing the "receive".
|
||||
# -u File system that is associated with the received stream is not mounted.
|
||||
receive = ["receive", "-d", "-F", "-u"]
|
||||
|
||||
_BINARY = Path("/usr/bin/zfs")
|
||||
|
||||
@classmethod
|
||||
def get_first_level_datasets(cls, pool: Pool) -> list[Dataset]:
|
||||
"""Get the first level datasets of a pool.
|
||||
|
||||
If a pool `p` has the datasets `p/a`, `p/a/b` and `p/c`, return `[p/a, p/c]`."""
|
||||
assert_type(pool, Pool, "Can only get datasets from Pool objects")
|
||||
datasets_str = cls._get_output(*cls._Subcommands.List.dataset, pool.name)
|
||||
# we omit the first return value, which is the pool, not the dataset
|
||||
datasets_list = datasets_str.split("\n")[1:]
|
||||
return [Dataset(ZFSPath.from_string(d)) for d in datasets_list]
|
||||
|
||||
@classmethod
|
||||
def get_snapshots(cls, dataset: Dataset) -> list[Snapshot]:
|
||||
"""Get list of snapshots that are associated with a dataset."""
|
||||
assert_type(dataset, Dataset, "Can only get snapshots from Dataset object")
|
||||
command = [*cls._Subcommands.List.snapshot, dataset.qualified_name]
|
||||
snapshot_names: list[str] = cls._get_output(*command).split("\n")
|
||||
return [Snapshot(ZFSPath.from_string(name)) for name in snapshot_names]
|
||||
|
||||
@classmethod
|
||||
def destroy_snapshot(cls, snapshot: Snapshot) -> None:
|
||||
"""Non-recursively destroy a snapshot."""
|
||||
assert_type(snapshot, Snapshot, "Can only destroy snapshots.")
|
||||
cls._run_command(*cls._Subcommands.destroy, str(snapshot))
|
||||
|
||||
@classmethod
|
||||
def send_incremental(
|
||||
cls, old_snapshot: Snapshot, new_snapshot: Snapshot
|
||||
) -> Popen[str]:
|
||||
"""Send the incremental replicating stream between two snapshots."""
|
||||
cls._pre_send_sanity_checks(old_snapshot, new_snapshot)
|
||||
print(f" {old_snapshot} -> {new_snapshot}")
|
||||
return cls._open_stream(
|
||||
*cls._Subcommands.Send.incremental,
|
||||
str(old_snapshot),
|
||||
str(new_snapshot),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _pre_send_sanity_checks(
|
||||
cls, old_snapshot: Snapshot, new_snapshot: Snapshot
|
||||
) -> None:
|
||||
for snapshot in [old_snapshot, new_snapshot]:
|
||||
assert_type(
|
||||
snapshot,
|
||||
Snapshot,
|
||||
f"Cannot send stream, object is not a Snapshot: {snapshot}",
|
||||
)
|
||||
if old_snapshot.dataset != new_snapshot.dataset:
|
||||
raise ValueError(SAME_DATASET)
|
||||
if old_snapshot.newer_than(new_snapshot):
|
||||
raise ValueError("Old snapshot is newer than new snapshot.")
|
||||
if old_snapshot == new_snapshot:
|
||||
raise ValueError("Cannot send stream, snapshots are identical.")
|
||||
|
||||
@classmethod
|
||||
def receive(cls, stream: Popen[str], pool: Pool):
|
||||
"""Receive a stream generated with `zfs send` into a pool."""
|
||||
assert_type(pool, Pool, "Can only receive into pools.")
|
||||
cls._receive_stream(*cls._Subcommands.receive, pool.name, stream=stream)
|
||||
|
||||
@classmethod
|
||||
def send_absolute(cls, snapshot: Snapshot) -> Popen:
|
||||
"""Send the non-incremental replicating stream of the snapshot."""
|
||||
print(f" {snapshot} -> [new]")
|
||||
return cls._open_stream(*cls._Subcommands.Send.absolute, str(snapshot))
|
||||
@@ -0,0 +1,96 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from ._core import CommandInterface
|
||||
from ..pool import Pool
|
||||
from ..misc import assert_type
|
||||
from ..time import Time
|
||||
|
||||
|
||||
class ZPool(CommandInterface):
|
||||
_BINARY = Path("/usr/bin/zpool")
|
||||
|
||||
class _Subcommands:
|
||||
# -N: no mount
|
||||
# -d: directory to search the pool in
|
||||
import_ = ["import", "-N", "-d"]
|
||||
export = ["export"]
|
||||
scrub = ["scrub"]
|
||||
status = ["status"]
|
||||
wait = ["wait", "-t", "scrub"]
|
||||
|
||||
@classmethod
|
||||
def import_from_directory(cls, pool: Pool, directory: Path) -> None:
|
||||
"""Search `directory` for `pool` and import it."""
|
||||
assert_type(pool, Pool, "Can only import Pool objects")
|
||||
cls._run_command(*cls._Subcommands.import_, str(directory), pool.name)
|
||||
|
||||
@classmethod
|
||||
def export(cls, pool: Pool) -> None:
|
||||
"""Export the pool."""
|
||||
assert_type(pool, Pool, "Can only export Pool objects")
|
||||
cls._run_command(*cls._Subcommands.export, pool.name)
|
||||
|
||||
@classmethod
|
||||
def scrub(
|
||||
cls,
|
||||
pool: Pool,
|
||||
*,
|
||||
skip_if_recently_scrubbed: bool,
|
||||
recent_scrub_timedelta: timedelta,
|
||||
wait_for_finish: bool = True,
|
||||
) -> None:
|
||||
"""Scrub the pool.
|
||||
|
||||
Waits for the scrub to finish. Raise an IOError if the pool reports as not healthy after the scrub."""
|
||||
assert_type(pool, Pool, "Can only scrub pools.")
|
||||
cls._scrub_if_necessary(pool, skip_if_recently_scrubbed, recent_scrub_timedelta)
|
||||
if wait_for_finish:
|
||||
cls._run_command(*cls._Subcommands.wait, pool.name)
|
||||
if not cls._is_healthy(pool):
|
||||
raise IOError(f"Pool {pool.name} is not healthy.")
|
||||
|
||||
@classmethod
|
||||
def _scrub_if_necessary(
|
||||
cls,
|
||||
pool: Pool,
|
||||
skip_if_recently_scrubbed: bool,
|
||||
recent_scrub_timedelta: timedelta,
|
||||
) -> None:
|
||||
"""Scrub the pool. If"""
|
||||
|
||||
def do_scrub():
|
||||
cls._run_command(*cls._Subcommands.scrub, pool.name)
|
||||
|
||||
if cls._scrub_in_progress(pool):
|
||||
return
|
||||
if not skip_if_recently_scrubbed:
|
||||
do_scrub()
|
||||
else:
|
||||
if not cls._recently_scrubbed(pool, recent_scrub_timedelta):
|
||||
do_scrub()
|
||||
|
||||
@classmethod
|
||||
def _recently_scrubbed(cls, pool: Pool, recent_scrub_timedelta: timedelta) -> bool:
|
||||
"""Is the interval after the last scrub smaller than the minimum timedelta?"""
|
||||
return datetime.now() - cls._last_scrub(pool) < recent_scrub_timedelta
|
||||
|
||||
@classmethod
|
||||
def _scrub_in_progress(cls, pool: Pool) -> bool:
|
||||
return "scrub in progress" in cls._get_pool_status_output(pool)
|
||||
|
||||
@classmethod
|
||||
def _is_healthy(cls, pool: Pool) -> bool:
|
||||
return "ONLINE" in cls._get_pool_status_output(pool)
|
||||
|
||||
@classmethod
|
||||
def _get_pool_status_output(cls, pool: Pool) -> str:
|
||||
"""Return output of `zpool status [pool]`."""
|
||||
return cls._get_output(*cls._Subcommands.status, pool.name)
|
||||
|
||||
@classmethod
|
||||
def _last_scrub(cls, pool: Pool) -> datetime:
|
||||
"""Get time of the last scrub."""
|
||||
return Time.get_last_scrub_from_status_output(cls._get_pool_status_output(pool))
|
||||
Reference in New Issue
Block a user