From a5be74c0660b1253e6129954607858d59927035a Mon Sep 17 00:00:00 2001 From: Nick Anderson Date: Tue, 11 Nov 2025 15:14:26 -0600 Subject: [PATCH] Added promise type to manage Application Streams (AppStreams) A CFEngine custom promise type for managing AppStream modules (https://www.redhat.com/en/blog/introduction-appstreams-and-modules-red-hat-enterprise-linux) on compatible systems. - Enable, disable, install, and remove AppStream modules - Support for specifying streams and profiles Ticket: CFE-3653 --- cfbs.json | 9 + promise-types/appstreams/README.md | 100 ++++ promise-types/appstreams/appstreams.py | 443 ++++++++++++++++++ promise-types/appstreams/init.cf | 6 + promise-types/appstreams/test_appstreams.py | 114 +++++ .../appstreams/test_appstreams_logic.py | 288 ++++++++++++ 6 files changed, 960 insertions(+) create mode 100644 promise-types/appstreams/README.md create mode 100644 promise-types/appstreams/appstreams.py create mode 100644 promise-types/appstreams/init.cf create mode 100644 promise-types/appstreams/test_appstreams.py create mode 100644 promise-types/appstreams/test_appstreams_logic.py diff --git a/cfbs.json b/cfbs.json index e05a954..ee0db9a 100644 --- a/cfbs.json +++ b/cfbs.json @@ -223,6 +223,15 @@ "append enable.cf services/init.cf" ] }, + "promise-type-appstreams": { + "description": "Promise type to manage AppStream modules.", + "subdirectory": "promise-types/appstreams", + "dependencies": ["library-for-promise-types-in-python"], + "steps": [ + "copy appstreams.py modules/promises/", + "append init.cf services/init.cf" + ] + }, "promise-type-git": { "description": "Promise type to manage git repos.", "subdirectory": "promise-types/git", diff --git a/promise-types/appstreams/README.md b/promise-types/appstreams/README.md new file mode 100644 index 0000000..65ea883 --- /dev/null +++ b/promise-types/appstreams/README.md @@ -0,0 +1,100 @@ +# AppStreams Promise Type + +A CFEngine custom promise type for managing AppStream modules on compatible systems. + +## Overview + +The `appstreams` promise type allows you to manage AppStream modules, which are a key feature of RHEL 8+ and compatible systems. AppStreams provide multiple versions of software components that can be enabled or disabled as needed. + +## Features + +- Enable, disable, install, and remove AppStream modules +- Support for specifying streams and profiles + +## Installation + +To install this promise type, copy the `appstreams.py` file to your CFEngine masterfiles directory and configure the promise agent: + +``` +promise agent appstreams +{ + interpreter => "/usr/bin/python3"; + path => "$(sys.workdir)/modules/promises/appstreams.py"; +} +``` + +## Usage + +### Ensure a module is enabled + +``` +bundle agent main +{ + appstreams: + "nodejs" + state => "enabled", + stream => "12"; +} +``` + +### Ensure a module is disabled + +``` +bundle agent main +{ + appstreams: + "nodejs" + state => "disabled"; +} +``` + +### Ensure a module is installed with a specific profile + +``` +bundle agent main +{ + appstreams: + "python36" + state => "installed", + stream => "3.6", + profile => "minimal"; +} +``` + +### Ensure a module is removed + +``` +bundle agent main +{ + appstreams: + "postgresql" + state => "removed"; +} +``` + +### Reset a module to default + +``` +bundle agent main +{ + appstreams: + "nodejs" + state => "default"; +} +``` + +## Attributes + +The promise type supports the following attributes: + +- `state` (optional) - Desired state of the module: `enabled`, `disabled`, `installed`, `removed`, `default`, or `reset` (default: `enabled`) +- `stream` (optional) - Specific stream of the module to use. Set to `default` to use the module's default stream. +- `profile` (optional) - Specific profile of the module to install. Set to `default` to use the module stream's default profile. + +## Requirements + +- CFEngine 3.18 or later +- Python 3 +- DNF Python API (python3-dnf package) +- DNF/YUM package manager (RHEL 8+, Fedora, CentOS 8+) +- AppStream repositories configured diff --git a/promise-types/appstreams/appstreams.py b/promise-types/appstreams/appstreams.py new file mode 100644 index 0000000..45b7437 --- /dev/null +++ b/promise-types/appstreams/appstreams.py @@ -0,0 +1,443 @@ +#!/usr/bin/python3 +# +# Custom promise type to manage AppStream modules +# Uses cfengine_module_library.py library. +# +# Use it in the policy like this: +# promise agent appstreams +# { +# interpreter => "/usr/bin/python3"; +# path => "$(sys.workdir)/modules/promises/appstreams.py"; +# } +# bundle agent main +# { +# appstreams: +# "nodejs" +# state => "installed", +# stream => "12"; +# +# "postgresql" +# state => "default"; +# } + +import dnf +import dnf.exceptions +import re +from cfengine_module_library import PromiseModule, ValidationError, Result + + +class AppStreamsPromiseTypeModule(PromiseModule): + def __init__(self, **kwargs): + super(AppStreamsPromiseTypeModule, self).__init__( + name="appstreams_promise_module", version="0.0.1", **kwargs + ) + + # Define all expected attributes with their types and validation + self.add_attribute( + "state", + str, + required=False, + default="enabled", + validator=lambda x: self._validate_state(x), + ) + self.add_attribute( + "stream", + str, + required=False, + validator=lambda x: self._validate_stream_name(x), + ) + self.add_attribute( + "profile", + str, + required=False, + validator=lambda x: self._validate_profile_name(x), + ) + + def _validate_state(self, value): + if value not in ( + "enabled", + "disabled", + "installed", + "removed", + "default", + "reset", + ): + raise ValidationError( + "State attribute must be 'enabled', 'disabled', 'installed', " + "'removed', 'default', or 'reset'" + ) + + def _validate_module_name(self, name): + # Validate module name to prevent injection + if not re.match(r"^[a-zA-Z0-9_.-]+$", name): + raise ValidationError( + f"Invalid module name: {name}. Only alphanumeric, underscore, " + f"dot, and dash characters are allowed." + ) + + def _validate_stream_name(self, stream): + # Validate stream name to prevent injection + if stream and not re.match(r"^[a-zA-Z0-9_.-]+$", stream): + raise ValidationError( + f"Invalid stream name: {stream}. Only alphanumeric, underscore, " + f"dot, and dash characters are allowed." + ) + + def _validate_profile_name(self, profile): + # Validate profile name to prevent injection + if profile and not re.match(r"^[a-zA-Z0-9_.-]+$", profile): + raise ValidationError( + f"Invalid profile name: {profile}. Only alphanumeric, underscore, " + f"dot, and dash characters are allowed." + ) + + def validate_promise(self, promiser, attributes, metadata): + # Validate promiser (module name) + if not isinstance(promiser, str): + raise ValidationError("Promiser must be of type string") + + self._validate_module_name(promiser) + + def evaluate_promise(self, promiser, attributes, metadata): + module_name = promiser + state = attributes.get("state", "enabled") + stream = attributes.get("stream", None) + profile = attributes.get("profile", None) + + base = dnf.Base() + try: + # Read configuration + base.conf.assumeyes = True + + # Read repository information + base.read_all_repos() + + # Fill the sack (package database) + base.fill_sack(load_system_repo=True) + + # Get ModulePackageContainer from sack + if base.sack is None: + self.log_error("DNF sack is not available") + return Result.NOT_KEPT + if hasattr(base.sack, "_moduleContainer"): + mpc = base.sack._moduleContainer + else: + self.log_error("DNF sack has no module container") + return Result.NOT_KEPT + + # Handle stream => "default" + if stream == "default": + stream = self._get_default_stream(mpc, module_name) + if not stream: + self.log_error(f"No default stream found for module {module_name}") + return Result.NOT_KEPT + self.log_verbose(f"Resolved 'default' stream to '{stream}'") + + # Handle profile => "default" + if profile == "default": + # We need the stream to check for default profile + # If stream is None, DNF might pick default stream, but safer to have it resolved + resolved_stream = stream + if not resolved_stream: + resolved_stream = self._get_default_stream(mpc, module_name) + + profile = self._get_default_profile(mpc, module_name, resolved_stream) + if not profile: + self.log_error(f"No default profile found for module {module_name}") + return Result.NOT_KEPT + self.log_verbose(f"Resolved 'default' profile to '{profile}'") + + # Check current state of the module + current_state = self._get_module_state(mpc, module_name) + + # Determine what action to take based on desired state + if state == "enabled": + if current_state == "enabled": + # Check stream match + is_stream_correct = True + if stream: + try: + enabled_stream = mpc.getEnabledStream(module_name) + if enabled_stream != stream: + is_stream_correct = False + # RuntimeError is raised by libdnf if the module is unknown + except RuntimeError: + pass + + if is_stream_correct: + self.log_verbose(f"Module {module_name} is already enabled") + return Result.KEPT + else: + return self._enable_module(mpc, base, module_name, stream) + else: + return self._enable_module(mpc, base, module_name, stream) + elif state == "disabled": + if current_state == "disabled": + self.log_verbose(f"Module {module_name} is already disabled") + return Result.KEPT + else: + return self._disable_module(mpc, base, module_name) + elif state == "installed": + if current_state in ["installed", "enabled"]: + # For "installed" state, if it's already installed or enabled, + # we need to check if the specific profile is installed + if self._is_module_installed_with_packages( + mpc, module_name, stream, profile + ): + self.log_verbose( + f"Module {module_name} (stream: {stream}, " + f"profile: {profile}) is already present" + ) + return Result.KEPT + else: + return self._install_module( + mpc, base, module_name, stream, profile + ) + else: + # Module is not enabled, need to install + # (which will enable and install packages) + return self._install_module(mpc, base, module_name, stream, profile) + elif state == "removed": + if current_state == "removed" or current_state == "disabled": + self.log_verbose( + f"Module {module_name} is already absent or disabled" + ) + return Result.KEPT + else: + return self._remove_module(mpc, base, module_name, stream, profile) + elif state == "default" or state == "reset": + return self._reset_module(mpc, base, module_name) + + self.log_error(f"Unexpected state '{state}' for module {module_name}") + return Result.NOT_KEPT + finally: + base.close() + + def _get_module_state(self, mpc, module_name): + """Get the current state of a module using DNF Python API""" + state = mpc.getModuleState(module_name) + if state == mpc.ModuleState_ENABLED: + return "enabled" + elif state == mpc.ModuleState_DISABLED: + return "disabled" + elif state == mpc.ModuleState_INSTALLED: + return "installed" + return "removed" + + def _get_default_stream(self, mpc, module_name): + """Find the default stream for a module""" + return mpc.getDefaultStream(module_name) + + def _get_default_profile(self, mpc, module_name, stream): + """Find the default profile for a module stream""" + profiles = mpc.getDefaultProfiles(module_name, stream) + if profiles: + return profiles[0] + return None + + def _is_module_installed_with_packages( + self, mpc, module_name, stream, profile_name + ): + """Check if the module packages/profiles are installed on the system""" + # Check stream + try: + enabled_stream = mpc.getEnabledStream(module_name) + except RuntimeError: + # RuntimeError is raised by libdnf if the module is unknown + return False + + if stream and enabled_stream != stream: + return False + + target_stream = stream or enabled_stream + if not target_stream: + return False + + # Check profile + if not profile_name: + profile_name = self._get_default_profile(mpc, module_name, target_stream) + + if profile_name: + try: + installed_profiles = mpc.getInstalledProfiles(module_name) + return profile_name in installed_profiles + except RuntimeError: + # RuntimeError is raised by libdnf if the module is unknown + return False + + return True + + def _enable_module(self, mpc, base, module_name, stream): + """Enable a module using DNF Python API""" + target_stream = stream or self._get_default_stream(mpc, module_name) + + if not target_stream: + self.log_error( + f"No stream specified and no default stream found for {module_name}" + ) + return Result.NOT_KEPT + + mpc.enable(module_name, target_stream) + mpc.save() + mpc.moduleDefaultsResolve() + base.resolve() + base.do_transaction() + if mpc.isEnabled(module_name, target_stream): + self.log_info(f"Module {module_name}:{target_stream} enabled successfully") + return Result.REPAIRED + else: + self.log_error(f"Failed to enable module {module_name}:{target_stream}") + return Result.NOT_KEPT + + def _disable_module(self, mpc, base, module_name): + """Disable a module using DNF Python API""" + mpc.disable(module_name) + mpc.save() + base.resolve() + base.do_transaction() + if mpc.isDisabled(module_name): + self.log_info(f"Module {module_name} disabled successfully") + return Result.REPAIRED + else: + self.log_error(f"Failed to disable module {module_name}") + return Result.NOT_KEPT + + def _get_profile_packages(self, mpc, module_name, stream, profile_name): + # Find the module package + # mpc.query(name) returns vector + modules = mpc.query(module_name) + for module in modules: + if module.getStream() == stream: + # Found stream + for profile in module.getProfiles(): + if profile.getName() == profile_name: + return profile.getContent() + return [] + + def _install_module(self, mpc, base, module_name, stream, profile): + """Install a module using DNF Python API""" + if not stream: + try: + stream = mpc.getEnabledStream(module_name) + except RuntimeError: + pass + if not stream: + stream = self._get_default_stream(mpc, module_name) + + if not profile: + profile = self._get_default_profile(mpc, module_name, stream) + + if not profile: + self.log_error( + f"No profile specified and no default found for {module_name}:{stream}" + ) + return Result.NOT_KEPT + + mpc.enable(module_name, stream) + mpc.install(module_name, stream, profile) + mpc.save() + mpc.moduleDefaultsResolve() + + # Install packages + packages = self._get_profile_packages(mpc, module_name, stream, profile) + failed_packages = [] + if packages: + for pkg in packages: + try: + base.install(pkg) + except dnf.exceptions.Error as e: + self.log_verbose(f"Failed to install package {pkg}: {e}") + failed_packages.append((pkg, str(e))) + + base.resolve() + base.do_transaction() + + # Verify installation succeeded + if self._is_module_installed_with_packages(mpc, module_name, stream, profile): + self.log_info( + f"Module {module_name}:{stream}/{profile} installed successfully" + ) + return Result.REPAIRED + else: + self.log_error(f"Failed to install module {module_name}:{stream}/{profile}") + if failed_packages: + for pkg, error in failed_packages: + self.log_error(f" Package {pkg} failed: {error}") + return Result.NOT_KEPT + + def _remove_module(self, mpc, base, module_name, stream, profile): + """Remove a module using DNF Python API""" + if not stream: + try: + target_stream = mpc.getEnabledStream(module_name) + except RuntimeError: + target_stream = None + else: + target_stream = stream + + if not target_stream: + self.log_verbose(f"No active stream for {module_name}, nothing to remove") + return Result.KEPT + + failed_packages = [] + if profile: + mpc.uninstall(module_name, target_stream, profile) + pkgs = self._get_profile_packages(mpc, module_name, target_stream, profile) + for pkg in pkgs: + try: + base.remove(pkg) + except dnf.exceptions.Error as e: + self.log_verbose(f"Failed to remove package {pkg}: {e}") + failed_packages.append((pkg, str(e))) + else: + profiles = mpc.getInstalledProfiles(module_name) + for p in profiles: + mpc.uninstall(module_name, target_stream, p) + pkgs = self._get_profile_packages(mpc, module_name, target_stream, p) + for pkg in pkgs: + try: + base.remove(pkg) + except dnf.exceptions.Error as e: + self.log_verbose(f"Failed to remove package {pkg}: {e}") + failed_packages.append((pkg, str(e))) + + mpc.save() + base.resolve(allow_erasing=True) + base.do_transaction() + + # Verify removal succeeded + current_state = self._get_module_state(mpc, module_name) + if current_state in ["removed", "disabled"]: + self.log_info(f"Module {module_name} removed successfully") + return Result.REPAIRED + else: + self.log_error(f"Failed to remove module {module_name}") + if failed_packages: + for pkg, error in failed_packages: + self.log_error(f" Package {pkg} failed: {error}") + return Result.NOT_KEPT + + def _reset_module(self, mpc, base, module_name): + """Reset a module using DNF Python API""" + if mpc.getModuleState(module_name) == mpc.ModuleState_DEFAULT: + self.log_verbose( + f"Module {module_name} is already in default (reset) state" + ) + return Result.KEPT + + mpc.reset(module_name) + mpc.save() + base.resolve() + base.do_transaction() + + # Verify reset succeeded + if mpc.getModuleState(module_name) == mpc.ModuleState_DEFAULT: + self.log_info(f"Module {module_name} reset successfully") + return Result.REPAIRED + else: + self.log_error(f"Failed to reset module {module_name}") + return Result.NOT_KEPT + + +if __name__ == "__main__": + AppStreamsPromiseTypeModule().start() diff --git a/promise-types/appstreams/init.cf b/promise-types/appstreams/init.cf new file mode 100644 index 0000000..24252ab --- /dev/null +++ b/promise-types/appstreams/init.cf @@ -0,0 +1,6 @@ +promise agent appstreams +# @brief Define appstreams promise type +{ + path => "$(sys.workdir)/modules/promises/appstreams.py"; + interpreter => "/usr/bin/python3"; +} diff --git a/promise-types/appstreams/test_appstreams.py b/promise-types/appstreams/test_appstreams.py new file mode 100644 index 0000000..1fe7471 --- /dev/null +++ b/promise-types/appstreams/test_appstreams.py @@ -0,0 +1,114 @@ +import sys +import os +import pytest +from unittest.mock import MagicMock + +# Mock dnf module before importing the promise module +mock_dnf = MagicMock() +mock_dnf.exceptions = MagicMock() +sys.modules["dnf"] = mock_dnf +sys.modules["dnf.exceptions"] = mock_dnf.exceptions + +# Add library path +sys.path.insert( + 0, os.path.join(os.path.dirname(__file__), "..", "..", "libraries", "python") +) +# Add module path +sys.path.insert(0, os.path.dirname(__file__)) + +from appstreams import AppStreamsPromiseTypeModule # noqa: E402 +from cfengine_module_library import ValidationError # noqa: E402 + + +@pytest.fixture +def module(): + return AppStreamsPromiseTypeModule() + + +def test_validation_valid_attributes(module): + """Test validation of valid module attributes""" + module.validate_promise("nodejs", {"state": "enabled", "stream": "12"}, {}) + + +def test_validation_invalid_module_name(module): + """Test validation of invalid module name""" + with pytest.raises(ValidationError) as excinfo: + module.validate_promise("nodejs; echo hi", {"state": "enabled"}, {}) + assert "Invalid module name" in str(excinfo.value) + + +@pytest.mark.parametrize( + "name", ["nodejs", "python3.6", "python36", "postgresql", "maven", "httpd"] +) +def test_module_name_validation_valid(module, name): + """Test module name validation with valid names""" + module._validate_module_name(name) + + +@pytest.mark.parametrize( + "name", ["nodejs;echo", "python36&&", "postgresql|", "maven>", "httpd<"] +) +def test_module_name_validation_invalid(module, name): + """Test module name validation with invalid names""" + with pytest.raises(ValidationError): + module._validate_module_name(name) + + +@pytest.mark.parametrize( + "stream", ["12", "14", "3.6", "1.14", "latest", "stable", "default"] +) +def test_stream_name_validation_valid(module, stream): + """Test stream name validation with valid names""" + module._validate_stream_name(stream) + + +@pytest.mark.parametrize("stream", ["12;echo", "14&&", "3.6|", "latest>", "stable<"]) +def test_stream_name_validation_invalid(module, stream): + """Test stream name validation with invalid names""" + with pytest.raises(ValidationError): + module._validate_stream_name(stream) + + +@pytest.mark.parametrize( + "state", ["enabled", "disabled", "installed", "removed", "default", "reset"] +) +def test_state_validation_valid(module, state): + """Test state validation with valid states""" + module._validate_state(state) + + +@pytest.mark.parametrize( + "state", + [ + "active", + "inactive", + "enable", + "disable", + "install", + "remove", + "present", + "absent", + ], +) +def test_state_validation_invalid(module, state): + """Test state validation with invalid states""" + with pytest.raises(ValidationError): + module._validate_state(state) + + +def test_state_parsing_method_exists(module): + """Test that the state parsing method exists""" + assert hasattr(module, "_get_module_state") + + +@pytest.mark.parametrize("profile", ["common", "minimal", "server", "default", "1.0"]) +def test_profile_name_validation_valid(module, profile): + """Test profile name validation with valid names""" + module._validate_profile_name(profile) + + +@pytest.mark.parametrize("profile", ["common;echo", "minimal&&", "server|", "default>"]) +def test_profile_name_validation_invalid(module, profile): + """Test profile name validation with invalid names""" + with pytest.raises(ValidationError): + module._validate_profile_name(profile) diff --git a/promise-types/appstreams/test_appstreams_logic.py b/promise-types/appstreams/test_appstreams_logic.py new file mode 100644 index 0000000..9997fa7 --- /dev/null +++ b/promise-types/appstreams/test_appstreams_logic.py @@ -0,0 +1,288 @@ +import sys +import os +import pytest +from unittest.mock import MagicMock + +# Add library path +sys.path.insert( + 0, os.path.join(os.path.dirname(__file__), "..", "..", "libraries", "python") +) +# Add module path +sys.path.insert(0, os.path.dirname(__file__)) + +# Mock dnf module before importing the promise module +mock_dnf = MagicMock() +mock_dnf.exceptions = MagicMock() +sys.modules["dnf"] = mock_dnf +sys.modules["dnf.exceptions"] = mock_dnf.exceptions + +import appstreams as appstreams_module # noqa: E402 + +appstreams_module.dnf = mock_dnf + +from appstreams import AppStreamsPromiseTypeModule # noqa: E402 +from cfengine_module_library import ValidationError, Result # noqa: E402 + + +@pytest.fixture +def module(): + # Reset mocks + mock_dnf.reset_mock() + if hasattr(mock_dnf.Base, "return_value"): + mock_dnf.Base.return_value.reset_mock() + + mod = AppStreamsPromiseTypeModule() + mod._log_level = "info" + mod._out = MagicMock() + return mod + + +@pytest.fixture +def mock_mpc(): + # Setup the ModulePackageContainer mock + mpc = MagicMock() + # Setup constants + mpc.ModuleState_ENABLED = 1 + mpc.ModuleState_DISABLED = 2 + mpc.ModuleState_INSTALLED = 3 + mpc.ModuleState_DEFAULT = 0 + return mpc + + +@pytest.fixture +def mock_base(mock_mpc): + base = mock_dnf.Base.return_value + base.sack._moduleContainer = mock_mpc + return base + + +def test_harness_setup(module): + """Verify the test harness is working""" + assert module is not None + assert module.name == "appstreams_promise_module" + + +def test_enable_module_already_enabled(module, mock_base, mock_mpc): + """Test enabling a module that is already enabled (KEPT)""" + mock_mpc.getModuleState.return_value = mock_mpc.ModuleState_ENABLED + mock_mpc.getEnabledStream.return_value = "12" + + result = module.evaluate_promise("nodejs", {"state": "enabled", "stream": "12"}, {}) + assert result == Result.KEPT + + +def test_enable_module_repaired(module, mock_base, mock_mpc): + """Test enabling a module that is disabled (REPAIRED)""" + mock_mpc.getModuleState.return_value = mock_mpc.ModuleState_DISABLED + mock_mpc.isEnabled.return_value = True # After enable() called + + result = module.evaluate_promise("nodejs", {"state": "enabled", "stream": "12"}, {}) + + mock_mpc.enable.assert_called_with("nodejs", "12") + assert result == Result.REPAIRED + + +def test_disable_module_already_disabled(module, mock_base, mock_mpc): + """Test disabling a module that is already disabled (KEPT)""" + mock_mpc.getModuleState.return_value = mock_mpc.ModuleState_DISABLED + + result = module.evaluate_promise( + "nodejs", {"state": "disabled", "stream": "12"}, {} + ) + assert result == Result.KEPT + + +def test_disable_module_repaired(module, mock_base, mock_mpc): + """Test disabling a module that is enabled (REPAIRED)""" + mock_mpc.getModuleState.return_value = mock_mpc.ModuleState_ENABLED + mock_mpc.isDisabled.return_value = True # After disable() called + + result = module.evaluate_promise( + "nodejs", {"state": "disabled", "stream": "12"}, {} + ) + + mock_mpc.disable.assert_called_with("nodejs") + assert result == Result.REPAIRED + + +def test_install_profile_repaired(module, mock_base, mock_mpc): + """Test installing a specific profile using 'installed' state (REPAIRED)""" + # Initial state: enabled but not fully installed with profile + mock_mpc.getModuleState.return_value = mock_mpc.ModuleState_ENABLED + mock_mpc.getEnabledStream.return_value = "12" + mock_mpc.getInstalledProfiles.return_value = [] + + # helper for _get_profile_packages + # It queries module, gets stream, gets profiles, gets content + mock_module_obj = MagicMock() + mock_module_obj.getStream.return_value = "12" + mock_profile_obj = MagicMock() + mock_profile_obj.getName.return_value = "common" + mock_profile_obj.getContent.return_value = ["pkg1"] + mock_module_obj.getProfiles.return_value = [mock_profile_obj] + mock_mpc.query.return_value = [mock_module_obj] + + result = module.evaluate_promise( + "nodejs", {"state": "installed", "stream": "12", "profile": "common"}, {} + ) + + mock_mpc.install.assert_called_with("nodejs", "12", "common") + assert result == Result.REPAIRED + + +def test_remove_module_repaired(module, mock_base, mock_mpc): + """Test removing a module using 'removed' state (REPAIRED)""" + mock_mpc.getModuleState.return_value = mock_mpc.ModuleState_INSTALLED + mock_mpc.getEnabledStream.return_value = "12" + mock_mpc.getInstalledProfiles.return_value = ["common"] + + # For removal, we also need package query to mock explicit package removal + mock_module_obj = MagicMock() + mock_module_obj.getStream.return_value = "12" + mock_profile_obj = MagicMock() + mock_profile_obj.getName.return_value = "common" + mock_profile_obj.getContent.return_value = ["pkg1"] + mock_module_obj.getProfiles.return_value = [mock_profile_obj] + mock_mpc.query.return_value = [mock_module_obj] + + result = module.evaluate_promise("nodejs", {"state": "removed", "stream": "12"}, {}) + + # Logic in _remove_module calls uninstall for each installed profile if no profile specified + mock_mpc.uninstall.assert_called() + assert result == Result.REPAIRED + + +def test_install_profile_idempotency_success(module, mock_base, mock_mpc): + """Test installing a profile that is already present (KEPT)""" + mock_mpc.getModuleState.return_value = mock_mpc.ModuleState_INSTALLED + mock_mpc.getEnabledStream.return_value = "12" + mock_mpc.getInstalledProfiles.return_value = ["common"] + + result = module.evaluate_promise( + "nodejs", {"state": "installed", "stream": "12", "profile": "common"}, {} + ) + + assert result == Result.KEPT + + +def test_reset_module_repaired(module, mock_base, mock_mpc): + """Test resetting a module to default state (REPAIRED)""" + mock_mpc.getModuleState.return_value = mock_mpc.ModuleState_ENABLED + + result = module.evaluate_promise("nodejs", {"state": "default", "stream": "12"}, {}) + + mock_mpc.reset.assert_called_with("nodejs") + assert result == Result.REPAIRED + + +def test_stream_default_resolution(module, mock_base, mock_mpc): + """Test resolving stream => 'default'""" + mock_mpc.getDefaultStream.return_value = "12" + + # State check uses resolved stream + mock_mpc.getModuleState.return_value = mock_mpc.ModuleState_ENABLED + mock_mpc.getEnabledStream.return_value = "12" + + result = module.evaluate_promise( + "nodejs", {"state": "enabled", "stream": "default"}, {} + ) + + assert result == Result.KEPT + + +def test_profile_default_resolution(module, mock_base, mock_mpc): + """Test resolving profile => 'default'""" + mock_mpc.getDefaultStream.return_value = "12" + mock_mpc.getDefaultProfiles.return_value = ["default_prof"] + + mock_mpc.getModuleState.return_value = mock_mpc.ModuleState_INSTALLED + mock_mpc.getEnabledStream.return_value = "12" + mock_mpc.getInstalledProfiles.return_value = ["default_prof"] + + result = module.evaluate_promise( + "nodejs", {"state": "installed", "stream": "12", "profile": "default"}, {} + ) + + assert result == Result.KEPT + + +def test_invalid_aliases(module): + """Verify that aliases 'install' and 'remove' are invalid""" + + # Test 'install' + with pytest.raises(ValidationError): + module._validate_state("install") + + # Test 'remove' + with pytest.raises(ValidationError): + module._validate_state("remove") + + +def test_get_module_state_logic(module, mock_mpc): + """Test the logic of _get_module_state""" + mock_mpc.getModuleState.return_value = mock_mpc.ModuleState_ENABLED + assert module._get_module_state(mock_mpc, "nodejs") == "enabled" + + mock_mpc.getModuleState.return_value = mock_mpc.ModuleState_DISABLED + assert module._get_module_state(mock_mpc, "nodejs") == "disabled" + + mock_mpc.getModuleState.return_value = mock_mpc.ModuleState_INSTALLED + assert module._get_module_state(mock_mpc, "nodejs") == "installed" + + mock_mpc.getModuleState.return_value = mock_mpc.ModuleState_DEFAULT + assert module._get_module_state(mock_mpc, "nodejs") == "removed" + + mock_mpc.getModuleState.return_value = 999 # Unknown state + assert module._get_module_state(mock_mpc, "nodejs") == "removed" + + +def test_remove_already_removed(module, mock_base, mock_mpc): + """Test removing a module that is already removed (KEPT)""" + mock_mpc.getModuleState.return_value = mock_mpc.ModuleState_DEFAULT + result = module.evaluate_promise("nodejs", {"state": "removed"}, {}) + assert result == Result.KEPT + + +def test_remove_already_disabled(module, mock_base, mock_mpc): + """Test removing a module that is already disabled (KEPT)""" + mock_mpc.getModuleState.return_value = mock_mpc.ModuleState_DISABLED + result = module.evaluate_promise("nodejs", {"state": "removed"}, {}) + assert result == Result.KEPT + + +def test_enable_wrong_stream_repaired(module, mock_base, mock_mpc): + """Test enabling a module that is enabled but with wrong stream (REPAIRED)""" + mock_mpc.getModuleState.return_value = mock_mpc.ModuleState_ENABLED + mock_mpc.getEnabledStream.return_value = "10" + mock_mpc.isEnabled.return_value = True + result = module.evaluate_promise("nodejs", {"state": "enabled", "stream": "12"}, {}) + mock_mpc.enable.assert_called_with("nodejs", "12") + assert result == Result.REPAIRED + + +def test_stream_default_not_found(module, mock_base, mock_mpc): + """Test stream => 'default' when no default stream exists (NOT_KEPT)""" + mock_mpc.getDefaultStream.return_value = None + result = module.evaluate_promise( + "nodejs", {"state": "enabled", "stream": "default"}, {} + ) + assert result == Result.NOT_KEPT + + +def test_profile_default_not_found(module, mock_base, mock_mpc): + """Test profile => 'default' when no default profile exists (NOT_KEPT)""" + mock_mpc.getDefaultStream.return_value = "12" + mock_mpc.getDefaultProfiles.return_value = [] + result = module.evaluate_promise( + "nodejs", {"state": "installed", "stream": "12", "profile": "default"}, {} + ) + assert result == Result.NOT_KEPT + + +def test_remove_unknown_module_runtime_error(module, mock_base, mock_mpc): + """Test removing a module when getEnabledStream raises RuntimeError""" + mock_mpc.getModuleState.return_value = mock_mpc.ModuleState_ENABLED + mock_mpc.getEnabledStream.side_effect = RuntimeError("No such module") + result = module.evaluate_promise("unknown_mod", {"state": "removed"}, {}) + # With no stream and getEnabledStream failing, target_stream is None, so KEPT + assert result == Result.KEPT