diff --git a/docs/README.md b/docs/README.md index 28df6c6..4031b11 100644 --- a/docs/README.md +++ b/docs/README.md @@ -129,7 +129,9 @@ decman.config.arch = "x86_64" ## Files and directories -Decman functions as a dotfile manager. It will install the defined files and directories to their destinations. You can set file permissions, owners as well as define variables that will be substituted in the installed files. Decman keeps track of all files it creates and when a file is no longer present in your source, it will be also removed from its destination. This helps with keeping your system clean. However, decman won't remove directories as they might contain files that weren't created by decman. +Decman functions as a dotfile manager. It will install the defined files, directories and symlinks to their destinations. You can set file permissions, owners as well as define variables that will be substituted in the installed files. Decman keeps track of all files it creates and when a file is no longer present in your source, it will be also removed from its destination. This helps with keeping your system clean. However, decman won't remove directories as they might contain files that weren't created by decman. + +Symlinks management is simpler and more limited than for files since symlinks cannot have file permissions or ownership. Variables can only be defined for files within modules. See the module example for using file variables. @@ -204,6 +206,18 @@ Ownership, permissions, and parent directories are enforced on creation. Missing - `group: str`: System group name to own the files and directories. By default the `owner`'s group is used. - `permissions: int`: File mode applied to the created or updated files (e.g. `0o644`). +### Symlink + +Declare a link to a target. Missing directories are created. + +```py +import decman + +# Replaces sudo with doas +# /usr/bin/sudo -> /usr/bin/doas +decman.symlinks["/usr/bin/sudo"] = "/usr/bin/doas" +``` + ## Modules Modules allow grouping related functionality together. @@ -332,6 +346,17 @@ def file_variables(self) -> dict[str, str]: } ``` +#### Symlinks + +Defines symlinks fro the module. + +```py +def symlinks(self) -> dict[str, str]: + return { + "/etc/resolv.conf": "/run/systemd/resolve/resolv.conf", + } +``` + ### Extending with plugins To include plugin functionality inside a module, create a new method and mark it with the plugin's decorator. During the execution of decman, the plugin will call the marked method and use its result. Here is an example with the pacman plugin. diff --git a/src/decman/__init__.py b/src/decman/__init__.py index db70810..aeda5fc 100644 --- a/src/decman/__init__.py +++ b/src/decman/__init__.py @@ -63,6 +63,7 @@ __all__ = [ # ----------------------------------------- files: dict[str, File] = {} directories: dict[str, Directory] = {} +symlinks: dict[str, str] = {} modules: list[Module] = [] execution_order: list[str] = [ "files", diff --git a/src/decman/app.py b/src/decman/app.py index 324e162..9357a3c 100644 --- a/src/decman/app.py +++ b/src/decman/app.py @@ -206,7 +206,12 @@ def run_decman(store: _store.Store, args: argparse.Namespace) -> bool: match step: case "files": if not file_manager.update_files( - store, decman.modules, decman.files, decman.directories, dry_run=args.dry_run + store, + decman.modules, + decman.files, + decman.directories, + decman.symlinks, + dry_run=args.dry_run, ): return False case plugin_name: diff --git a/src/decman/core/error.py b/src/decman/core/error.py index 3f0f4b8..1331171 100644 --- a/src/decman/core/error.py +++ b/src/decman/core/error.py @@ -18,6 +18,17 @@ class FSInstallationFailedError(Exception): super().__init__(f"Failed to install file from {source} to {target}: {reason}.") +class FSSymlinkFailedError(Exception): + """ + Error raised when trying to create a symlink to a target. + """ + + def __init__(self, link_name: str, target: str, reason: str): + self.link_name = link_name + self.target = target + super().__init__(f"Failed to install symlink from {link_name} to {target}: {reason}.") + + class InvalidOnDisableError(Exception): """ Error raised when trying to create a Module with an invalid on_disable method. diff --git a/src/decman/core/file_manager.py b/src/decman/core/file_manager.py index 9d5bd99..abaae5c 100644 --- a/src/decman/core/file_manager.py +++ b/src/decman/core/file_manager.py @@ -13,6 +13,7 @@ def update_files( modules: list[module.Module], files: dict[str, fs.File], directories: dict[str, fs.Directory], + symlinks: dict[str, str], dry_run: bool = False, ) -> bool: """ @@ -59,6 +60,11 @@ def update_files( all_checked_files += checked all_changed_files += changed + output.print_debug("Applying common symlinks.") + checked, changed = _install_symlinks(symlinks, dry_run=dry_run) + all_checked_files += checked + all_changed_files += changed + for mod in modules: module_changed_files = [] @@ -80,6 +86,14 @@ def update_files( all_checked_files += checked module_changed_files += changed + output.print_debug(f"Applying symlinks in module '{mod.name}'.") + checked, changed = _install_symlinks( + mod.symlinks(), + dry_run=dry_run, + ) + all_checked_files += checked + module_changed_files += changed + if len(module_changed_files) > 0: output.print_debug( f"Module '{mod.name}' set to changed due to modified " @@ -91,6 +105,10 @@ def update_files( output.print_error(str(error)) output.print_traceback() return False + except errors.FSSymlinkFailedError as error: + output.print_error(str(error)) + output.print_traceback() + return False to_remove = [] for file in store["all_files"]: @@ -184,3 +202,41 @@ def _install_directories( changed_files += changed return checked_files, changed_files + + +def _is_symlink_to(path: str, target: str) -> bool: + if not os.path.islink(path): + return False + return os.readlink(path) == target + + +def _install_symlinks( + symlinks: dict[str, str], dry_run: bool = False +) -> tuple[list[str], list[str]]: + checked_files = [] + changed_files = [] + + for link_name, target in symlinks.items(): + output.print_debug(f"Checking symlink {link_name}.") + try: + checked_files.append(link_name) + + if _is_symlink_to(link_name, target): + continue + + changed_files.append(link_name) + + if dry_run: + continue + + if os.path.lexists(link_name): + os.unlink(link_name) + + os.makedirs(os.path.dirname(link_name), exist_ok=True) + os.symlink(target, link_name) + except OSError as error: + raise errors.FSSymlinkFailedError( + link_name, target, error.strerror or str(error) + ) from error + + return checked_files, changed_files diff --git a/src/decman/core/module.py b/src/decman/core/module.py index cebbdfd..97155c9 100644 --- a/src/decman/core/module.py +++ b/src/decman/core/module.py @@ -105,6 +105,13 @@ class Module: """ return {} + def symlinks(self) -> dict[str, str]: + """ + Override this method to return symlinks that should be created as a part of this + module. + """ + return {} + def file_variables(self) -> dict[str, str]: """ Override this method to return variables that should replaced with a new value inside diff --git a/tests/test_decman_app.py b/tests/test_decman_app.py index 2cbbfc8..4e1d03c 100644 --- a/tests/test_decman_app.py +++ b/tests/test_decman_app.py @@ -95,6 +95,7 @@ def base_decman(monkeypatch): dm.modules = [] dm.files = [] dm.directories = [] + dm.symlinks = {} dm.plugins = {} dm.prg_calls = [] @@ -113,13 +114,14 @@ def file_manager(monkeypatch): fm.update_files_calls = [] fm.result = True - def update_files(store, modules, files, directories, dry_run=False): + def update_files(store, modules, files, directories, symlinks, dry_run=False): fm.update_files_calls.append( dict( store=store, modules=list(modules), files=list(files), directories=list(directories), + symlinks=list(symlinks), dry_run=dry_run, ) ) diff --git a/tests/test_decman_core_file_manager.py b/tests/test_decman_core_file_manager.py index c9e6260..3280cb7 100644 --- a/tests/test_decman_core_file_manager.py +++ b/tests/test_decman_core_file_manager.py @@ -4,7 +4,12 @@ import pytest import decman.core.error as errors import decman.core.output as output -from decman.core.file_manager import _install_directories, _install_files, update_files +from decman.core.file_manager import ( + _install_directories, + _install_files, + _install_symlinks, + update_files, +) class DummyFile: @@ -48,12 +53,14 @@ class DummyModule: name: str, file_map: dict[str, DummyFile] | None = None, dir_map: dict[str, DummyDirectory] | None = None, + symlink_map: dict[str, str] | None = None, file_vars: dict[str, str] | None = None, ): self.name = name self._file_map = file_map or {} self._dir_map = dir_map or {} self._file_vars = file_vars or {} + self._symlink_map = symlink_map or {} self._changed = False def files(self): @@ -62,6 +69,9 @@ class DummyModule: def directories(self): return self._dir_map + def symlinks(self): + return self._symlink_map + def file_variables(self): return self._file_vars @@ -227,6 +237,7 @@ def test_update_files_success_updates_store_and_removes_stale_files(monkeypatch) modules={m}, files={"/etc/app/common.conf": common_file}, directories={"/etc/app/config.d": common_dir}, + symlinks={}, dry_run=False, ) @@ -276,6 +287,7 @@ def test_update_files_dry_run_does_not_touch_store_or_remove(monkeypatch): modules={m}, files={"/etc/app/common.conf": common_file}, directories={"/etc/app/config.d": common_dir}, + symlinks={}, dry_run=True, ) @@ -328,6 +340,7 @@ def test_update_files_propagates_fsinstallation_error_and_does_not_modify_store( modules=set(), files={"/etc/app/broken.conf": DummyFile()}, directories={}, + symlinks={}, dry_run=False, ) @@ -342,3 +355,165 @@ def test_update_files_propagates_fsinstallation_error_and_does_not_modify_store( # Error and traceback were logged assert error_msgs assert traces + + +# symlinks + + +def test_install_symlinks_creates_missing_link_and_parents(tmp_path): + target = tmp_path / "target" + target.write_text("x") + + link = tmp_path / "a" / "b" / "link" + + checked, changed = _install_symlinks({str(link): str(target)}, dry_run=False) + + assert checked == [str(link)] + assert changed == [str(link)] + assert link.is_symlink() + assert os.readlink(link) == str(target) + + +def test_install_symlinks_no_change_when_already_points_to_target(tmp_path): + target = tmp_path / "target" + target.write_text("x") + + link = tmp_path / "link" + os.symlink(str(target), str(link)) + + checked, changed = _install_symlinks({str(link): str(target)}, dry_run=False) + + assert checked == [str(link)] + assert changed == [] + assert link.is_symlink() + assert os.readlink(link) == str(target) + + +def test_install_symlinks_replaces_wrong_target(tmp_path): + target1 = tmp_path / "target1" + target2 = tmp_path / "target2" + target1.write_text("1") + target2.write_text("2") + + link = tmp_path / "link" + os.symlink(str(target1), str(link)) + + checked, changed = _install_symlinks({str(link): str(target2)}, dry_run=False) + + assert checked == [str(link)] + assert changed == [str(link)] + assert link.is_symlink() + assert os.readlink(link) == str(target2) + + +def test_install_symlinks_replaces_existing_regular_file(tmp_path): + target = tmp_path / "target" + target.write_text("x") + + link = tmp_path / "link" + link.write_text("not a symlink") + + checked, changed = _install_symlinks({str(link): str(target)}, dry_run=False) + + assert checked == [str(link)] + assert changed == [str(link)] + assert link.is_symlink() + assert os.readlink(link) == str(target) + + +def test_install_symlinks_dry_run_does_not_touch_fs(tmp_path): + target = tmp_path / "target" + target.write_text("x") + + link = tmp_path / "a" / "b" / "link" + + checked, changed = _install_symlinks({str(link): str(target)}, dry_run=True) + + assert checked == [str(link)] + assert changed == [str(link)] # would change + assert not link.exists() + + +def test_update_files_tracks_symlinks_and_removes_stale_symlinks(tmp_path): + # layout + root = tmp_path + t = root / "target" + t.write_text("x") + + live_link = root / "links" / "live" + stale_link = root / "links" / "stale" + + # pre-existing stale link to be removed + os.makedirs(stale_link.parent, exist_ok=True) + os.symlink(str(t), str(stale_link)) + + m = DummyModule( + name="mod1", + file_map={}, + dir_map={}, + symlink_map={str(live_link): str(t)}, + ) + + store = DummyStore( + {"all_files": [str(stale_link)]} # new store key + ) + + ok = update_files( + store=store, + modules={m}, + files={}, + directories={}, + symlinks={}, + dry_run=False, + ) + + assert ok is True + + # new link exists + assert live_link.is_symlink() + assert os.readlink(live_link) == str(t) + + # stale link removed + assert not stale_link.exists() + + # store updated + assert store["all_files"] == [str(live_link)] + + +def test_update_files_dry_run_does_not_create_or_remove_symlinks(tmp_path): + root = tmp_path + t = root / "target" + t.write_text("x") + + live_link = root / "links" / "live" + stale_link = root / "links" / "stale" + + os.makedirs(stale_link.parent, exist_ok=True) + os.symlink(str(t), str(stale_link)) + + m = DummyModule( + name="mod1", + file_map={}, + dir_map={}, + symlink_map={str(live_link): str(t)}, + ) + + store = DummyStore({"all_files": [str(stale_link)]}) + + ok = update_files( + store=store, + modules={m}, + files={}, + directories={}, + symlinks={}, + dry_run=True, + ) + + assert ok is True + + # no fs changes + assert not live_link.exists() + assert stale_link.is_symlink() + + # store unchanged + assert store["all_files"] == [str(stale_link)]