diff --git a/bottles/backend/managers/backup.py b/bottles/backend/managers/backup.py index 7a60311521..4ba32ce428 100644 --- a/bottles/backend/managers/backup.py +++ b/bottles/backend/managers/backup.py @@ -24,6 +24,7 @@ from bottles.backend.globals import Paths from bottles.backend.logger import Logger from bottles.backend.managers.manager import Manager +from bottles.backend.models.btrfssubvolume import duplicate_bottle_as_subvolume, DuplicateResult from bottles.backend.models.config import BottleConfig from bottles.backend.models.result import Result from bottles.backend.state import TaskManager, Task @@ -185,26 +186,28 @@ def _duplicate_bottle_directory( config: BottleConfig, source_path: str, destination_path: str, new_name: str ) -> Result: try: - if not os.path.exists(destination_path): + duplicate_result = duplicate_bottle_as_subvolume(source_path, destination_path) + if not duplicate_result.destination_directories_created(): os.makedirs(destination_path) - for item in [ - "drive_c", - "system.reg", - "user.reg", - "userdef.reg", - "bottle.yml", - ]: - source_item = os.path.join(source_path, item) - destination_item = os.path.join(destination_path, item) - if os.path.isdir(source_item): - shutil.copytree( - source_item, - destination_item, - ignore=shutil.ignore_patterns(".*"), - symlinks=True, - ) - elif os.path.isfile(source_item): - shutil.copy(source_item, destination_item) + if not duplicate_result.bottle_contents_is_duplicated(): + for item in [ + "drive_c", + "system.reg", + "user.reg", + "userdef.reg", + "bottle.yml", + ]: + source_item = os.path.join(source_path, item) + destination_item = os.path.join(destination_path, item) + if os.path.isdir(source_item): + shutil.copytree( + source_item, + destination_item, + ignore=shutil.ignore_patterns(".*"), + symlinks=True, + ) + elif os.path.isfile(source_item): + shutil.copy(source_item, destination_item) # Update the bottle configuration config_path = os.path.join(destination_path, "bottle.yml") diff --git a/bottles/backend/managers/btrfssubvolume.py b/bottles/backend/managers/btrfssubvolume.py new file mode 100644 index 0000000000..76b2218632 --- /dev/null +++ b/bottles/backend/managers/btrfssubvolume.py @@ -0,0 +1,22 @@ +import bottles.backend.models.btrfssubvolume as btrfssubvolume + +class BtrfsSubvolumeManager: + """ + Manager to handle bottles created as btrfs subvolume. + """ + + def __init__( + self, + manager, + ): + self._manager = manager + + @staticmethod + def create_bottle_as_subvolume(bottle_path) -> bool: + return btrfssubvolume.create_bottle_as_subvolume(bottle_path) + + @staticmethod + def delete_all_snapshots(bottle_path): + snapshots_handle = btrfssubvolume.try_create_bottle_snapshots_handle(bottle_path) + if snapshots_handle: + snapshots_handle.delete_all_snapshots() diff --git a/bottles/backend/managers/manager.py b/bottles/backend/managers/manager.py index 5f99fb6782..535279b7f3 100644 --- a/bottles/backend/managers/manager.py +++ b/bottles/backend/managers/manager.py @@ -36,6 +36,7 @@ from bottles.backend.dlls.vkd3d import VKD3DComponent from bottles.backend.globals import Paths from bottles.backend.logger import Logger +from bottles.backend.managers.btrfssubvolume import BtrfsSubvolumeManager from bottles.backend.managers.component import ComponentManager from bottles.backend.managers.data import DataManager, UserDataKeys from bottles.backend.managers.dependency import DependencyManager @@ -145,6 +146,7 @@ def __init__( times["RepositoryManager"] = time.time() self.versioning_manager = VersioningManager(self) times["VersioningManager"] = time.time() + self.btrfs_subvolume_manager = BtrfsSubvolumeManager(self) self.component_manager = ComponentManager(self, _offline) self.installer_manager = InstallerManager(self, _offline) self.dependency_manager = DependencyManager(self, _offline) @@ -1060,17 +1062,14 @@ def create_bottle_from_config(self, config: BottleConfig) -> bool: # create the bottle path bottle_path = os.path.join(Paths.bottles, config.Name) - if not os.path.exists(bottle_path): - """ - If the bottle does not exist, create it, else - append a random number to the name. - """ - os.makedirs(bottle_path) - else: + # If the bottle exists append a random number to the name. + if os.path.exists(bottle_path): rnd = random.randint(100, 200) bottle_path = f"{bottle_path}__{rnd}" config.Name = f"{config.Name}__{rnd}" config.Path = f"{config.Path}__{rnd}" + + if not self.btrfs_subvolume_manager.create_bottle_as_subvolume(bottle_path): os.makedirs(bottle_path) # Pre-create drive_c directory and set the case-fold flag @@ -1232,22 +1231,17 @@ def components_check(): bottle_name_path = f"{bottle_name_path}__{rnd}" bottle_complete_path = f"{bottle_complete_path}__{rnd}" - # define registers that should be awaited - reg_files = [ - os.path.join(bottle_complete_path, "system.reg"), - os.path.join(bottle_complete_path, "user.reg"), - ] - # create the bottle directory try: - os.makedirs(bottle_complete_path) + if not self.btrfs_subvolume_manager.create_bottle_as_subvolume(bottle_complete_path): + os.makedirs(bottle_complete_path) # Pre-create drive_c directory and set the case-fold flag bottle_drive_c = os.path.join(bottle_complete_path, "drive_c") os.makedirs(bottle_drive_c) FileUtils.chattr_f(bottle_drive_c) - except: + except RuntimeError as e: logging.error( - f"Failed to create bottle directory: {bottle_complete_path}", jn=True + f"Failed to create bottle directory '{bottle_complete_path}' {e}", jn=True ) log_update(_("Failed to create bottle directory.")) return Result(False) @@ -1353,6 +1347,12 @@ def components_check(): os.unlink(link) os.makedirs(link) + # define registers that should be awaited + reg_files = [ + os.path.join(bottle_complete_path, "system.reg"), + os.path.join(bottle_complete_path, "user.reg"), + ] + # wait for registry files to be created FileUtils.wait_for_files(reg_files) @@ -1557,6 +1557,7 @@ def delete_bottle(self, config: BottleConfig) -> bool: logging.info(f"Removing the bottle…") path = ManagerUtils.get_bottle_path(config) + self.btrfs_subvolume_manager.delete_all_snapshots(path) subprocess.run(["rm", "-rf", path], stdout=subprocess.DEVNULL) self.update_bottles(silent=True) diff --git a/bottles/backend/managers/meson.build b/bottles/backend/managers/meson.build index e682c0c3b4..25c2cc0c79 100644 --- a/bottles/backend/managers/meson.build +++ b/bottles/backend/managers/meson.build @@ -4,6 +4,7 @@ managersdir = join_paths(pkgdatadir, 'bottles/backend/managers') bottles_sources = [ '__init__.py', 'backup.py', + 'btrfssubvolume.py', 'component.py', 'dependency.py', 'installer.py', diff --git a/bottles/backend/managers/versioning.py b/bottles/backend/managers/versioning.py index 1d88ecd41c..7915c2712f 100644 --- a/bottles/backend/managers/versioning.py +++ b/bottles/backend/managers/versioning.py @@ -15,260 +15,50 @@ # along with this program. If not, see . # -import os -import shutil -from datetime import datetime -from gettext import gettext as _ -from glob import glob - -from fvs.exceptions import ( - FVSNothingToCommit, - FVSStateNotFound, - FVSNothingToRestore, - FVSStateZeroNotDeletable, -) -from fvs.repo import FVSRepo - -from bottles.backend.logger import Logger from bottles.backend.models.config import BottleConfig from bottles.backend.models.result import Result -from bottles.backend.state import TaskManager, Task -from bottles.backend.utils import yaml -from bottles.backend.utils.file import FileUtils from bottles.backend.utils.manager import ManagerUtils -logging = Logger() - +# The implementations doing the actual work +from bottles.backend.models.btrfssubvolume import try_create_bottle_snapshots_versioning_wrapper, BottleSnapshotsVersioningWrapper +from bottles.backend.models.fvs_versioning import BottleFvsVersioning # noinspection PyTypeChecker class VersioningManager: def __init__(self, manager): self.manager = manager - @staticmethod - def __get_patterns(config: BottleConfig): - patterns = ["*dosdevices*", "*cache*"] - if config.Parameters.versioning_exclusion_patterns: - patterns += config.Versioning_Exclusion_Patterns - return patterns + def _get_bottle_versioning_system(self, config: BottleConfig): + bottle_path = ManagerUtils.get_bottle_path(config) + bottle_snapshots_wrapper = try_create_bottle_snapshots_versioning_wrapper(bottle_path) + if bottle_snapshots_wrapper: + return bottle_snapshots_wrapper + def update_config(config: BottleConfig, key: str, value: any): + self.manager.update_config(config, key, value) + return BottleFvsVersioning(config, bottle_path, update_config) - @staticmethod - def is_initialized(config: BottleConfig): - try: - repo = FVSRepo( - repo_path=ManagerUtils.get_bottle_path(config), - use_compression=config.Parameters.versioning_compression, - no_init=True, - ) - except FileNotFoundError: - return False - return not repo.has_no_states + def is_initialized(self, config: BottleConfig): + bottle_versioning_system = self._get_bottle_versioning_system(config) + return bottle_versioning_system.is_initialized() - @staticmethod - def re_initialize(config: BottleConfig): - fvs_path = os.path.join(ManagerUtils.get_bottle_path(config), ".fvs") - if os.path.exists(fvs_path): - shutil.rmtree(fvs_path) + def re_initialize(self, config: BottleConfig): + bottle_versioning_system = self._get_bottle_versioning_system(config) + return bottle_versioning_system.re_initialize() def update_system(self, config: BottleConfig): - states_path = os.path.join(ManagerUtils.get_bottle_path(config), "states") - if os.path.exists(states_path): - shutil.rmtree(states_path) - return self.manager.update_config(config, "Versioning", False) + bottle_versioning_system = self._get_bottle_versioning_system(config) + return bottle_versioning_system.update_system() def create_state(self, config: BottleConfig, message: str = "No message"): - patterns = self.__get_patterns(config) - repo = FVSRepo( - repo_path=ManagerUtils.get_bottle_path(config), - use_compression=config.Parameters.versioning_compression, - ) - task_id = TaskManager.add(Task(title=_("Committing state …"))) - try: - repo.commit(message, ignore=patterns) - except FVSNothingToCommit: - TaskManager.remove(task_id) - return Result(status=False, message=_("Nothing to commit")) - - TaskManager.remove(task_id) - return Result( - status=True, - message=_("New state [{0}] created successfully!").format( - repo.active_state_id - ), - data={"state_id": repo.active_state_id, "states": repo.states}, - ) + bottle_versioning_system = self._get_bottle_versioning_system(config) + return bottle_versioning_system.create_state(message) def list_states(self, config: BottleConfig) -> Result: - """ - This function take all the states from the states.yml file - of the given bottle and return them as a dict. - """ - if not config.Versioning: - try: - repo = FVSRepo( - repo_path=ManagerUtils.get_bottle_path(config), - use_compression=config.Parameters.versioning_compression, - ) - except FVSStateNotFound: - logging.warning( - "The FVS repository may be corrupted, trying to re-initialize it" - ) - self.re_initialize(config) - repo = FVSRepo( - repo_path=ManagerUtils.get_bottle_path(config), - use_compression=config.Parameters.versioning_compression, - ) - return Result( - status=True, - message=_("States list retrieved successfully!"), - data={"state_id": repo.active_state_id, "states": repo.states}, - ) - - bottle_path = ManagerUtils.get_bottle_path(config) - states = {} - - try: - states_file = open("%s/states/states.yml" % bottle_path) - states_file_yaml = yaml.load(states_file) - states_file.close() - states = states_file_yaml.get("States") - logging.info(f"Found [{len(states)}] states for bottle: [{config.Name}]") - except (FileNotFoundError, yaml.YAMLError): - logging.info(f"No states found for bottle: [{config.Name}]") - - return states + bottle_versioning_system = self._get_bottle_versioning_system(config) + return bottle_versioning_system.list_states() def set_state( self, config: BottleConfig, state_id: int, after: callable = None ) -> Result: - if not config.Versioning: - patterns = self.__get_patterns(config) - repo = FVSRepo( - repo_path=ManagerUtils.get_bottle_path(config), - use_compression=config.Parameters.versioning_compression, - ) - res = Result( - status=True, - message=_("State {0} restored successfully!").format(state_id), - ) - task_id = TaskManager.add( - Task(title=_("Restoring state {} …".format(state_id))) - ) - try: - repo.restore_state(state_id, ignore=patterns) - except FVSStateNotFound: - logging.error(f"State {state_id} not found.") - res = Result(status=False, message=_("State not found")) - except (FVSNothingToRestore, FVSStateZeroNotDeletable): - logging.error(f"State {state_id} is the active state.") - res = Result( - status=False, - message=_("State {} is already the active state").format(state_id), - ) - TaskManager.remove(task_id) - return res - - bottle_path = ManagerUtils.get_bottle_path(config) - logging.info(f"Restoring to state: [{state_id}]") - - # get bottle and state indexes - bottle_index = self.get_index(config) - state_index = self.get_state_files(config, state_id) - - search_sources = list(range(int(state_id) + 1)) - search_sources.reverse() - - # check for removed and changed files - remove_files = [] - edit_files = [] - for file in bottle_index.get("Files"): - if file["file"] not in [f["file"] for f in state_index.get("Files")]: - remove_files.append(file) - elif file["checksum"] not in [ - f["checksum"] for f in state_index.get("Files") - ]: - edit_files.append(file) - logging.info(f"[{len(remove_files)}] files to remove.") - logging.info(f"[{len(edit_files)}] files to replace.") - - # check for new files - add_files = [] - for file in state_index.get("Files"): - if file["file"] not in [f["file"] for f in bottle_index.get("Files")]: - add_files.append(file) - logging.info(f"[{len(add_files)}] files to add.") - - # perform file updates - for file in remove_files: - os.remove("%s/drive_c/%s" % (bottle_path, file["file"])) - - for file in add_files: - source = "%s/states/%s/drive_c/%s" % ( - bottle_path, - str(state_id), - file["file"], - ) - target = "%s/drive_c/%s" % (bottle_path, file["file"]) - shutil.copy2(source, target) - - for file in edit_files: - for i in search_sources: - source = "%s/states/%s/drive_c/%s" % (bottle_path, str(i), file["file"]) - if os.path.isfile(source): - checksum = FileUtils().get_checksum(source) - if file["checksum"] == checksum: - break - target = "%s/drive_c/%s" % (bottle_path, file["file"]) - shutil.copy2(source, target) - - # update State in bottle config - self.manager.update_config(config, "State", state_id) - - # execute caller function after all - if after: - after() - - return Result(True) - - @staticmethod - def get_state_files( - config: BottleConfig, state_id: int, plain: bool = False - ) -> dict: - """ - Return the files.yml content of the state. Use the plain argument - to return the content as plain text. - """ - try: - file = open( - "%s/states/%s/files.yml" - % (ManagerUtils.get_bottle_path(config), state_id) - ) - files = file.read() if plain else yaml.load(file.read()) - file.close() - return files - except (OSError, IOError, yaml.YAMLError): - logging.error(f"Could not read the state files file.") - return {} - - @staticmethod - def get_index(config: BottleConfig): - """List all files in a bottle and return as dict.""" - bottle_path = ManagerUtils.get_bottle_path(config) - cur_index = {"Update_Date": str(datetime.now()), "Files": []} - for file in glob("%s/drive_c/**" % bottle_path, recursive=True): - if not os.path.isfile(file): - continue - - if os.path.islink(os.path.dirname(file)): - continue - - if file[len(bottle_path) + 9 :].split("/")[0] in ["users"]: - continue - - cur_index["Files"].append( - { - "file": file[len(bottle_path) + 9 :], - "checksum": FileUtils().get_checksum(file), - } - ) - return cur_index + bottle_versioning_system = self._get_bottle_versioning_system(config) + return bottle_versioning_system.set_state(state_id, after) diff --git a/bottles/backend/models/btrfssubvolume.py b/bottles/backend/models/btrfssubvolume.py new file mode 100644 index 0000000000..8e7614caa4 --- /dev/null +++ b/bottles/backend/models/btrfssubvolume.py @@ -0,0 +1,267 @@ +from dataclasses import dataclass +from enum import Enum +import errno +from functools import cached_property +import os +import os.path +import shutil + +import btrfsutil + +from bottles.backend.models.result import Result + +# TODO ask in the GUI, if a bottle should be created as subvolume. +# TODO Add logging + +# Internal subvolumes created at initialization time: +_internal_subvolumes = [ + "cache", + ] + +def _delete_subvolume(path): + try: + btrfsutil.delete_subvolume(path) + except btrfsutil.BtrfsUtilError as error: + if not error.btrfsutilerror == btrfsutil.ERROR_SNAP_DESTROY_FAILED or not error.errno == errno.EPERM: + raise + try: + # Try to delete the subvolume as a normal directory tree. This is + # in particular needed, if the btrfs filesystem is not mounted with + # 'user_subvol_rm_allowed' option. + btrfsutil.set_subvolume_read_only(path, False) + shutil.rmtree(path) + except Exception as e: + # Raise the first error with some appended note + error.add_note(f"Fallback to 'shutil.rmtree()' failed with: '{e}'") + raise error + + +def create_bottle_as_subvolume(bottle_path) -> bool: + """Create bottle as btrfs subvolume. + + Creates the directory 'bottle_path' as btrfs subvolume and internal + subvolumes inside of it. Returns True on success and False, if the + filesystem is not btrfs. For other failures an exception is raised. + """ + + os.makedirs(os.path.dirname(bottle_path), exist_ok=True) + try: + btrfsutil.create_subvolume(bottle_path) + for internal_subvolume in _internal_subvolumes: + path = os.path.join(bottle_path, internal_subvolume) + btrfsutil.create_subvolume(path) + except btrfsutil.BtrfsUtilError as error: + if not error.btrfsutilerror == btrfsutil.ERROR_NOT_BTRFS: + raise + return False + else: + return True + +class DuplicateResult(Enum): + NOTHING = 1 + EMPTY_SUBVOLUMES = 2 + SNAPSHOTS_FROM_SOURCE = 3 + + def destination_directories_created(self) -> bool: + return not self == DuplicateResult.NOTHING + + def bottle_contents_is_duplicated(self) -> bool: + return self == DuplicateResult.SNAPSHOTS_FROM_SOURCE + +def duplicate_bottle_as_subvolume(source_path, destination_path) -> DuplicateResult: + def create_bare_destination() -> DuplicateResult: + if create_bottle_as_subvolume(destination_path): + return DuplicateResult.EMPTY_SUBVOLUMES + else: + return DuplicateResult.NOTHING + + if not btrfsutil.is_subvolume(source_path): + return create_bare_destination() + else: + try: + btrfsutil.create_snapshot(source_path, destination_path, read_only=False) + except btrfsutil.BtrfsUtilError as error: + match error.btrfsutilerror: + case btrfsutil.ERROR_NOT_BTRFS: + return DuplicateResult.NOTHING + case btrfsutil.ERROR_SNAP_CREATE_FAILED: + return create_bare_destination() + case _: + raise error + for internal_subvolume in _internal_subvolumes: + internal_source_path = os.path.join(source_path, internal_subvolume) + if not btrfsutil.is_subvolume(internal_source_path): + continue + internal_destination_path = os.path.join(destination_path, internal_subvolume) + if os.path.isdir(internal_destination_path): + os.rmdir(internal_destination_path) + btrfsutil.create_snapshot(internal_source_path, internal_destination_path, read_only=False) + return DuplicateResult.SNAPSHOTS_FROM_SOURCE + +def try_create_bottle_snapshots_handle(bottle_path): + """Try to create a bottle snapshots handle. + + Checks if the bottle states can be stored as btrfs snapshots and if no + states have been stored by FVS versioning system. Returns + BottleSnapshotsHandle, if checks succeed, None otherwise. + """ + if not btrfsutil.is_subvolume(bottle_path): + return None + if os.path.exists(os.path.join(bottle_path, ".fvs")): + return None + return BottleSnapshotsHandle(bottle_path) + +@dataclass(frozen=True) +class SnapshotMetaData: + description: str + timestamp: float = 0.0 + +class BottleSnapshotsHandle: + """Handle the snapshots of a single bottle created as btrfs subvolume. + """ + + def __init__(self, bottle_path): + """Internal should not be called directly. + + Use try_create_bottle_snapshots_handle() to potentially create an + instance. + """ + self._bottle_path = bottle_path + bottles_dir, bottle_name = os.path.split(bottle_path) + self._snapshots_directory = os.path.join(bottles_dir, "BottlesSnapshots", bottle_name) + + # Lazily created + @cached_property + def _snapshots(self): + dict_snapshots = {} + if os.path.exists(self._snapshots_directory): + with os.scandir(self._snapshots_directory) as it: + for snapshot in it: + if not snapshot.is_dir(follow_symlinks=False): + continue + if not btrfsutil.is_subvolume(snapshot.path): + continue + snapshot_id, separator, description = snapshot.name.partition("_") + if len(separator) == 0: + continue + dict_snapshots[int(snapshot_id)] = SnapshotMetaData(description, timestamp=snapshot.stat().st_mtime) + return dict_snapshots + + def snapshots(self) -> dict: + """A dictionary of all available snapshots. + + Returns a dictionary from snapshot ID (int) to SnapshotMetaData. + """ + return self._snapshots.copy() + + def _snapshot_path2(self, snapshot_id: int, description: str): + return os.path.join(self._snapshots_directory, f"{snapshot_id}_{description}") + + def _snapshot_path(self, snapshot_id: int): + return self._snapshot_path2(snapshot_id, self._snapshots[snapshot_id].description) + + def _active_snapshot_id_path(self): + return os.path.join(self._bottle_path, ".active_state_id") + + def _save_active_snapshot_id(self, active_state_id: int): + with open(self._active_snapshot_id_path(), "w") as file: + file.write(str(active_state_id)) + + def read_active_snapshot_id(self) -> int: + try: + with open(self._active_snapshot_id_path(), "r") as file: + return int(file.read()) + except OSError: + return -1 + + def create_snapshot(self, description: str) -> int: + snapshot_id = max(self._snapshots.keys(), default=-1) + 1 + snapshot_path = self._snapshot_path2(snapshot_id, description) + os.makedirs(self._snapshots_directory, exist_ok=True) + btrfsutil.create_snapshot(self._bottle_path, snapshot_path, read_only=True) + stat = os.stat(snapshot_path) + self._snapshots[snapshot_id] = SnapshotMetaData(description, stat.st_mtime) + self._save_active_snapshot_id(snapshot_id) + return snapshot_id + + def set_state(self, snapshot_id: int): + """Restore the bottle state from a snapshot. + """ + tmp_bottle_path = f"{self._bottle_path}-tmp" + snapshot_path = self._snapshot_path(snapshot_id) + os.rename(self._bottle_path, tmp_bottle_path) + try: + btrfsutil.create_snapshot(snapshot_path, self._bottle_path, read_only=False) + except btrfsutil.BtrfsUtilError as error: + os.rename(tmp_bottle_path, self._bottle_path) + raise error + for internal_subvolume in _internal_subvolumes: + source_path = os.path.join(tmp_bottle_path, internal_subvolume) + if not os.path.exists(source_path) or not btrfsutil.is_subvolume(source_path): + continue + destination_path = os.path.join(self._bottle_path, internal_subvolume) + os.rmdir(destination_path) + os.rename(source_path, destination_path) + _delete_subvolume(tmp_bottle_path) + self._save_active_snapshot_id(snapshot_id) + + def delete_all_snapshots(self) -> None: + for snapshot_id, metadata in self._snapshots.items(): + snapshot_path = self._snapshot_path2(snapshot_id, metadata.description) + _delete_subvolume(snapshot_path) + try: + os.rmdir(self._snapshots_directory) + except FileNotFoundError: + pass + +def try_create_bottle_snapshots_versioning_wrapper(bottle_path): + handle = try_create_bottle_snapshots_handle(bottle_path) + if not handle: + return None + return BottleSnapshotsVersioningWrapper(handle) + +class BottleSnapshotsVersioningWrapper: + def __init__(self, handle: BottleSnapshotsHandle): + self._handle = handle + + def convert_states(self): + states = {} + for snapshot_id, metadata in self._handle.snapshots().items(): + states[snapshot_id] = {"message": metadata.description, "timestamp": metadata.timestamp} + return states + + def is_initialized(self): + # Nothing to initialize + return True + + def re_initialize(self): + # Nothing to initialize + pass + + def update_system(self): + # Nothing to update + pass + + def create_state(self, message: str) -> Result: + newly_created_snapshot_id = self._handle.create_snapshot(message) + return Result( + status=True, + data={"state_id": newly_created_snapshot_id, "states": self.convert_states()}, + message="Created new BTRFS snapshot", + ) + + def list_states(self) -> Result: + active_state_id = self._handle.read_active_snapshot_id() + return Result( + status=True, + data={"state_id": active_state_id, "states": self.convert_states()}, + message="Retrieved list of states", + ) + + def set_state( + self, state_id: int, after: callable + ) -> Result: + self._handle.set_state(state_id) + if after: + after() + return Result(True) diff --git a/bottles/backend/models/fvs_versioning.py b/bottles/backend/models/fvs_versioning.py new file mode 100644 index 0000000000..0b39093025 --- /dev/null +++ b/bottles/backend/models/fvs_versioning.py @@ -0,0 +1,268 @@ +# fvs_versioning.py +# +# Copyright 2022 brombinmirko +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, in version 3 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# + +import os +import shutil +from datetime import datetime +from gettext import gettext as _ +from glob import glob + +from fvs.exceptions import ( + FVSNothingToCommit, + FVSStateNotFound, + FVSNothingToRestore, + FVSStateZeroNotDeletable, +) +from fvs.repo import FVSRepo + +from bottles.backend.logger import Logger +from bottles.backend.models.config import BottleConfig +from bottles.backend.models.result import Result +from bottles.backend.state import TaskManager, Task +from bottles.backend.utils import yaml +from bottles.backend.utils.file import FileUtils + +logging = Logger() + +class BottleFvsVersioning: + """Provide versioning based on FVS for a single bottle""" + + def __init__(self, config: BottleConfig, bottle_path: str, update_config: callable): + self._config = config + self._bottle_path = bottle_path + self._update_config = update_config + + @staticmethod + def __get_patterns(config: BottleConfig): + patterns = ["*dosdevices*", "*cache*"] + if config.Parameters.versioning_exclusion_patterns: + patterns += config.Versioning_Exclusion_Patterns + return patterns + + def is_initialized(self): + try: + repo = FVSRepo( + repo_path=self._bottle_path, + use_compression=self._config.Parameters.versioning_compression, + no_init=True, + ) + except FileNotFoundError: + return False + return not repo.has_no_states + + def re_initialize(self): + fvs_path = os.path.join(self._bottle_path, ".fvs") + if os.path.exists(fvs_path): + shutil.rmtree(fvs_path) + + def update_system(self): + states_path = os.path.join(self._bottle_path, "states") + if os.path.exists(states_path): + shutil.rmtree(states_path) + return self._update_config(self._config, "Versioning", False) + + def create_state(self, message: str = "No message"): + patterns = self.__get_patterns(self._config) + repo = FVSRepo( + repo_path=self._bottle_path, + use_compression=self._config.Parameters.versioning_compression, + ) + task_id = TaskManager.add(Task(title=_("Committing state …"))) + try: + repo.commit(message, ignore=patterns) + except FVSNothingToCommit: + TaskManager.remove(task_id) + return Result(status=False, message=_("Nothing to commit")) + + TaskManager.remove(task_id) + return Result( + status=True, + message=_("New state [{0}] created successfully!").format( + repo.active_state_id + ), + data={"state_id": repo.active_state_id, "states": repo.states}, + ) + + def list_states(self) -> Result: + """ + This function take all the states from the states.yml file + of the given bottle and return them as a dict. + """ + if not self._config.Versioning: + try: + repo = FVSRepo( + repo_path=self._bottle_path, + use_compression=self._config.Parameters.versioning_compression, + ) + except FVSStateNotFound: + logging.warning( + "The FVS repository may be corrupted, trying to re-initialize it" + ) + self.re_initialize() + repo = FVSRepo( + repo_path=self._bottle_path, + use_compression=self._config.Parameters.versioning_compression, + ) + return Result( + status=True, + message=_("States list retrieved successfully!"), + data={"state_id": repo.active_state_id, "states": repo.states}, + ) + + states = {} + + try: + states_file = open("%s/states/states.yml" % self._bottle_path) + states_file_yaml = yaml.load(states_file) + states_file.close() + states = states_file_yaml.get("States") + logging.info(f"Found [{len(states)}] states for bottle: [{self._config.Name}]") + except (FileNotFoundError, yaml.YAMLError): + logging.info(f"No states found for bottle: [{self._config.Name}]") + + return states + + def set_state( + self, state_id: int, after: callable = None + ) -> Result: + if not self._config.Versioning: + patterns = self.__get_patterns(self._config) + repo = FVSRepo( + repo_path=self._bottle_path, + use_compression=self._config.Parameters.versioning_compression, + ) + res = Result( + status=True, + message=_("State {0} restored successfully!").format(state_id), + ) + task_id = TaskManager.add( + Task(title=_("Restoring state {} …".format(state_id))) + ) + try: + repo.restore_state(state_id, ignore=patterns) + except FVSStateNotFound: + logging.error(f"State {state_id} not found.") + res = Result(status=False, message=_("State not found")) + except (FVSNothingToRestore, FVSStateZeroNotDeletable): + logging.error(f"State {state_id} is the active state.") + res = Result( + status=False, + message=_("State {} is already the active state").format(state_id), + ) + TaskManager.remove(task_id) + return res + + logging.info(f"Restoring to state: [{state_id}]") + + # get bottle and state indexes + bottle_index = self.get_index() + state_index = self.get_state_files(state_id) + + search_sources = list(range(int(state_id) + 1)) + search_sources.reverse() + + # check for removed and changed files + remove_files = [] + edit_files = [] + for file in bottle_index.get("Files"): + if file["file"] not in [f["file"] for f in state_index.get("Files")]: + remove_files.append(file) + elif file["checksum"] not in [ + f["checksum"] for f in state_index.get("Files") + ]: + edit_files.append(file) + logging.info(f"[{len(remove_files)}] files to remove.") + logging.info(f"[{len(edit_files)}] files to replace.") + + # check for new files + add_files = [] + for file in state_index.get("Files"): + if file["file"] not in [f["file"] for f in bottle_index.get("Files")]: + add_files.append(file) + logging.info(f"[{len(add_files)}] files to add.") + + # perform file updates + for file in remove_files: + os.remove("%s/drive_c/%s" % (self._bottle_path, file["file"])) + + for file in add_files: + source = "%s/states/%s/drive_c/%s" % ( + self._bottle_path, + str(state_id), + file["file"], + ) + target = "%s/drive_c/%s" % (self._bottle_path, file["file"]) + shutil.copy2(source, target) + + for file in edit_files: + for i in search_sources: + source = "%s/states/%s/drive_c/%s" % (self._bottle_path, str(i), file["file"]) + if os.path.isfile(source): + checksum = FileUtils().get_checksum(source) + if file["checksum"] == checksum: + break + target = "%s/drive_c/%s" % (self._bottle_path, file["file"]) + shutil.copy2(source, target) + + # update State in bottle config + self._update_config(self._config, "State", state_id) + + # execute caller function after all + if after: + after() + + return Result(True) + + def get_state_files( + self, state_id: int, plain: bool = False + ) -> dict: + """ + Return the files.yml content of the state. Use the plain argument + to return the content as plain text. + """ + try: + file = open( + "%s/states/%s/files.yml" + % (self._bottle_path, state_id) + ) + files = file.read() if plain else yaml.load(file.read()) + file.close() + return files + except (OSError, IOError, yaml.YAMLError): + logging.error(f"Could not read the state files file.") + return {} + + def get_index(self): + """List all files in a bottle and return as dict.""" + cur_index = {"Update_Date": str(datetime.now()), "Files": []} + for file in glob("%s/drive_c/**" % self._bottle_path, recursive=True): + if not os.path.isfile(file): + continue + + if os.path.islink(os.path.dirname(file)): + continue + + if file[len(self._bottle_path) + 9 :].split("/")[0] in ["users"]: + continue + + cur_index["Files"].append( + { + "file": file[len(self._bottle_path) + 9 :], + "checksum": FileUtils().get_checksum(file), + } + ) + return cur_index diff --git a/bottles/backend/models/meson.build b/bottles/backend/models/meson.build index 3da22eed8b..cbe2870c04 100644 --- a/bottles/backend/models/meson.build +++ b/bottles/backend/models/meson.build @@ -3,6 +3,8 @@ modelsdir = join_paths(pkgdatadir, 'bottles/backend/models') bottles_sources = [ '__init__.py', + 'btrfssubvolume.py', + 'fvs_versioning.py', 'result.py', 'samples.py', 'vdict.py', diff --git a/bottles/frontend/views/bottle_versioning.py b/bottles/frontend/views/bottle_versioning.py index 4c2539a176..d364dacfd3 100644 --- a/bottles/frontend/views/bottle_versioning.py +++ b/bottles/frontend/views/bottle_versioning.py @@ -135,8 +135,8 @@ def check_entry_state_message(self, *_args): def add_state(self, widget): """ - This function create ask the versioning manager to - create a new bottle state with the given message. + This function asks the versioning manager to create a new bottle state + with the given message. """ if not self.btn_save.get_sensitive(): return diff --git a/com.usebottles.bottles.yml b/com.usebottles.bottles.yml index 413168fe66..e3cf7d583a 100644 --- a/com.usebottles.bottles.yml +++ b/com.usebottles.bottles.yml @@ -175,6 +175,26 @@ modules: type: git tag-pattern: ^xdpyinfo-([\d.]+)$ + # btrfs-progs contains the btrfsutil library with it's Python bindings + - name: btrfs-progs + buildsystem: simple + build-commands: + - ./autogen.sh + - ./configure --prefix=/app --enable-python --disable-documentation --disable-lzo + - make -j + - make install + # Unfortunately with `make install_python` the `btrfsutil` Python-module + # still can not be imported. It needs this ugly hack: + - cp -a libbtrfsutil/python/btrfsutil.cpython-311-x86_64-linux-gnu.so /app/lib/python3.11/site-packages + sources: + - type: git + url: https://github.com/kdave/btrfs-progs + tag: v6.9.2 + commit: b0e5ef4cf7c8b473119e0d487a26b96058e8f80d + x-checker-data: + type: git + tag-pattern: ^v([\d.]+)$ + # Libraries # ---------------------------------------------------------------------------- diff --git a/requirements.txt b/requirements.txt index 8cf0ebef4a..ae16e9a602 100644 --- a/requirements.txt +++ b/requirements.txt @@ -17,3 +17,4 @@ idna==3.7 urllib3==2.2.1 certifi==2024.2.2 pefile==2023.2.7 +btrfsutil=6.8