Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions dockerfiles/arch
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ RUN touch README.md # Content is not relevant for this Dockerfile
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-install-project

COPY examples/ examples/
COPY src/ src/
RUN uv sync --frozen

Expand Down
1 change: 1 addition & 0 deletions dockerfiles/debian
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-install-project

# Install project
COPY examples/ examples/
COPY src/ src/
RUN uv sync --frozen

Expand Down
1 change: 1 addition & 0 deletions dockerfiles/python3.11
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-install-project

# Install project
COPY examples/ examples/
COPY src/ src/
RUN uv sync --frozen

Expand Down
1 change: 1 addition & 0 deletions dockerfiles/python3.12
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-install-project

# Install project
COPY examples/ examples/
COPY src/ src/
RUN uv sync --frozen

Expand Down
1 change: 1 addition & 0 deletions dockerfiles/python3.13
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-install-project

# Install project
COPY examples/ examples/
COPY src/ src/
RUN uv sync --frozen

Expand Down
1 change: 1 addition & 0 deletions dockerfiles/python3.14
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-install-project

# Install project
COPY examples/ examples/
COPY src/ src/
RUN uv sync --frozen

Expand Down
6 changes: 6 additions & 0 deletions src/butter_backup/backup_backends.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,11 @@ def adapt_ownership(backup_repository: Path) -> None:
)
sdm.chown(backup_repository, user, group, recursive=True)

@staticmethod
def sync_filesystem_changes(mount_dir: Path) -> None:
sync_cmd: sh.StrPathList = ["sudo", "sync", "-f", mount_dir]
sh.run_cmd(cmd=sync_cmd)

def copy_files(self, backup_repository: Path) -> None:
restic_cmd: sh.StrPathList = [
"sudo",
Expand All @@ -166,3 +171,4 @@ def copy_files(self, backup_repository: Path) -> None:
restic_cmd.extend(["--exclude-file", self.config.ExcludePatternsFile])
restic_cmd.extend(list(self.config.FilesAndFolders))
sh.pipe_pass_cmd_to_real_cmd(self.config.RepositoryPassCmd, restic_cmd)
self.sync_filesystem_changes(backup_repository)
70 changes: 36 additions & 34 deletions src/butter_backup/cli.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#!/usr/bin/env python3
import contextlib
import enum
import json
import os
Expand Down Expand Up @@ -65,17 +66,15 @@ def _get_default_file_system(backend: ValidBackends) -> ValidFileSystems:
return ValidFileSystems.btrfs
case ValidBackends.restic:
return ValidFileSystems.ext4
# TODO: Use t.assert_never when Python 3.11 is the minimum version!
raise ValueError(
f"Unsupported backend: {backend}. Expected one of: {list(ValidBackends)}"
)
case _:
t.assert_never(backend)


def _skip_device(
config: cp.BaseConfig,
config: cp.Configuration,
*,
log_missing: Callable[[cp.BaseConfig], None] | None = None,
log_opened: Callable[[cp.BaseConfig], None] | None = None,
log_missing: Callable[[cp.Configuration], None] | None = None,
log_opened: Callable[[cp.Configuration], None] | None = None,
) -> bool:
"""
Helper function to determine whether a device should be skipped.
Expand Down Expand Up @@ -103,9 +102,29 @@ def _skip_device(
VERBOSITY_OPTION = typer.Option(0, "--verbose", "-v", count=True)


def _open_device(cfg: cp.Configuration, base_dir: Path) -> None:
mount_dir = base_dir / cfg.Name
mount_dir.mkdir(exist_ok=True)
try:
decrypted = sdm.open_encrypted_device(cfg.device(), cfg.DevicePassCmd)
sdm.mount_btrfs_device(
decrypted, mount_dir=mount_dir, compression=cfg.compression()
)
except:
typer.echo(
f"Speichermedium {cfg.Name} konnte nicht geöffnet werden. Es wird übersprungen."
)
with contextlib.suppress(OSError):
mount_dir.rmdir()
else:
typer.echo(f"Speichermedium {cfg.Name} wurde in {mount_dir} geöffnet.")


@app.command()
def open( # noqa: A001
config: Path = CONFIG_OPTION, verbose: int = VERBOSITY_OPTION
dest: Path | None = typer.Argument(None), # noqa: B008
config: Path = CONFIG_OPTION,
verbose: int = VERBOSITY_OPTION,
) -> None:
"""
Öffne alle in der Konfiguration gelisteten Speichermedien
Expand All @@ -120,9 +139,14 @@ def open( # noqa: A001
kann mit den Daten interagiert werden, z.B. durch Öffnen im Dateibrowser
oder durch Verwendung von `restic`. Nach erfolgreicher Wiederherstellung
kann das Speichermedium mit `butter-backup close` wieder entfernt werden.

Optional kann ein Zielverzeichnis angegeben werden. Wenn angegeben, werden
die Speichermedien in Unterverzeichnissen dieses Verzeichnisses gemountet.
Andernfalls wird ein temporäres Verzeichnis erstellt.
"""
setup_logging(verbose)
configurations = cp.parse_configuration(config.read_text())
base_dir = dest if dest is not None else Path(mkdtemp())
for cfg in configurations:
if _skip_device(
cfg,
Expand All @@ -131,18 +155,7 @@ def open( # noqa: A001
),
):
continue
mount_dir = Path(mkdtemp())
decrypted = sdm.open_encrypted_device(cfg.device(), cfg.DevicePassCmd)
match cfg:
case cp.BtrFSRsyncConfig():
sdm.mount_btrfs_device(
decrypted, mount_dir=mount_dir, compression=cfg.Compression
)
case cp.ResticConfig():
sdm.mount_btrfs_device(decrypted, mount_dir=mount_dir)
case _:
t.assert_never(cfg)
typer.echo(f"Speichermedium {cfg.Name} wurde in {mount_dir} geöffnet.")
_open_device(cfg, base_dir)


@app.command()
Expand All @@ -169,7 +182,6 @@ def close(config: Path = CONFIG_OPTION, verbose: int = VERBOSITY_OPTION) -> None
mount_dir = next(iter(mount_dirs))
sdm.unmount_device(mount_dir)
sdm.close_decrypted_device(Path(mapped_device))
mount_dir.rmdir()


@app.command()
Expand Down Expand Up @@ -207,14 +219,7 @@ def backup(config: Path = CONFIG_OPTION, verbose: int = VERBOSITY_OPTION) -> Non
continue
backend = bb.BackupBackend.from_config(cfg)
with sdm.decrypted_device(cfg.device(), cfg.DevicePassCmd) as decrypted:
match cfg:
case cp.BtrFSRsyncConfig():
compression = cfg.Compression
case cp.ResticConfig():
compression = None
case _:
t.assert_never(cfg)
with sdm.mounted_device(decrypted, compression) as mount_dir:
with sdm.mounted_device(decrypted, cfg.compression()) as mount_dir:
backend.do_backup(mount_dir)


Expand Down Expand Up @@ -271,17 +276,14 @@ def format_device(
"Zieldatei für ButterBackup-Konfiguration existiert schon!"
)
config_writer = config_to.write_text
config: cp.BaseConfig
config: cp.Configuration
match backend:
case ValidBackends.btrfs_rsync:
config = prepare_device_for_butterbackend(device)
case ValidBackends.restic:
config = prepare_device_for_resticbackend(device, file_system.value)
case _:
# TODO: Use t.assert_never when Python 3.11 is the minimum version!
raise ValueError(
f"Unsupported backend: {backend}. Expected one of: {list(ValidBackends)}"
)
t.assert_never(backend)
json_serialisable = json.loads(config.model_dump_json(exclude_none=True))
config_writer(json.dumps([json_serialisable], indent=4, sort_keys=True))

Expand Down
19 changes: 18 additions & 1 deletion src/butter_backup/config_parser.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import abc
import json
import sys
import tomllib
Expand Down Expand Up @@ -37,7 +38,7 @@ def path_aware_restic_json_decoding(
return json.dumps(as_dict)


class BaseConfig(BaseModel):
class BaseConfig(BaseModel, abc.ABC):
BackupRepositoryFolder: str
DevicePassCmd: str
ExcludePatternsFile: FilePath | None = None
Expand Down Expand Up @@ -65,6 +66,13 @@ def set_default_name(cls, data: Any) -> Any:
@field_validator("Name")
@classmethod
def name_must_be_valid_path_component(cls, name: str) -> str:
if Path(name).is_absolute():
# Appending an absolute path to another path would ignore the other path, so
# the combined path would point to the first absolute path and not to a
# subdirectory of the other path.
raise ValueError(
f"Name {name!r} ist ungültig, da er ein absoluter Pfad ist."
)
with TemporaryDirectory() as tmpdir:
test_dir = Path(tmpdir) / name
try:
Expand Down Expand Up @@ -92,6 +100,9 @@ def device(self) -> Path:
def map_name(self) -> Path:
return Path(f"/dev/mapper/{self.UUID}")

@abc.abstractmethod
def compression(self) -> ValidCompressions | None: ...


class BtrFSRsyncConfig(BaseConfig):
model_config = ConfigDict(extra="forbid", frozen=True)
Expand Down Expand Up @@ -149,6 +160,9 @@ def raise_with_message_upon_duplicate(
)
raise ValueError(f"{errmsg_begin} {errmsg_body}")

def compression(self) -> ValidCompressions | None:
return self.Compression


class ResticConfig(BaseConfig):
model_config = ConfigDict(extra="forbid", frozen=True)
Expand All @@ -160,6 +174,9 @@ def expand_tilde_in_sources(cls, files_and_folders) -> set[str]:
new = {str(Path(src).expanduser()) for src in files_and_folders}
return new

def compression(self) -> None:
return None


Configuration = BtrFSRsyncConfig | ResticConfig

Expand Down
5 changes: 1 addition & 4 deletions src/butter_backup/device_managers.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,7 @@ def format_device(device: Path, file_system: ValidFileSystems) -> None:
case "btrfs":
sdm.mkfs_btrfs(device)
case _:
# TODO: Use t.assert_never when Python 3.11 is the minimum version!
raise ValueError(
f"Unsupported file system {file_system} for Restic backend."
)
t.assert_never(file_system)


def mkfs_ext4(device: Path) -> None:
Expand Down
34 changes: 16 additions & 18 deletions tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,21 +31,19 @@ def complement_configuration(
source_dir / "etc" / "fstab",
source_dir / "cache" / "randomfile.bin",
}
if isinstance(config, cp.BtrFSRsyncConfig):
folder_dest_dir = "some-folder-name"
return config.model_copy(
update={
"Folders": {folders_root: folder_dest_dir},
"Files": single_files,
"FilesDest": "Einzeldateien",
}
)
if isinstance(config, cp.ResticConfig):
return config.model_copy(
update={"FilesAndFolders": {folders_root}.union(single_files)}
)
# TODO: Use t.assert_never when Python 3.11 is the minimum version!
raise TypeError(
f"Unsupported configuration type: {type(config).__name__}. "
"Expected BtrFSRsyncConfig or ResticConfig."
)
match config:
case cp.BtrFSRsyncConfig():
folder_dest_dir = "some-folder-name"
return config.model_copy(
update={
"Folders": {folders_root: folder_dest_dir},
"Files": single_files,
"FilesDest": "Einzeldateien",
}
)
case cp.ResticConfig():
return config.model_copy(
update={"FilesAndFolders": {folders_root}.union(single_files)}
)
case _:
t.assert_never(config)
7 changes: 1 addition & 6 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,13 +119,8 @@ def encrypted_device(request) -> cp.Configuration:
@pytest.fixture
def mounted_device(encrypted_device) -> t.Iterator[tuple[cp.Configuration, Path]]:
config = encrypted_device
match config:
case cp.BtrFSRsyncConfig():
compression = config.Compression
case cp.ResticConfig():
compression = None
with sdm.decrypted_device(config.device(), config.DevicePassCmd) as decrypted:
with sdm.mounted_device(decrypted, compression) as mounted_device:
with sdm.mounted_device(decrypted, config.compression()) as mounted_device:
if isinstance(config, cp.BtrFSRsyncConfig):
# Ensure `FilesDest` is a file, initially. This ensures correct handling
# of single files backup, even if an existing backup suffered from the
Expand Down
2 changes: 1 addition & 1 deletion tests/hypothesis_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ def filenames(draw, min_size=1) -> str:
@st.composite
def valid_path_components(draw, min_size=1) -> str:
name: str = draw(
st.text(min_size=min_size).filter(
st.text(min_size=min_size, max_size=128).filter(
lambda n: "/" not in n and "\x00" not in n and n not in {".", ".."}
)
)
Expand Down
Loading
Loading