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
3 changes: 1 addition & 2 deletions .github/workflows/ci-shell-interface.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ jobs:
strategy:
matrix:
python-version: ["3.11", "3.12", "3.13", "3.14"]
dependency-extras: ["", "--extra logging"]

steps:
- uses: actions/checkout@v7
Expand All @@ -18,7 +17,7 @@ jobs:
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: uv sync --package shell-interface ${{ matrix.dependency-extras }}
run: uv sync --package shell-interface
- name: Check formatting with ruff
run: uv run --package shell-interface ruff format --check projects/shell-interface
- name: Check import ordering with ruff
Expand Down
3 changes: 1 addition & 2 deletions .github/workflows/ci-storage-device-managers.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ jobs:
strategy:
matrix:
python-version: ["3.11", "3.12", "3.13", "3.14"]
dependency-extras: ["", "--extra logging"]

steps:
- uses: actions/checkout@v7
Expand All @@ -18,7 +17,7 @@ jobs:
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: uv sync --package storage-device-managers ${{ matrix.dependency-extras }}
run: uv sync --package storage-device-managers
- name: Check formatting with ruff
run: uv run --package storage-device-managers ruff format --check projects/storage-device-managers
- name: Check import ordering with ruff
Expand Down
24 changes: 24 additions & 0 deletions conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
from collections import abc
from pathlib import Path

import pytest

import shell_interface as sh


@pytest.fixture
def root_owned_tmp_path(tmp_path: Path) -> abc.Iterable[Path]:
"""
Create a temporary directory owned by root for testing
"""
root_owned_path = tmp_path / "root_owned"
root_owned_path.mkdir()
current_user = sh.get_user()
current_group = sh.get_group(current_user)
sh.chown(root_owned_path, user="root", group="root", recursive=False)
try:
yield root_owned_path
finally:
sh.chown(
root_owned_path, user=current_user, group=current_group, recursive=False
)
28 changes: 13 additions & 15 deletions projects/butter-backup/src/butter_backup/backup_backends.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,11 @@
from typing import overload

import shell_interface as sh
import storage_device_managers as sdm
from loguru import logger

from . import config_parser as cp


def _refresh_sudo(sudo_pass_cmd: str | None) -> None:
if sudo_pass_cmd is not None:
sh.pipe_pass_cmd_to_real_cmd(
sudo_pass_cmd, ["sudo", "-Sv"], capture_output=True
)


class BackupBackend(abc.ABC):
@abc.abstractmethod
def do_backup(self, mount_dir: Path, sudo_pass_cmd: str | None = None) -> None: ...
Expand Down Expand Up @@ -52,20 +44,20 @@ def do_backup(self, mount_dir: Path, sudo_pass_cmd: str | None = None) -> None:
backup_root = self.snapshot(
src=src_snapshot, backup_repository=backup_repository
)
_refresh_sudo(sudo_pass_cmd)
sh.refresh_sudo(sudo_pass_cmd)
self.adapt_ownership(backup_root)

for src, dest_name in self.config.Folders.items():
dest = backup_root / dest_name
_refresh_sudo(sudo_pass_cmd)
sh.refresh_sudo(sudo_pass_cmd)
self.rsync_folder(src, dest, self.config.ExcludePatternsFile)

files_dest = backup_root / self.config.FilesDest
if files_dest.is_file():
files_dest.unlink()
files_dest.mkdir(parents=True, exist_ok=True)
for src in self.config.Files:
_refresh_sudo(sudo_pass_cmd)
sh.refresh_sudo(sudo_pass_cmd)
self.rsync_file(src, files_dest)

@staticmethod
Expand All @@ -90,7 +82,7 @@ def adapt_ownership(snapshot_root: Path) -> None:
# correct ownership.
# Therefore, it is believed that writing test that fails if `recursive=True` is
# currently impossible.
sdm.chown(snapshot_root, user, group, recursive=False)
sh.chown(snapshot_root, user, group, recursive=False)

def snapshot(self, *, src: Path, backup_repository: Path) -> Path:
timestamp = dt.datetime.now()
Expand Down Expand Up @@ -150,9 +142,9 @@ class ResticBackend(BackupBackend):
def do_backup(self, mount_dir: Path, sudo_pass_cmd: str | None = None) -> None:
logger.info(f"Beginne mit Restic-Backup für Speichermedium {self.config.Name}.")
backup_repository = mount_dir / self.config.BackupRepositoryFolder
_refresh_sudo(sudo_pass_cmd)
sh.refresh_sudo(sudo_pass_cmd)
self.copy_files(backup_repository)
_refresh_sudo(sudo_pass_cmd)
sh.refresh_sudo(sudo_pass_cmd)
self.adapt_ownership(backup_repository)

@staticmethod
Expand All @@ -165,9 +157,15 @@ def adapt_ownership(backup_repository: Path) -> None:
user=user,
group=group,
)
sdm.chown(backup_repository, user, group, recursive=True)
sh.chown(backup_repository, user, group, recursive=True)

def copy_files(self, backup_repository: Path) -> None:
if len(self.config.FilesAndFolders) == 0:
logger.warning(
"Es wurden keine Dateien oder Ordner zum Sichern angegeben. "
"Es wird kein Backup durchgeführt."
)
return
restic_cmd: sh.StrPathList = [
"sudo",
"restic",
Expand Down
37 changes: 6 additions & 31 deletions projects/butter-backup/src/butter_backup/cli.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
#!/usr/bin/env python3
import contextlib
import enum
import json
import os
Expand Down Expand Up @@ -99,13 +98,6 @@ def _get_default_file_system(backend: ValidBackends) -> ValidFileSystems:
t.assert_never(backend)


def _refresh_sudo(sudo_pass_cmd: str | None) -> None:
if sudo_pass_cmd is not None:
sh.pipe_pass_cmd_to_real_cmd(
sudo_pass_cmd, ["sudo", "-Sv"], capture_output=True
)


def _skip_device(
config: cp.DeviceConfiguration,
*,
Expand Down Expand Up @@ -144,8 +136,8 @@ def _open_device(
mount_dir = base_dir / cfg.Name
topmost_created_ancestor = None
try:
_refresh_sudo(sudo_pass_cmd)
topmost_created_ancestor = sdm.ensure_directory(mount_dir)
sh.refresh_sudo(sudo_pass_cmd)
topmost_created_ancestor = sh.ensure_directory(mount_dir)
decrypted = sdm.open_encrypted_device(cfg.device(), cfg.DevicePassCmd)
sdm.mount_device(decrypted, mount_dir=mount_dir, compression=cfg.compression())
except Exception:
Expand All @@ -156,28 +148,11 @@ def _open_device(
f"Speichermedium {cfg.Name} konnte nicht geöffnet werden. Es wird übersprungen."
)
if topmost_created_ancestor is not None:
_rmdir_ancestor_path(start=topmost_created_ancestor, stop=base_dir)
sh.rmdir_up_to(start=topmost_created_ancestor, stop=base_dir)
else:
typer.echo(f"Speichermedium {cfg.Name} wurde in {mount_dir} geöffnet.")


def _rmdir_ancestor_path(start: Path, stop: Path) -> None:
"""
Remove all directories from `start` to `stop` (inclusive).

The directories are removed in a bottom-up manner. Execution stops at the first
non-empty directory or directly after removing stop, whatever comes first.
"""
if not start.is_relative_to(stop):
raise ValueError(f"Start path {start} is not a subpath of stop path {stop}.")
current = start
while current.is_relative_to(stop):
with contextlib.suppress(sh.ShellInterfaceError):
cmd: sh.StrPathList = ["sudo", "rmdir", current]
sh.run_cmd(cmd=cmd)
current = current.parent


@app.command()
def open( # noqa: A001
config: t.Annotated[Path | None, CONFIG_OPTION] = None,
Expand Down Expand Up @@ -245,7 +220,7 @@ def close(
device=cfg.Name,
)
continue
_refresh_sudo(parsed_config.SudoPassCmd)
sh.refresh_sudo(parsed_config.SudoPassCmd)
sdm.unmount_device(map_name)
sdm.close_decrypted_device(map_name)

Expand Down Expand Up @@ -287,7 +262,7 @@ def backup(
):
continue
backend = bb.BackupBackend.from_config(cfg)
_refresh_sudo(parsed_config.SudoPassCmd)
sh.refresh_sudo(parsed_config.SudoPassCmd)
open_dir = parsed_config.OpenDirectory
dest = open_dir / cfg.Name if open_dir is not None else None
with (
Expand All @@ -300,7 +275,7 @@ def backup(
# A backup could take so long that the sudo session expires. In this
# case the user would have to enter the password again to unmount and
# close the device. To prevent this, the sudo session is refreshed.
_refresh_sudo(parsed_config.SudoPassCmd)
sh.refresh_sudo(parsed_config.SudoPassCmd)


@app.command()
Expand Down
10 changes: 4 additions & 6 deletions projects/butter-backup/src/butter_backup/device_managers.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,7 @@ def prepare_device_for_butterbackend(device: Path) -> cp.BtrFSRsyncConfig:
sdm.mkfs(decrypted, "btrfs")
with sdm.mounted_device(decrypted) as mounted:
backup_repository = mounted / backup_repository_folder
mkdir_cmd: sh.StrPathList = ["sudo", "mkdir", backup_repository]
sh.run_cmd(cmd=mkdir_cmd)
sh.ensure_directory(backup_repository)

initial_subvol = backup_repository / date.today().strftime(
cp.BtrFSRsyncConfig.SubvolTimestampFmt
Expand All @@ -35,7 +34,7 @@ def prepare_device_for_butterbackend(device: Path) -> cp.BtrFSRsyncConfig:
initial_subvol,
]
sh.run_cmd(cmd=subvol_cmd)
sdm.chown(mounted, user, group, recursive=True)
sh.chown(mounted, user, group, recursive=True)

config = cp.BtrFSRsyncConfig(
BackupRepositoryFolder=backup_repository_folder,
Expand Down Expand Up @@ -63,17 +62,16 @@ def prepare_device_for_resticbackend(
sdm.mkfs(decrypted, file_system)
with sdm.mounted_device(decrypted) as mounted:
backup_repo = mounted / backup_repository_folder
mkdir_repo: sh.StrPathList = ["sudo", "mkdir", backup_repo]
restic_init: sh.StrPathList = [
"sudo",
"restic",
"init",
"-r",
backup_repo,
]
sh.run_cmd(cmd=mkdir_repo)
sh.ensure_directory(backup_repo)
sh.pipe_pass_cmd_to_real_cmd(repository_passcmd, restic_init)
sdm.chown(mounted, user, group, recursive=True)
sh.chown(mounted, user, group, recursive=True)
config = cp.ResticConfig(
BackupRepositoryFolder=backup_repository_folder,
DevicePassCmd=device_passcmd,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@

import pytest
import shell_interface as sh
import storage_device_managers as sdm

from butter_backup import backup_backends as bb
from butter_backup import config_parser as cp
Expand Down Expand Up @@ -65,7 +64,7 @@ def test_btrfs_backend_gracefully_handles_existing_snapshots_owned_by_root(
latest_snapshot = sorted(snapshot_root.iterdir())[-1]
for cur in itertools.chain(snapshot_root.glob("*"), snapshot_root.glob("*/*")):
print(f"Changing ownership of {cur} to root:root")
sdm.chown(cur, "root", "root", recursive=False)
sh.chown(cur, "root", "root", recursive=False)
sh.run_cmd(cmd=["sudo", "rm", "-rf", latest_snapshot / first_config.FilesDest])

second_config = run_backup_cycle(empty_config, second_source, device)
Expand Down Expand Up @@ -105,7 +104,7 @@ def test_btrfs_backend_refreshes_sudo_session_in_do_backup(
UUID=uuid4(),
)
backend = bb.BtrFSRsyncBackend(config=config)
mock_refresh = mocker.patch("butter_backup.backup_backends._refresh_sudo")
mock_refresh = mocker.patch("butter_backup.backup_backends.sh.refresh_sudo")
mocker.patch.object(
bb.BtrFSRsyncBackend, "get_source_snapshot", return_value=tmp_path
)
Expand Down Expand Up @@ -140,7 +139,7 @@ def test_do_backup_for_btrfs_rsync_preserves_ownership_of_source_files(
source_dir = tmp_path / cur.name
shutil.copytree(cur, source_dir)
rm_cmd: sh.StrPathList = ["sudo", "rm", "-r", source_dir]
sdm.chown(source_dir, f"{test_owner_uid}", f"{test_group_gid}", recursive=True)
sh.chown(source_dir, f"{test_owner_uid}", f"{test_group_gid}", recursive=True)
config = run_backup_cycle(empty_config, source_dir, device)
# remove source dir to avoid permission issues with pytest and "user" 1337
sh.run_cmd(cmd=rm_cmd)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ def test_restic_backend_refreshes_sudo_session_in_do_backup(
UUID=uuid4(),
)
backend = bb.ResticBackend(config=config)
mock_refresh = mocker.patch("butter_backup.backup_backends._refresh_sudo")
mock_refresh = mocker.patch("butter_backup.backup_backends.sh.refresh_sudo")
mocker.patch.object(bb.ResticBackend, "copy_files")
mocker.patch.object(bb.ResticBackend, "adapt_ownership")

Expand Down
Loading
Loading