Add symlink support

This commit is contained in:
Kivi Kaitaniemi
2026-01-07 00:52:18 +02:00
parent 8ca2ca8a06
commit 360e0fcf9e
8 changed files with 286 additions and 4 deletions
+26 -1
View File
@@ -129,7 +129,9 @@ decman.config.arch = "x86_64"
## Files and directories ## 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. 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. - `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`). - `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
Modules allow grouping related functionality together. 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 ### 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. 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.
+1
View File
@@ -63,6 +63,7 @@ __all__ = [
# ----------------------------------------- # -----------------------------------------
files: dict[str, File] = {} files: dict[str, File] = {}
directories: dict[str, Directory] = {} directories: dict[str, Directory] = {}
symlinks: dict[str, str] = {}
modules: list[Module] = [] modules: list[Module] = []
execution_order: list[str] = [ execution_order: list[str] = [
"files", "files",
+6 -1
View File
@@ -206,7 +206,12 @@ def run_decman(store: _store.Store, args: argparse.Namespace) -> bool:
match step: match step:
case "files": case "files":
if not file_manager.update_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 return False
case plugin_name: case plugin_name:
+11
View File
@@ -18,6 +18,17 @@ class FSInstallationFailedError(Exception):
super().__init__(f"Failed to install file from {source} to {target}: {reason}.") 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): class InvalidOnDisableError(Exception):
""" """
Error raised when trying to create a Module with an invalid on_disable method. Error raised when trying to create a Module with an invalid on_disable method.
+56
View File
@@ -13,6 +13,7 @@ def update_files(
modules: list[module.Module], modules: list[module.Module],
files: dict[str, fs.File], files: dict[str, fs.File],
directories: dict[str, fs.Directory], directories: dict[str, fs.Directory],
symlinks: dict[str, str],
dry_run: bool = False, dry_run: bool = False,
) -> bool: ) -> bool:
""" """
@@ -59,6 +60,11 @@ def update_files(
all_checked_files += checked all_checked_files += checked
all_changed_files += changed 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: for mod in modules:
module_changed_files = [] module_changed_files = []
@@ -80,6 +86,14 @@ def update_files(
all_checked_files += checked all_checked_files += checked
module_changed_files += changed 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: if len(module_changed_files) > 0:
output.print_debug( output.print_debug(
f"Module '{mod.name}' set to changed due to modified " f"Module '{mod.name}' set to changed due to modified "
@@ -91,6 +105,10 @@ def update_files(
output.print_error(str(error)) output.print_error(str(error))
output.print_traceback() output.print_traceback()
return False return False
except errors.FSSymlinkFailedError as error:
output.print_error(str(error))
output.print_traceback()
return False
to_remove = [] to_remove = []
for file in store["all_files"]: for file in store["all_files"]:
@@ -184,3 +202,41 @@ def _install_directories(
changed_files += changed changed_files += changed
return checked_files, changed_files 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
+7
View File
@@ -105,6 +105,13 @@ class Module:
""" """
return {} 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]: def file_variables(self) -> dict[str, str]:
""" """
Override this method to return variables that should replaced with a new value inside Override this method to return variables that should replaced with a new value inside
+3 -1
View File
@@ -95,6 +95,7 @@ def base_decman(monkeypatch):
dm.modules = [] dm.modules = []
dm.files = [] dm.files = []
dm.directories = [] dm.directories = []
dm.symlinks = {}
dm.plugins = {} dm.plugins = {}
dm.prg_calls = [] dm.prg_calls = []
@@ -113,13 +114,14 @@ def file_manager(monkeypatch):
fm.update_files_calls = [] fm.update_files_calls = []
fm.result = True 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( fm.update_files_calls.append(
dict( dict(
store=store, store=store,
modules=list(modules), modules=list(modules),
files=list(files), files=list(files),
directories=list(directories), directories=list(directories),
symlinks=list(symlinks),
dry_run=dry_run, dry_run=dry_run,
) )
) )
+176 -1
View File
@@ -4,7 +4,12 @@ import pytest
import decman.core.error as errors import decman.core.error as errors
import decman.core.output as output 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: class DummyFile:
@@ -48,12 +53,14 @@ class DummyModule:
name: str, name: str,
file_map: dict[str, DummyFile] | None = None, file_map: dict[str, DummyFile] | None = None,
dir_map: dict[str, DummyDirectory] | None = None, dir_map: dict[str, DummyDirectory] | None = None,
symlink_map: dict[str, str] | None = None,
file_vars: dict[str, str] | None = None, file_vars: dict[str, str] | None = None,
): ):
self.name = name self.name = name
self._file_map = file_map or {} self._file_map = file_map or {}
self._dir_map = dir_map or {} self._dir_map = dir_map or {}
self._file_vars = file_vars or {} self._file_vars = file_vars or {}
self._symlink_map = symlink_map or {}
self._changed = False self._changed = False
def files(self): def files(self):
@@ -62,6 +69,9 @@ class DummyModule:
def directories(self): def directories(self):
return self._dir_map return self._dir_map
def symlinks(self):
return self._symlink_map
def file_variables(self): def file_variables(self):
return self._file_vars return self._file_vars
@@ -227,6 +237,7 @@ def test_update_files_success_updates_store_and_removes_stale_files(monkeypatch)
modules={m}, modules={m},
files={"/etc/app/common.conf": common_file}, files={"/etc/app/common.conf": common_file},
directories={"/etc/app/config.d": common_dir}, directories={"/etc/app/config.d": common_dir},
symlinks={},
dry_run=False, dry_run=False,
) )
@@ -276,6 +287,7 @@ def test_update_files_dry_run_does_not_touch_store_or_remove(monkeypatch):
modules={m}, modules={m},
files={"/etc/app/common.conf": common_file}, files={"/etc/app/common.conf": common_file},
directories={"/etc/app/config.d": common_dir}, directories={"/etc/app/config.d": common_dir},
symlinks={},
dry_run=True, dry_run=True,
) )
@@ -328,6 +340,7 @@ def test_update_files_propagates_fsinstallation_error_and_does_not_modify_store(
modules=set(), modules=set(),
files={"/etc/app/broken.conf": DummyFile()}, files={"/etc/app/broken.conf": DummyFile()},
directories={}, directories={},
symlinks={},
dry_run=False, dry_run=False,
) )
@@ -342,3 +355,165 @@ def test_update_files_propagates_fsinstallation_error_and_does_not_modify_store(
# Error and traceback were logged # Error and traceback were logged
assert error_msgs assert error_msgs
assert traces 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)]