mirror of
https://github.com/kiviktnm/decman.git
synced 2026-09-19 12:08:28 +00:00
Add symlink support
This commit is contained in:
+26
-1
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
Reference in New Issue
Block a user