diff --git a/src/decman/__init__.py b/src/decman/__init__.py index 8cabfc5..4cb2e0b 100644 --- a/src/decman/__init__.py +++ b/src/decman/__init__.py @@ -32,7 +32,7 @@ directories: dict[str, Directory] = {} modules: set[Module] = set() plugins: dict[str, Plugin] = available_plugins() execution_order: list[str] = [ - "fs", + "files", "pacman", "aur", "flatpak", diff --git a/src/decman/app.py b/src/decman/app.py index 90ea2be..abaf1a4 100644 --- a/src/decman/app.py +++ b/src/decman/app.py @@ -1,5 +1,256 @@ +import argparse +import os +import sys + import decman +import decman.config as conf +import decman.core.error as errors +import decman.core.file_manager as file_manager +import decman.core.module as _module +import decman.core.output as output +import decman.core.store as _store + +_STORE_FILE = "/var/lib/decman/store.json" def main(): - print(decman.plugins) + """ + Main entry for the CLI app + """ + + sys.pycache_prefix = os.path.join(conf.pkg_cache_dir, "python/") + + parser = argparse.ArgumentParser( + prog="decman", + description="Declarative package & configuration manager for Arch Linux", + epilog="See more help at: https://github.com/kiviktnm/decman", + ) + + parser.add_argument("--source", action="store", help="python file containing configuration") + parser.add_argument( + "--print", + "--dry-run", + action="store_true", + default=False, + help="print what would happen as a result of running decman", + ) + parser.add_argument("--debug", action="store_true", default=False, help="show debug output") + parser.add_argument("--skip", nargs="*", type=str, help="skip the following execution steps") + parser.add_argument( + "--only", nargs="*", type=str, help="run only the following execution steps" + ) + parser.add_argument( + "--no-hooks", + action="store_true", + default=False, + help="don't run hook methods for modules", + ) + parser.add_argument( + "--params", nargs="*", type=str, help="additional parameters passed to pluging" + ) + + args = parser.parse_args() + + if os.getuid() != 0: + output.print_error("Not running as root. Please run decman as root.") + sys.exit(1) + + original_wd = os.getcwd() + failed = False + + try: + with _store.Store(_STORE_FILE, args.dry_run) as store: + try: + _execute_source(store, args) + failed = run_decman(store, args) + except OSError as error: + output.print_error( + f"Unexpected OSError while running decman: {error.strerror or str(error)}" + ) + output.print_traceback() + except errors.SourceError as error: + output.print_error(f"Error raised manually in the source: {error}") + output.print_traceback() + except errors.CommandFailedError as error: + output.print_error(f"{error}") + output.print_traceback() + except errors.InvalidOnDisableError as error: + output.print_error(f"Invalid source. {error}") + output.print_traceback() + except OSError as error: + output.print_error(f"Failed to access decman store file '{_STORE_FILE}': {error.strerror}.") + output.print_error("This may cause already completed operations to run again.") + output.print_traceback() + except Exception as error: + output.print_error(f"Unexpected error while running decman: {error}") + output.print_traceback() + finally: + os.chdir(original_wd) + + if failed: + sys.exit(1) + + +def run_decman(store: _store.Store, args: argparse.Namespace) -> bool: + # Ensure execution order is correct. Remove steps not executed now. + execution_order = [] + + if args.only: + output.print_debug("Argument '--only' is set. Pruning execution steps.") + for step in decman.execution_order: + if step in args.only: + output.print_debug(f"Adding {step} to execution order.") + execution_order.append(step) + else: + execution_order = decman.execution_order + + for skip in args.skip: + output.print_debug(f"Skipping step {skip}.") + execution_order.remove(skip) + + output.print_debug(f"Execution order is: {', '.join(execution_order)}.") + + # Find newly enabled and disabled modules. + store.ensure("enabled_modules", []) + store.ensure("module_on_disable_scripts", {}) + + new_modules = [] + disabled_modules = [] + + for module in decman.modules: + if module.name not in store["enabled_modules"]: + new_modules.append(module.name) + + for module_name in store["enabled_modules"]: + if module_name not in decman.modules: + disabled_modules.append(module_name) + + output.print_debug(f"New modules are: {', '.join(new_modules)}.") + output.print_debug(f"Disabled modules are: {', '.join(disabled_modules)}.") + + # Disable hooks should be run before anything else because they might depend on packages that + # are going to get removed. + if not args.no_hooks: + output.print_summary("Running 'before update' -hooks.") + for module in decman.modules: + output.print_info(f"Running 'before update' for {module.name}.") + if not args.dry_run: + module.before_update() + + if disabled_modules: + output.print_summary("Running 'on disable' -scripts.") + + for disabled_module in disabled_modules: + on_disable_script = store["module_on_disable_scripts"].get(disabled_module, None) + if on_disable_script: + output.print_info(f"Running 'on disable' for {disabled_module}.") + + if not args.dry_run: + decman.prg([on_disable_script]) + store["enabled_modules"].remove(disabled_module) + store["module_on_disable_scripts"].pop(disabled_module) + + # Run main execution order + for step in execution_order: + output.print_debug(f"Running step '{step}'.") + match step: + case "files": + if not file_manager.update_files( + store, decman.modules, decman.files, decman.directories, dry_run=args.dry_run + ): + return False + case plugin_name: + plugin = decman.plugins.get(plugin_name, None) + if plugin: + plugin.process_modules(store, decman.modules) + if not plugin.apply(store, dry_run=args.dry_run, params=args.params): + return False + else: + output.print_warning( + f"Plugin '{plugin_name}' configured in execution_order\ + but not found in available plugins." + ) + + # On enable and on change should be ran last since they might depend on effects caused by + # execution steps. + if not args.no_hooks: + output.print_summary("Running 'on enable' -hooks.") + for module in decman.modules: + if module.name in new_modules: + output.print_info(f"Running 'on enable' for {module.name}.") + + if not args.dry_run: + module.on_enable() + store["enabled_modules"].append(module.name) + try: + script = _module.write_on_disable_script( + module, conf.module_on_disable_scripts_dir + ) + if script: + store["module_on_disable_scripts"][module.name] = script + except OSError as error: + output.print_error( + f"Failed to create 'on disable' script for module {module.name}:\ + {error.strerror or str(error)}." + ) + output.print_warning( + "This script will NOT be created when decman runs the next time." + ) + + output.print_summary("Running 'on change' -hooks.") + for module in decman.modules: + if module._changed: + output.print_info(f"Running 'on change' for {module.name}.") + if not args.dry_run: + module.on_change() + + output.print_summary("Running 'after update' -hooks.") + for module in decman.modules: + output.print_info(f"Running 'after update' for {module.name}.") + if not args.dry_run: + module.after_update() + + return True + + +def _execute_source(store: _store.Store, args: argparse.Namespace): + source = store.get("source_file", None) + source_changed = False + + if args.source is not None: + source = args.source + source_changed = True + + if source is None: + output.print_error( + "Source was not specified. Please specify a source with the '--source' argument." + ) + output.print_info("Decman will remember the previously specified source.") + sys.exit(1) + + if source_changed or not store.get("allow_running_source_without_prompt", False): + output.print_warning(f"Decman will run the file '{source}' as root!") + output.print_warning( + "Only proceed if you trust the file completely. The file can also import other files." + ) + + if not output.prompt_confirm("Proceed?", default=False): + sys.exit(1) + + if output.prompt_confirm("Remember this choice?", default=False): + store["allow_running_source_without_prompt"] = True + + source_path = os.path.abspath(source) + source_dir = os.path.dirname(source_path) + store["source_file"] = source_path + + try: + with open(source_path, "rt", encoding="utf-8") as file: + content = file.read() + except OSError as error: + output.print_error(f"Failed to read source '{source_path}': {error.strerror or str(error)}") + sys.exit(1) + + os.chdir(source_dir) + sys.path.append(".") + exec(content) diff --git a/src/decman/config.py b/src/decman/config.py index d04d4ca..30a8eb4 100644 --- a/src/decman/config.py +++ b/src/decman/config.py @@ -23,3 +23,6 @@ variable to an instance of your class. Look in the example directory for an exam debug_output: bool = False quiet_output: bool = False color_output: bool = True + +pkg_cache_dir: str = "/var/cache/decman" +module_on_disable_scripts_dir: str = "/var/lib/decman/scripts/" diff --git a/src/decman/core/command.py b/src/decman/core/command.py index 68f9971..5e36023 100644 --- a/src/decman/core/command.py +++ b/src/decman/core/command.py @@ -1,7 +1,3 @@ -""" -Module for running external commands. -""" - import errno import fcntl import os diff --git a/src/decman/core/error.py b/src/decman/core/error.py index acf570e..4246786 100644 --- a/src/decman/core/error.py +++ b/src/decman/core/error.py @@ -1,8 +1,3 @@ -""" -Module for decman errors. -""" - - class SourceError(Exception): """ Error raised manually from the user's source. diff --git a/src/decman/core/file_manager.py b/src/decman/core/file_manager.py index 089232b..6112e96 100644 --- a/src/decman/core/file_manager.py +++ b/src/decman/core/file_manager.py @@ -15,6 +15,33 @@ def update_files( directories: dict[str, fs.Directory], dry_run: bool = False, ) -> bool: + """ + Apply the desired file and directory state. + + Installs common and module-provided files and directories, tracks all checked paths, detects + changes, removes files no longer managed, and updates the store. + + On failure, no removals are performed and the store is left unchanged. + + Arguments: + store: + Persistent store used to track managed file paths. + + modules: + Enabled modules providing additional files and directories. + + files: + Common files to install (target path -> File). + + directories: + Common directories to install (target path -> Directory). + + dry_run: + If True, perform change detection only without modifying the filesystem. + + Returns: + True if all operations completed successfully, False if installation failed. + """ output.print_summary("Installing files.") all_checked_files = [] diff --git a/src/decman/core/module.py b/src/decman/core/module.py index f6483b9..50ef587 100644 --- a/src/decman/core/module.py +++ b/src/decman/core/module.py @@ -44,22 +44,34 @@ class Module: def before_update(self): """ Override this method to run python code before updating the system. + + Handle errors within this function. If an error should abort running decman, + raise SourceError or CommandFailedError. """ def after_update(self): """ Override this method to run python code after updating the system. + + Handle errors within this function. If an error should abort running decman, + raise SourceError or CommandFailedError. """ def on_enable(self): """ Override this method to run python code when this module gets enabled. + + Handle errors within this function. If an error should abort running decman, + raise SourceError or CommandFailedError. """ def on_change(self): """ Override this method to run python code after the contents of this module have been changed in the source. + + Handle errors within this function. If an error should abort running decman, + raise SourceError or CommandFailedError. """ @staticmethod diff --git a/src/decman/plugins/__init__.py b/src/decman/plugins/__init__.py index c0c0c61..1171ed5 100644 --- a/src/decman/plugins/__init__.py +++ b/src/decman/plugins/__init__.py @@ -24,7 +24,9 @@ class Plugin: """ return True - def apply(self, store: cstore.Store, dry_run: bool = False) -> bool: + def apply( + self, store: cstore.Store, dry_run: bool = False, params: list[str] | None = None + ) -> bool: """ Ensures that the state managed by this plugin is present. @@ -37,7 +39,7 @@ class Plugin: """ return True - def process_module(self, store: cstore.Store, module: module.Module): + def process_modules(self, store: cstore.Store, modules: set[module.Module]): """ Processes a module. """ diff --git a/tests/test_decman_app.py b/tests/test_decman_app.py new file mode 100644 index 0000000..776fc1d --- /dev/null +++ b/tests/test_decman_app.py @@ -0,0 +1,349 @@ +import argparse +import types + +import pytest + +import decman.app as app # adjust if run_decman lives elsewhere + + +class DummyStore: + def __init__(self, enabled=None, scripts=None): + self._data = {} + if enabled is not None: + self._data["enabled_modules"] = list(enabled) + if scripts is not None: + self._data["module_on_disable_scripts"] = dict(scripts) + + def __getitem__(self, key): + return self._data[key] + + def __setitem__(self, key, value): + self._data[key] = value + + def ensure(self, key, default): + self._data.setdefault(key, default) + + +class DummyModule: + def __init__(self, name): + self.name = name + self._changed = False + self.before_update_called = False + self.on_enable_called = False + self.on_change_called = False + self.after_update_called = False + + def before_update(self): + self.before_update_called = True + + def on_enable(self): + self.on_enable_called = True + + def on_change(self): + self.on_change_called = True + + def after_update(self): + self.after_update_called = True + + @staticmethod + def on_disable(): + print("Disabled") + + +class DummyPlugin: + def __init__(self, apply_result=True): + self.process_modules_called = False + self.apply_called_with = None + self.apply_result = apply_result + + def process_modules(self, store, modules): + self.process_modules_called = True + + def apply(self, store, dry_run=False, params=None): + self.apply_called_with = dry_run + return self.apply_result + + +def make_args( + only=None, + skip=None, + dry_run=False, + no_hooks=False, +): + return argparse.Namespace( + only=only, skip=skip or [], dry_run=dry_run, no_hooks=no_hooks, params=[] + ) + + +@pytest.fixture +def no_op_output(monkeypatch): + ns = types.SimpleNamespace( + print_debug=lambda *a, **k: None, + print_summary=lambda *a, **k: None, + print_info=lambda *a, **k: None, + print_warning=lambda *a, **k: None, + ) + monkeypatch.setattr(app, "output", ns) + return ns + + +@pytest.fixture +def base_decman(monkeypatch): + # Ensure decman attribute exists on app and has the fields we need + dm = types.SimpleNamespace() + dm.execution_order = [] + dm.modules = [] + dm.files = [] + dm.directories = [] + dm.plugins = {} + dm.prg_calls = [] + + def prg(cmd): + dm.prg_calls.append(cmd) + + dm.prg = prg + + monkeypatch.setattr(app, "decman", dm) + return dm + + +@pytest.fixture +def file_manager(monkeypatch): + fm = types.SimpleNamespace() + fm.update_files_calls = [] + fm.result = True + + def update_files(store, modules, files, directories, dry_run=False): + fm.update_files_calls.append( + dict( + store=store, + modules=list(modules), + files=list(files), + directories=list(directories), + dry_run=dry_run, + ) + ) + return fm.result + + fm.update_files = update_files + monkeypatch.setattr(app, "file_manager", fm) + return fm + + +def test_execution_order_only_and_skip(no_op_output, base_decman, file_manager): + base_decman.execution_order = ["files", "plugin_a", "plugin_b"] + + args = make_args( + only=["files", "plugin_b"], + skip=["plugin_b"], + dry_run=True, + no_hooks=True, + ) + store = DummyStore() + + plugin = DummyPlugin(apply_result=False) + base_decman.plugins = {"plugin_b": plugin} + + result = app.run_decman(store, args) + + assert result is True + # Should have run only "files" + assert len(file_manager.update_files_calls) == 1 + assert file_manager.update_files_calls[0]["dry_run"] is True + + +def test_returns_false_when_update_files_fails_and_skips_plugins( + no_op_output, base_decman, file_manager +): + base_decman.execution_order = ["files", "plugin_a"] + + plugin = DummyPlugin(apply_result=True) + base_decman.plugins = {"plugin_a": plugin} + + file_manager.result = False # update_files fails + + args = make_args(dry_run=False, no_hooks=True) + store = DummyStore() + + result = app.run_decman(store, args) + + assert result is False + # update_files called once + assert len(file_manager.update_files_calls) == 1 + # plugin should never be touched + assert plugin.process_modules_called is False + assert plugin.apply_called_with is None + + +def test_plugin_failure_returns_false(no_op_output, base_decman, file_manager): + base_decman.execution_order = ["plugin_a"] + plugin = DummyPlugin(apply_result=False) + base_decman.plugins = {"plugin_a": plugin} + + args = make_args(dry_run=False, no_hooks=True) + store = DummyStore() + + result = app.run_decman(store, args) + + assert result is False + assert plugin.process_modules_called is True + assert plugin.apply_called_with is False + # No file updates + assert file_manager.update_files_calls == [] + + +def test_disabled_modules_run_on_disable_script(no_op_output, base_decman, file_manager): + # enabled_modules contains a module that no longer exists + store = DummyStore( + enabled=["present", "old_mod"], + scripts={"old_mod": "/tmp/on_disable.sh"}, + ) + + # Only "present" exists now, so "old_mod" is disabled + base_decman.modules = [DummyModule("present")] + base_decman.execution_order = [] + + args = make_args(dry_run=False, no_hooks=False) + + result = app.run_decman(store, args) + + assert result is True + # prg should be called with the script for old_mod + assert base_decman.prg_calls == [["/tmp/on_disable.sh"]] + assert store["enabled_modules"] == ["present"] + assert store["module_on_disable_scripts"] == {} + + +def test_on_disable_not_run_in_dry_run(no_op_output, base_decman, file_manager): + store = DummyStore( + enabled=["present", "old_mod"], + scripts={"old_mod": "/tmp/on_disable.sh"}, + ) + base_decman.modules = [DummyModule("present")] + base_decman.execution_order = [] + + args = make_args(dry_run=True, no_hooks=True) + + result = app.run_decman(store, args) + + assert result is True + # dry_run: on_disable scripts must not be executed + assert base_decman.prg_calls == [] + + +def test_hooks_called_for_new_and_changed_modules( + no_op_output, base_decman, file_manager, monkeypatch, tmp_path +): + m1 = DummyModule("mod1") + m2 = DummyModule("mod2") + m1._changed = True + m2._changed = False + + base_decman.modules = [m1, m2] + base_decman.execution_order = [] # no steps, just hooks + + monkeypatch.setattr("decman.config.module_on_disable_scripts_dir", tmp_path) + + # Only mod2 was previously enabled, so mod1 is "new" + store = DummyStore(enabled=["mod2"]) + + args = make_args(dry_run=False, no_hooks=False) + + result = app.run_decman(store, args) + + assert result is True + + # before_update for all modules + assert m1.before_update_called is True + assert m2.before_update_called is True + + # on_enable only for new module (mod1) + assert m1.on_enable_called is True + assert m2.on_enable_called is False + + # on_change only for modules with _changed + assert m1.on_change_called is True + assert m2.on_change_called is False + + # after_update for all modules + assert m1.after_update_called is True + assert m2.after_update_called is True + + assert store["enabled_modules"] == ["mod2", "mod1"] + assert store["module_on_disable_scripts"] == {"mod1": str(tmp_path / "mod1_on_disable.py")} + + +def test_hooks_not_called_when_no_hooks(no_op_output, base_decman, file_manager): + m1 = DummyModule("mod1") + m1._changed = True + + base_decman.modules = [m1] + base_decman.execution_order = [] + + store = DummyStore(enabled=["mod1"]) + + args = make_args(dry_run=False, no_hooks=True) + + result = app.run_decman(store, args) + + assert result is True + + assert m1.before_update_called is False + assert m1.on_enable_called is False + assert m1.on_change_called is False + assert m1.after_update_called is False + + +def test_dry_run_skips_all_hooks_but_runs_steps_with_flag(no_op_output, base_decman, file_manager): + m1 = DummyModule("mod1") + m1._changed = True + base_decman.modules = [m1] + + base_decman.execution_order = ["files", "plugin_a"] + plugin = DummyPlugin(apply_result=True) + base_decman.plugins = {"plugin_a": plugin} + + store = DummyStore() + args = make_args(dry_run=True, no_hooks=False) + + result = app.run_decman(store, args) + + assert result is True + + # Steps executed with dry_run=True + assert len(file_manager.update_files_calls) == 1 + assert file_manager.update_files_calls[0]["dry_run"] is True + assert plugin.process_modules_called is True + assert plugin.apply_called_with is True + + # All hooks skipped due to dry_run + assert m1.before_update_called is False + assert m1.on_enable_called is False + assert m1.on_change_called is False + assert m1.after_update_called is False + + +def test_missing_plugin_emits_warning_but_continues(base_decman, file_manager, monkeypatch): + warnings = [] + + def warn(msg): + warnings.append(msg) + + out = types.SimpleNamespace( + print_debug=lambda *a, **k: None, + print_summary=lambda *a, **k: None, + print_info=lambda *a, **k: None, + print_warning=warn, + ) + monkeypatch.setattr(app, "output", out) + + base_decman.execution_order = ["unknown_plugin"] + base_decman.plugins = {} # none available + + store = DummyStore() + args = make_args(dry_run=True, no_hooks=True) + + result = app.run_decman(store, args) + + assert result is True + assert any("unknown_plugin" in w for w in warnings)