From bce9c4ea23d34fdee3cb270a1c2db14fd3cae312 Mon Sep 17 00:00:00 2001 From: Nick Anderson Date: Tue, 11 Nov 2025 15:14:26 -0600 Subject: [PATCH 1/5] Prototyped dnf_appstream custom promise type I wasn't looking at the ticket prior to implementation, likely missing things. Ticket: CFE-3653 --- promise-types/dnf_appstream/dnf_appstream.py | 223 +++++++++++++++++++ 1 file changed, 223 insertions(+) create mode 100644 promise-types/dnf_appstream/dnf_appstream.py diff --git a/promise-types/dnf_appstream/dnf_appstream.py b/promise-types/dnf_appstream/dnf_appstream.py new file mode 100644 index 0000000..a5a5bb2 --- /dev/null +++ b/promise-types/dnf_appstream/dnf_appstream.py @@ -0,0 +1,223 @@ +#!/usr/bin/python3 +# +# Custom promise type to manage DNF AppStream modules +# Uses cfengine_module_library.py library. +# +# Use it in the policy like this: +# promise agent dnf_appstream +# { +# interpreter => "/usr/bin/python3"; +# path => "$(sys.inputdir)/dnf_appstream.py"; +# } +# bundle agent main +# { +# dnf_appstream: +# "nodejs" +# state => "enabled", +# stream => "12"; +# } + +import dnf +import re +from cfengine_module_library import PromiseModule, ValidationError, Result + + +class DnfAppStreamPromiseTypeModule(PromiseModule): + def __init__(self, **kwargs): + super(DnfAppStreamPromiseTypeModule, self).__init__( + name="dnf_appstream_promise_module", version="0.0.1", **kwargs + ) + + # Define all expected attributes with their types and validation + self.add_attribute("state", str, required=True, default="enabled", + validator=lambda x: self._validate_state(x)) + self.add_attribute("stream", str, required=False) + self.add_attribute("profile", str, required=False) + + def _validate_state(self, value): + if value not in ("enabled", "disabled", "installed", "removed"): + raise ValidationError("state attribute must be 'enabled', 'disabled', 'installed', or 'removed'") + + 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, 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, dot, and dash characters are allowed.") + + def validate_promise(self, promiser, attributes, meta): + # Validate promiser (module name) + if not isinstance(promiser, str): + raise ValidationError("Promiser must be of type string") + + self._validate_module_name(promiser) + + # Validate stream if provided + if "stream" in attributes: + self._validate_stream_name(attributes["stream"]) + + def evaluate_promise(self, promiser, attributes, meta): + module_name = promiser + state = attributes.get("state", "enabled") + stream = attributes.get("stream", None) + profile = attributes.get("profile", None) + + # Construct the module specification + module_spec = module_name + if stream: + module_spec += ":" + stream + if profile: + module_spec += "/" + profile + + try: + # Create a DNF base object + base = dnf.Base() + + # Read configuration + base.conf.assumeyes = True + + # Read repository information + base.read_all_repos() + + # Fill the sack (package database) + base.fill_sack(load_system_repo='auto') + + # Access the module base + module_base = base.module_base + if module_base is None: + self.log_error("DNF modules are not available") + return Result.NOT_KEPT + + # Check current state of the module + current_state = self._get_module_state(module_base, module_name, stream) + + # Determine what action to take based on desired state + if state == "enabled": + if current_state == "enabled": + self.log_verbose(f"Module {module_name} is already enabled") + return Result.KEPT + else: + return self._enable_module(module_base, module_spec) + 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(module_base, module_spec) + elif state == "installed": + if current_state in ["installed", "enabled"]: + # For "installed" state, if it's already installed or enabled, + # we need to install packages from it + # But if it's already installed with packages, we're done + if self._is_module_installed_with_packages(base, module_name, stream): + self.log_verbose(f"Module {module_name} is already installed with packages") + return Result.KEPT + else: + # Module is enabled but packages are not installed + return self._install_module(module_base, module_spec) + else: + # Module is not enabled, need to install (which will enable and install packages) + return self._install_module(module_base, module_spec) + elif state == "removed": + if current_state == "removed" or current_state == "disabled": + self.log_verbose(f"Module {module_name} is already removed or disabled") + return Result.KEPT + else: + return self._remove_module(module_base, module_spec) + + except Exception as e: + self.log_error(f"Error managing module {module_name}: {str(e)}") + return Result.NOT_KEPT + + def _get_module_state(self, module_base, module_name, stream): + """Get the current state of a module using DNF Python API""" + try: + # List all modules to check the current state + module_list, _ = module_base._get_modules(module_name) + + for module in module_list: + # Check if this is the stream we're looking for (if specified) + if stream and module.stream != stream: + continue + + # Check the module state + if module.status == "enabled": + return "enabled" + elif module.status == "disabled": + return "disabled" + elif module.status == "installed": + return "installed" + + # If we get here, module is not found or not in the specified stream + return "removed" + + except Exception as e: + self.log_error(f"Error getting module state for {module_name}: {str(e)}") + return "unknown" + + def _is_module_installed_with_packages(self, base, module_name, stream): + """Check if the module packages are actually installed on the system""" + try: + # Check if packages from the module are installed + # This is a more complex check that requires examining installed packages + # to see if they are from the specified module + return False # Simplified for now - would need more complex logic + except Exception: + return False + + def _enable_module(self, module_base, module_spec): + """Enable a module using DNF Python API""" + try: + module_base.enable([module_spec]) + module_base.base.resolve() + module_base.base.do_transaction() + self.log_verbose(f"Module {module_spec} enabled successfully") + return Result.REPAIRED + except Exception as e: + self.log_error(f"Failed to enable module {module_spec}: {str(e)}") + return Result.NOT_KEPT + + def _disable_module(self, module_base, module_spec): + """Disable a module using DNF Python API""" + try: + module_base.disable([module_spec]) + module_base.base.resolve() + module_base.base.do_transaction() + self.log_verbose(f"Module {module_spec} disabled successfully") + return Result.REPAIRED + except Exception as e: + self.log_error(f"Failed to disable module {module_spec}: {str(e)}") + return Result.NOT_KEPT + + def _install_module(self, module_base, module_spec): + """Install a module (enable + install default packages) using DNF Python API""" + try: + # Enable and install the module + module_base.install([module_spec]) + module_base.base.resolve() + module_base.base.do_transaction() + self.log_verbose(f"Module {module_spec} installed successfully") + return Result.REPAIRED + except Exception as e: + self.log_error(f"Failed to install module {module_spec}: {str(e)}") + return Result.NOT_KEPT + + def _remove_module(self, module_base, module_spec): + """Remove a module using DNF Python API""" + try: + # Get list of packages from the module to remove + module_base.remove([module_spec]) + module_base.base.resolve() + module_base.base.do_transaction() + self.log_verbose(f"Module {module_spec} removed successfully") + return Result.REPAIRED + except Exception as e: + self.log_error(f"Failed to remove module {module_spec}: {str(e)}") + return Result.NOT_KEPT + + +if __name__ == "__main__": + DnfAppStreamPromiseTypeModule().start() From 132c134943219037a279ad97e6df8c3f1d9a8f3b Mon Sep 17 00:00:00 2001 From: Nick Anderson Date: Wed, 7 Jan 2026 12:40:47 -0600 Subject: [PATCH 2/5] Address review comments for dnf_appstream module - Improve state validation error message. - Move validation to attribute validators. - Simplify state checking logic. - Use info logging for repairs. - Remove redundant try/except blocks. - Add test and documentation files. --- promise-types/dnf_appstream/README.md | 187 ++++++++++++++++++ promise-types/dnf_appstream/cfbs.json | 21 ++ promise-types/dnf_appstream/dnf_appstream.py | 124 ++++++------ promise-types/dnf_appstream/example.cf | 103 ++++++++++ .../dnf_appstream/test_dnf_appstream.py | 146 ++++++++++++++ 5 files changed, 513 insertions(+), 68 deletions(-) create mode 100644 promise-types/dnf_appstream/README.md create mode 100644 promise-types/dnf_appstream/cfbs.json create mode 100644 promise-types/dnf_appstream/example.cf create mode 100644 promise-types/dnf_appstream/test_dnf_appstream.py diff --git a/promise-types/dnf_appstream/README.md b/promise-types/dnf_appstream/README.md new file mode 100644 index 0000000..22e8c2c --- /dev/null +++ b/promise-types/dnf_appstream/README.md @@ -0,0 +1,187 @@ +# DNF AppStream Promise Type + +A CFEngine custom promise type for managing DNF AppStream modules on RHEL 8+ and compatible systems. + +## Overview + +The `dnf_appstream` promise type allows you to manage DNF 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 DNF AppStream modules +- Support for specifying streams and profiles +- Input validation and sanitization for security +- Proper error handling and logging +- Module state checking to avoid unnecessary operations +- Uses DNF Python API for efficient and secure operations + +## Installation + +To install this promise type, copy the `dnf_appstream.py` file to your CFEngine masterfiles directory and configure the promise agent: + +``` +promise agent dnf_appstream +{ + interpreter => "/usr/bin/python3"; + path => "$(sys.inputdir)/dnf_appstream.py"; +} +``` + +## Usage + +### Enable a Module + +``` +bundle agent main +{ + dnf_appstream: + "nodejs" + state => "enabled", + stream => "12"; +} +``` + +### Disable a Module + +``` +bundle agent main +{ + dnf_appstream: + "nodejs" + state => "disabled"; +} +``` + +### Install a Module with Profile + +``` +bundle agent main +{ + dnf_appstream: + "python36" + state => "installed", + stream => "3.6", + profile => "minimal"; +} +``` + +### Remove a Module + +``` +bundle agent main +{ + dnf_appstream: + "postgresql" + state => "removed"; +} +``` + +## Attributes + +The promise type supports the following attributes: + +- `state` (required) - Desired state of the module: `enabled`, `disabled`, `installed`, or `removed` (default: `enabled`) +- `stream` (optional) - Specific stream of the module to use +- `profile` (optional) - Specific profile of the module to install + +## Module States + +- `enabled` - The module is enabled and available for installation +- `disabled` - The module is disabled and not available for installation +- `installed` - The module is installed with its default profile (implies enabled) +- `removed` - The module is removed or not installed + +Note: The `installed` state implies `enabled` because in DNF's module system, installing a module automatically enables it first. + +## Security Features + +- Input validation and sanitization +- Module name validation (alphanumeric, underscore, dot, and dash only) +- Stream name validation (alphanumeric, underscore, dot, and dash only) +- Uses DNF Python API for secure operations instead of subprocess calls +- Proper error handling and timeout management + +## Requirements + +- CFEngine 3.18 or later +- Python 3 +- DNF Python API (python3-dnf package) +- DNF package manager (RHEL 8+, Fedora, CentOS 8+) +- AppStream repositories configured + +## Examples + +### Enable Multiple Modules + +``` +bundle agent enable_development_stack +{ + dnf_appstream: + "nodejs" + state => "enabled", + stream => "14"; + + "python36" + state => "enabled", + stream => "3.6"; + + "postgresql" + state => "enabled", + stream => "12"; +} +``` + +### Configure Web Server Stack + +``` +bundle agent configure_web_server +{ + dnf_appstream: + "nginx" + state => "installed", + stream => "1.14"; + + "php" + state => "installed", + stream => "7.4", + profile => "minimal"; +} +``` + +### Complete Example with Package Installation + +``` +promise agent dnf_appstream +{ + interpreter => "/usr/bin/python3"; + path => "$(sys.inputdir)/modules/promises/dnf_appstream.py"; +} + +body package_method dnf +{ + package_module => "dnf"; + package_policy => "present"; +} + +bundle agent setup_web_server +{ + # Enable AppStream modules + dnf_appstream: + "nodejs" + state => "enabled", + stream => "14"; + + "postgresql" + state => "installed", + stream => "12"; + + # Install packages from the enabled modules + packages: + # These packages will be installed from the enabled AppStream modules + "nodejs" package_method => dnf; + "postgresql-server" package_method => dnf; + + # Standard packages + "nginx" package_method => dnf; +} +``` \ No newline at end of file diff --git a/promise-types/dnf_appstream/cfbs.json b/promise-types/dnf_appstream/cfbs.json new file mode 100644 index 0000000..021c0cc --- /dev/null +++ b/promise-types/dnf_appstream/cfbs.json @@ -0,0 +1,21 @@ +{ + "name": "dnf_appstream", + "type": "promise-type", + "description": "A custom promise type to manage DNF AppStream modules", + "tags": ["dnf", "appstream", "modules", "package management", "redhat", "fedora", "centos"], + "files": [ + { + "path": "promise-types/dnf_appstream/dnf_appstream.py", + "type": "source", + "permissions": "644" + }, + { + "path": "promise-types/dnf_appstream/README.md", + "type": "documentation", + "permissions": "644" + } + ], + "dependencies": [], + "test_command": "python3 test_dnf_appstream.py", + "version": "0.0.1" +} \ No newline at end of file diff --git a/promise-types/dnf_appstream/dnf_appstream.py b/promise-types/dnf_appstream/dnf_appstream.py index a5a5bb2..cfb543d 100644 --- a/promise-types/dnf_appstream/dnf_appstream.py +++ b/promise-types/dnf_appstream/dnf_appstream.py @@ -31,12 +31,13 @@ def __init__(self, **kwargs): # Define all expected attributes with their types and validation self.add_attribute("state", str, required=True, default="enabled", validator=lambda x: self._validate_state(x)) - self.add_attribute("stream", str, required=False) + self.add_attribute("stream", str, required=False, + validator=lambda x: self._validate_stream_name(x)) self.add_attribute("profile", str, required=False) def _validate_state(self, value): if value not in ("enabled", "disabled", "installed", "removed"): - raise ValidationError("state attribute must be 'enabled', 'disabled', 'installed', or 'removed'") + raise ValidationError("State attribute must be 'enabled', 'disabled', 'installed', or 'removed'") def _validate_module_name(self, name): # Validate module name to prevent injection @@ -55,10 +56,6 @@ def validate_promise(self, promiser, attributes, meta): self._validate_module_name(promiser) - # Validate stream if provided - if "stream" in attributes: - self._validate_stream_name(attributes["stream"]) - def evaluate_promise(self, promiser, attributes, meta): module_name = promiser state = attributes.get("state", "enabled") @@ -72,65 +69,60 @@ def evaluate_promise(self, promiser, attributes, meta): if profile: module_spec += "/" + profile - try: - # Create a DNF base object - base = dnf.Base() - - # Read configuration - base.conf.assumeyes = True + # Create a DNF base object + base = dnf.Base() - # Read repository information - base.read_all_repos() + # Read configuration + base.conf.assumeyes = True - # Fill the sack (package database) - base.fill_sack(load_system_repo='auto') + # Read repository information + base.read_all_repos() - # Access the module base - module_base = base.module_base - if module_base is None: - self.log_error("DNF modules are not available") - return Result.NOT_KEPT + # Fill the sack (package database) + base.fill_sack(load_system_repo='auto') - # Check current state of the module - current_state = self._get_module_state(module_base, module_name, stream) + # Access the module base + module_base = base.module_base + if module_base is None: + self.log_error("DNF modules are not available") + return Result.NOT_KEPT - # Determine what action to take based on desired state - if state == "enabled": - if current_state == "enabled": - self.log_verbose(f"Module {module_name} is already enabled") - return Result.KEPT - else: - return self._enable_module(module_base, module_spec) - elif state == "disabled": - if current_state == "disabled": - self.log_verbose(f"Module {module_name} is already disabled") + # Check current state of the module + current_state = self._get_module_state(module_base, module_name, stream) + + # Determine what action to take based on desired state + if state == "enabled": + if current_state == "enabled": + self.log_verbose(f"Module {module_name} is already enabled") + return Result.KEPT + else: + return self._enable_module(module_base, module_spec) + 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(module_base, module_spec) + elif state == "installed": + if current_state in ["installed", "enabled"]: + # For "installed" state, if it's already installed or enabled, + # we need to install packages from it + # But if it's already installed with packages, we're done + if self._is_module_installed_with_packages(base, module_name, stream): + self.log_verbose(f"Module {module_name} is already installed with packages") return Result.KEPT else: - return self._disable_module(module_base, module_spec) - elif state == "installed": - if current_state in ["installed", "enabled"]: - # For "installed" state, if it's already installed or enabled, - # we need to install packages from it - # But if it's already installed with packages, we're done - if self._is_module_installed_with_packages(base, module_name, stream): - self.log_verbose(f"Module {module_name} is already installed with packages") - return Result.KEPT - else: - # Module is enabled but packages are not installed - return self._install_module(module_base, module_spec) - else: - # Module is not enabled, need to install (which will enable and install packages) + # Module is enabled but packages are not installed return self._install_module(module_base, module_spec) - elif state == "removed": - if current_state == "removed" or current_state == "disabled": - self.log_verbose(f"Module {module_name} is already removed or disabled") - return Result.KEPT - else: - return self._remove_module(module_base, module_spec) - - except Exception as e: - self.log_error(f"Error managing module {module_name}: {str(e)}") - return Result.NOT_KEPT + else: + # Module is not enabled, need to install (which will enable and install packages) + return self._install_module(module_base, module_spec) + elif state == "removed": + if current_state == "removed" or current_state == "disabled": + self.log_verbose(f"Module {module_name} is already removed or disabled") + return Result.KEPT + else: + return self._remove_module(module_base, module_spec) def _get_module_state(self, module_base, module_name, stream): """Get the current state of a module using DNF Python API""" @@ -144,12 +136,8 @@ def _get_module_state(self, module_base, module_name, stream): continue # Check the module state - if module.status == "enabled": - return "enabled" - elif module.status == "disabled": - return "disabled" - elif module.status == "installed": - return "installed" + if module.status in ("enabled", "disabled", "installed"): + return module.status # If we get here, module is not found or not in the specified stream return "removed" @@ -174,7 +162,7 @@ def _enable_module(self, module_base, module_spec): module_base.enable([module_spec]) module_base.base.resolve() module_base.base.do_transaction() - self.log_verbose(f"Module {module_spec} enabled successfully") + self.log_info(f"Module {module_spec} enabled successfully") return Result.REPAIRED except Exception as e: self.log_error(f"Failed to enable module {module_spec}: {str(e)}") @@ -186,7 +174,7 @@ def _disable_module(self, module_base, module_spec): module_base.disable([module_spec]) module_base.base.resolve() module_base.base.do_transaction() - self.log_verbose(f"Module {module_spec} disabled successfully") + self.log_info(f"Module {module_spec} disabled successfully") return Result.REPAIRED except Exception as e: self.log_error(f"Failed to disable module {module_spec}: {str(e)}") @@ -199,7 +187,7 @@ def _install_module(self, module_base, module_spec): module_base.install([module_spec]) module_base.base.resolve() module_base.base.do_transaction() - self.log_verbose(f"Module {module_spec} installed successfully") + self.log_info(f"Module {module_spec} installed successfully") return Result.REPAIRED except Exception as e: self.log_error(f"Failed to install module {module_spec}: {str(e)}") @@ -212,7 +200,7 @@ def _remove_module(self, module_base, module_spec): module_base.remove([module_spec]) module_base.base.resolve() module_base.base.do_transaction() - self.log_verbose(f"Module {module_spec} removed successfully") + self.log_info(f"Module {module_spec} removed successfully") return Result.REPAIRED except Exception as e: self.log_error(f"Failed to remove module {module_spec}: {str(e)}") @@ -220,4 +208,4 @@ def _remove_module(self, module_base, module_spec): if __name__ == "__main__": - DnfAppStreamPromiseTypeModule().start() + DnfAppStreamPromiseTypeModule().start() \ No newline at end of file diff --git a/promise-types/dnf_appstream/example.cf b/promise-types/dnf_appstream/example.cf new file mode 100644 index 0000000..9d0247f --- /dev/null +++ b/promise-types/dnf_appstream/example.cf @@ -0,0 +1,103 @@ +# Example policy using the dnf_appstream promise type and packages with AppStream info + +promise agent dnf_appstream +{ + interpreter => "/usr/bin/python3"; + path => "$(sys.inputdir)/promise-types/dnf_appstream/dnf_appstream.py"; +} + +promise agent rpm_repo +{ + interpreter => "/usr/bin/python3"; + path => "$(sys.inputdir)/promise-types/rpm_repo/rpm_repo.py"; +} + +body package_method dnf_with_modules +{ + package_module => "dnf"; + package_policy => "present"; +} + +bundle agent main +{ + # Configure repositories first + rpm_repo: + "epel" + name => "Extra Packages for Enterprise Linux", + baseurl => "https://download.fedoraproject.org/pub/epel/$releasever/$basearch/", + enabled => "1", + gpgcheck => "1", + gpgkey => "https://download.fedoraproject.org/pub/epel/RPM-GPG-KEY-EPEL-$releasever"; + + # Configure AppStream modules before installing packages + dnf_appstream: + "nodejs" + state => "enabled", + stream => "14"; + + "python36" + state => "installed", + stream => "3.6", + profile => "minimal"; + + "postgresql" + state => "enabled", + stream => "12"; + + # Install packages that are part of AppStream modules + packages: + # Node.js packages from the enabled stream + "nodejs" + package_method => dnf_with_modules; + + # Python 3.6 packages from the installed stream + "python36" + package_method => dnf_with_modules; + + # PostgreSQL packages from the enabled stream + "postgresql-server" + package_method => dnf_with_modules; + + # Other packages from standard repositories + "nginx" + package_method => dnf_with_modules; + "git" + package_method => dnf_with_modules; +} + +bundle agent setup_development_environment +{ + # Enable development-related modules + dnf_appstream: + "nodejs" + state => "enabled", + stream => "16"; + + "python36" + state => "installed", + stream => "3.6", + profile => "development"; + + "maven" + state => "enabled", + stream => "3.6"; + + packages: + # Install packages from the enabled modules + "nodejs" + package_method => dnf_with_modules; + + "python36" + package_method => dnf_with_modules; + + "maven" + package_method => dnf_with_modules; + + # Additional development tools + "gcc" + package_method => dnf_with_modules; + "make" + package_method => dnf_with_modules; + "vim-enhanced" + package_method => dnf_with_modules; +} \ No newline at end of file diff --git a/promise-types/dnf_appstream/test_dnf_appstream.py b/promise-types/dnf_appstream/test_dnf_appstream.py new file mode 100644 index 0000000..46feac1 --- /dev/null +++ b/promise-types/dnf_appstream/test_dnf_appstream.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 + +import os +import sys + +# Add the libraries directory to the Python path so we can import cfengine_module_library +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "libraries", "python")) + +try: + from dnf_appstream import DnfAppStreamPromiseTypeModule + from cfengine_module_library import ValidationError +except ImportError as e: + print(f"Import error: {e}") + print("Make sure cfengine_module_library.py is in the correct location") + sys.exit(1) + +def test_validation(): + """Test validation of module attributes""" + print("Testing validation...") + + module = DnfAppStreamPromiseTypeModule() + + # Test valid attributes + try: + module.validate_promise("nodejs", { + "state": "enabled", + "stream": "12" + }, {}) + print(" ✓ Valid attributes validation passed") + except Exception as e: + print(f" ✗ Valid attributes validation failed: {e}") + + # Test invalid module name + try: + module.validate_promise("nodejs; rm -rf /", { + "state": "enabled" + }, {}) + print(" ✗ Invalid module name validation failed - should have caught injection") + except ValidationError as e: + print(f" ✓ Invalid module name validation passed: {e}") + except Exception as e: + print(f" ? Unexpected exception for invalid module name: {e}") + + # Note: Stream and State validation have been moved to attribute validators + # which are handled by the library, not inside validate_promise directly. + # Therefore we don't test them here via validate_promise, but in their specific test functions below. + +def test_module_name_validation(): + """Test module name validation""" + print("\nTesting module name validation...") + + module = DnfAppStreamPromiseTypeModule() + + # Test valid names + valid_names = ["nodejs", "python3.6", "python36", "postgresql", "maven", "httpd"] + for name in valid_names: + try: + module._validate_module_name(name) + print(f" ✓ Valid name '{name}' passed validation") + except Exception as e: + print(f" ✗ Valid name '{name}' failed validation: {e}") + + # Test invalid names + invalid_names = ["nodejs;rm", "python36&&", "postgresql|", "maven>", "httpd<"] + for name in invalid_names: + try: + module._validate_module_name(name) + print(f" ✗ Invalid name '{name}' passed validation - should have failed") + except Exception as e: + print(f" ✓ Invalid name '{name}' failed validation as expected: {e}") + +def test_stream_name_validation(): + """Test stream name validation""" + print("\nTesting stream name validation...") + + module = DnfAppStreamPromiseTypeModule() + + # Test valid stream names + valid_streams = ["12", "14", "3.6", "1.14", "latest", "stable"] + for stream in valid_streams: + try: + module._validate_stream_name(stream) + print(f" ✓ Valid stream '{stream}' passed validation") + except Exception as e: + print(f" ✗ Valid stream '{stream}' failed validation: {e}") + + # Test invalid stream names + invalid_streams = ["12;rm", "14&&", "3.6|", "latest>", "stable<"] + for stream in invalid_streams: + try: + module._validate_stream_name(stream) + print(f" ✗ Invalid stream '{stream}' passed validation - should have failed") + except Exception as e: + print(f" ✓ Invalid stream '{stream}' failed validation as expected: {e}") + +def test_state_validation(): + """Test state validation""" + print("\nTesting state validation...") + + module = DnfAppStreamPromiseTypeModule() + + # Test valid states + valid_states = ["enabled", "disabled", "installed", "removed"] + for state in valid_states: + try: + module._validate_state(state) + print(f" ✓ Valid state '{state}' passed validation") + except Exception as e: + print(f" ✗ Valid state '{state}' failed validation: {e}") + + # Test invalid states + invalid_states = ["active", "inactive", "present", "absent", "enable", "disable"] + for state in invalid_states: + try: + module._validate_state(state) + print(f" ✗ Invalid state '{state}' passed validation - should have failed") + except ValidationError as e: + print(f" ✓ Invalid state '{state}' failed validation as expected: {e}") + except Exception as e: + print(f" ? Unexpected exception for invalid state '{state}': {e}") + +def test_state_parsing(): + """Test parsing of module states from dnf output""" + print("\nTesting state parsing...") + + module = DnfAppStreamPromiseTypeModule() + + # Test that the method exists and can be called + try: + # We can't easily test the actual parsing without mocking dnf, + # but we can at least verify the method exists + hasattr(module, '_get_module_state') + print(" ✓ State parsing method exists") + except Exception as e: + print(f" ✗ State parsing method test failed: {e}") + +if __name__ == "__main__": + print("Running tests for dnf_appstream promise type...") + + test_validation() + test_module_name_validation() + test_stream_name_validation() + test_state_validation() + test_state_parsing() + + print("\nAll tests completed.") From 568afb1d53b6f9c3cc7d0334f9729debda27bf61 Mon Sep 17 00:00:00 2001 From: Nick Anderson Date: Wed, 7 Jan 2026 12:44:20 -0600 Subject: [PATCH 3/5] whitespace cleanup --- promise-types/dnf_appstream/README.md | 4 +-- promise-types/dnf_appstream/example.cf | 36 +++++++++---------- .../dnf_appstream/test_dnf_appstream.py | 34 +++++++++--------- 3 files changed, 37 insertions(+), 37 deletions(-) diff --git a/promise-types/dnf_appstream/README.md b/promise-types/dnf_appstream/README.md index 22e8c2c..032a2b7 100644 --- a/promise-types/dnf_appstream/README.md +++ b/promise-types/dnf_appstream/README.md @@ -180,8 +180,8 @@ bundle agent setup_web_server # These packages will be installed from the enabled AppStream modules "nodejs" package_method => dnf; "postgresql-server" package_method => dnf; - + # Standard packages "nginx" package_method => dnf; } -``` \ No newline at end of file +``` diff --git a/promise-types/dnf_appstream/example.cf b/promise-types/dnf_appstream/example.cf index 9d0247f..57c5414 100644 --- a/promise-types/dnf_appstream/example.cf +++ b/promise-types/dnf_appstream/example.cf @@ -47,21 +47,21 @@ bundle agent main # Install packages that are part of AppStream modules packages: # Node.js packages from the enabled stream - "nodejs" + "nodejs" package_method => dnf_with_modules; - + # Python 3.6 packages from the installed stream - "python36" + "python36" package_method => dnf_with_modules; - + # PostgreSQL packages from the enabled stream - "postgresql-server" + "postgresql-server" package_method => dnf_with_modules; - + # Other packages from standard repositories - "nginx" + "nginx" package_method => dnf_with_modules; - "git" + "git" package_method => dnf_with_modules; } @@ -84,20 +84,20 @@ bundle agent setup_development_environment packages: # Install packages from the enabled modules - "nodejs" + "nodejs" package_method => dnf_with_modules; - - "python36" + + "python36" package_method => dnf_with_modules; - - "maven" + + "maven" package_method => dnf_with_modules; - + # Additional development tools - "gcc" + "gcc" package_method => dnf_with_modules; - "make" + "make" package_method => dnf_with_modules; - "vim-enhanced" + "vim-enhanced" package_method => dnf_with_modules; -} \ No newline at end of file +} diff --git a/promise-types/dnf_appstream/test_dnf_appstream.py b/promise-types/dnf_appstream/test_dnf_appstream.py index 46feac1..eeb1976 100644 --- a/promise-types/dnf_appstream/test_dnf_appstream.py +++ b/promise-types/dnf_appstream/test_dnf_appstream.py @@ -17,9 +17,9 @@ def test_validation(): """Test validation of module attributes""" print("Testing validation...") - + module = DnfAppStreamPromiseTypeModule() - + # Test valid attributes try: module.validate_promise("nodejs", { @@ -29,7 +29,7 @@ def test_validation(): print(" ✓ Valid attributes validation passed") except Exception as e: print(f" ✗ Valid attributes validation failed: {e}") - + # Test invalid module name try: module.validate_promise("nodejs; rm -rf /", { @@ -40,7 +40,7 @@ def test_validation(): print(f" ✓ Invalid module name validation passed: {e}") except Exception as e: print(f" ? Unexpected exception for invalid module name: {e}") - + # Note: Stream and State validation have been moved to attribute validators # which are handled by the library, not inside validate_promise directly. # Therefore we don't test them here via validate_promise, but in their specific test functions below. @@ -48,9 +48,9 @@ def test_validation(): def test_module_name_validation(): """Test module name validation""" print("\nTesting module name validation...") - + module = DnfAppStreamPromiseTypeModule() - + # Test valid names valid_names = ["nodejs", "python3.6", "python36", "postgresql", "maven", "httpd"] for name in valid_names: @@ -59,7 +59,7 @@ def test_module_name_validation(): print(f" ✓ Valid name '{name}' passed validation") except Exception as e: print(f" ✗ Valid name '{name}' failed validation: {e}") - + # Test invalid names invalid_names = ["nodejs;rm", "python36&&", "postgresql|", "maven>", "httpd<"] for name in invalid_names: @@ -72,9 +72,9 @@ def test_module_name_validation(): def test_stream_name_validation(): """Test stream name validation""" print("\nTesting stream name validation...") - + module = DnfAppStreamPromiseTypeModule() - + # Test valid stream names valid_streams = ["12", "14", "3.6", "1.14", "latest", "stable"] for stream in valid_streams: @@ -83,7 +83,7 @@ def test_stream_name_validation(): print(f" ✓ Valid stream '{stream}' passed validation") except Exception as e: print(f" ✗ Valid stream '{stream}' failed validation: {e}") - + # Test invalid stream names invalid_streams = ["12;rm", "14&&", "3.6|", "latest>", "stable<"] for stream in invalid_streams: @@ -96,9 +96,9 @@ def test_stream_name_validation(): def test_state_validation(): """Test state validation""" print("\nTesting state validation...") - + module = DnfAppStreamPromiseTypeModule() - + # Test valid states valid_states = ["enabled", "disabled", "installed", "removed"] for state in valid_states: @@ -107,7 +107,7 @@ def test_state_validation(): print(f" ✓ Valid state '{state}' passed validation") except Exception as e: print(f" ✗ Valid state '{state}' failed validation: {e}") - + # Test invalid states invalid_states = ["active", "inactive", "present", "absent", "enable", "disable"] for state in invalid_states: @@ -122,9 +122,9 @@ def test_state_validation(): def test_state_parsing(): """Test parsing of module states from dnf output""" print("\nTesting state parsing...") - + module = DnfAppStreamPromiseTypeModule() - + # Test that the method exists and can be called try: # We can't easily test the actual parsing without mocking dnf, @@ -136,11 +136,11 @@ def test_state_parsing(): if __name__ == "__main__": print("Running tests for dnf_appstream promise type...") - + test_validation() test_module_name_validation() test_stream_name_validation() test_state_validation() test_state_parsing() - + print("\nAll tests completed.") From 754e2fec255f99e118d1cf02f05c4d2f002b3753 Mon Sep 17 00:00:00 2001 From: Nick Anderson Date: Wed, 21 Jan 2026 15:59:28 -0600 Subject: [PATCH 4/5] Added reset/default support Added ability to reset/default a module as well as specify "default" for the profile or stream. --- promise-types/dnf_appstream/README.md | 125 +++--------- promise-types/dnf_appstream/dnf_appstream.py | 179 +++++++++++++++--- .../dnf_appstream/test_dnf_appstream.py | 20 +- 3 files changed, 200 insertions(+), 124 deletions(-) diff --git a/promise-types/dnf_appstream/README.md b/promise-types/dnf_appstream/README.md index 032a2b7..d8566e4 100644 --- a/promise-types/dnf_appstream/README.md +++ b/promise-types/dnf_appstream/README.md @@ -29,7 +29,7 @@ promise agent dnf_appstream ## Usage -### Enable a Module +### Ensure a module is enabled ``` bundle agent main @@ -41,7 +41,7 @@ bundle agent main } ``` -### Disable a Module +### Ensure a module is disabled ``` bundle agent main @@ -52,27 +52,38 @@ bundle agent main } ``` -### Install a Module with Profile +### Ensure a module is installed with a specific profile ``` bundle agent main { dnf_appstream: "python36" - state => "installed", + state => "present", stream => "3.6", profile => "minimal"; } ``` -### Remove a Module +### Ensure a module is absent ``` bundle agent main { dnf_appstream: "postgresql" - state => "removed"; + state => "absent"; +} +``` + +### Reset a module to default + +``` +bundle agent main +{ + dnf_appstream: + "nodejs" + state => "default"; } ``` @@ -80,26 +91,19 @@ bundle agent main The promise type supports the following attributes: -- `state` (required) - Desired state of the module: `enabled`, `disabled`, `installed`, or `removed` (default: `enabled`) -- `stream` (optional) - Specific stream of the module to use -- `profile` (optional) - Specific profile of the module to install +- `state` (required) - Desired state of the module: `present`, `absent`, `enabled`, `disabled`, or `default` (default: `present`) +- `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. ## Module States -- `enabled` - The module is enabled and available for installation -- `disabled` - The module is disabled and not available for installation -- `installed` - The module is installed with its default profile (implies enabled) -- `removed` - The module is removed or not installed - -Note: The `installed` state implies `enabled` because in DNF's module system, installing a module automatically enables it first. +- `present` - The module and its packages (profile) are present on the system (implies enabled). Alias: `install`. +- `absent` - The module is not present or is disabled. Alias: `remove`. +- `enabled` - The module is enabled and available for installation. +- `disabled` - The module is explicitly disabled. +- `default` - The module is in its default state (neither enabled nor disabled, no profiles installed). Alias: `reset`. -## Security Features - -- Input validation and sanitization -- Module name validation (alphanumeric, underscore, dot, and dash only) -- Stream name validation (alphanumeric, underscore, dot, and dash only) -- Uses DNF Python API for secure operations instead of subprocess calls -- Proper error handling and timeout management +Note: The `present` state implies `enabled` because in DNF's module system, installing a module automatically enables it first. ## Requirements @@ -108,80 +112,3 @@ Note: The `installed` state implies `enabled` because in DNF's module system, in - DNF Python API (python3-dnf package) - DNF package manager (RHEL 8+, Fedora, CentOS 8+) - AppStream repositories configured - -## Examples - -### Enable Multiple Modules - -``` -bundle agent enable_development_stack -{ - dnf_appstream: - "nodejs" - state => "enabled", - stream => "14"; - - "python36" - state => "enabled", - stream => "3.6"; - - "postgresql" - state => "enabled", - stream => "12"; -} -``` - -### Configure Web Server Stack - -``` -bundle agent configure_web_server -{ - dnf_appstream: - "nginx" - state => "installed", - stream => "1.14"; - - "php" - state => "installed", - stream => "7.4", - profile => "minimal"; -} -``` - -### Complete Example with Package Installation - -``` -promise agent dnf_appstream -{ - interpreter => "/usr/bin/python3"; - path => "$(sys.inputdir)/modules/promises/dnf_appstream.py"; -} - -body package_method dnf -{ - package_module => "dnf"; - package_policy => "present"; -} - -bundle agent setup_web_server -{ - # Enable AppStream modules - dnf_appstream: - "nodejs" - state => "enabled", - stream => "14"; - - "postgresql" - state => "installed", - stream => "12"; - - # Install packages from the enabled modules - packages: - # These packages will be installed from the enabled AppStream modules - "nodejs" package_method => dnf; - "postgresql-server" package_method => dnf; - - # Standard packages - "nginx" package_method => dnf; -} -``` diff --git a/promise-types/dnf_appstream/dnf_appstream.py b/promise-types/dnf_appstream/dnf_appstream.py index cfb543d..480fa73 100644 --- a/promise-types/dnf_appstream/dnf_appstream.py +++ b/promise-types/dnf_appstream/dnf_appstream.py @@ -13,8 +13,11 @@ # { # dnf_appstream: # "nodejs" -# state => "enabled", +# state => "present", # stream => "12"; +# +# "postgresql" +# state => "default"; # } import dnf @@ -36,18 +39,30 @@ def __init__(self, **kwargs): self.add_attribute("profile", str, required=False) def _validate_state(self, value): - if value not in ("enabled", "disabled", "installed", "removed"): - raise ValidationError("State attribute must be 'enabled', 'disabled', 'installed', or 'removed'") + if value not in ( + "enabled", "disabled", "install", "remove", "default", "reset", + "present", "absent" + ): + raise ValidationError( + "State attribute must be 'enabled', 'disabled', 'install', " + "'remove', 'default', 'reset', 'present', or 'absent'" + ) 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, dot, and dash characters are allowed.") + 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, dot, and dash characters are allowed.") + raise ValidationError( + f"Invalid stream name: {stream}. Only alphanumeric, underscore, " + f"dot, and dash characters are allowed." + ) def validate_promise(self, promiser, attributes, meta): # Validate promiser (module name) @@ -87,6 +102,39 @@ def evaluate_promise(self, promiser, attributes, meta): self.log_error("DNF modules are not available") return Result.NOT_KEPT + # Handle stream => "default" + if stream == "default": + stream = self._get_default_stream(module_base, 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(module_base, module_name) + + profile = self._get_default_profile(module_base, 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}'") + + # Re-construct the module specification with resolved values + module_spec = module_name + if stream: + module_spec += ":" + stream + if profile: + module_spec += "/" + profile + # Check current state of the module current_state = self._get_module_state(module_base, module_name, stream) @@ -103,26 +151,36 @@ def evaluate_promise(self, promiser, attributes, meta): return Result.KEPT else: return self._disable_module(module_base, module_spec) - elif state == "installed": + elif state == "install" or state == "present": if current_state in ["installed", "enabled"]: - # For "installed" state, if it's already installed or enabled, - # we need to install packages from it - # But if it's already installed with packages, we're done - if self._is_module_installed_with_packages(base, module_name, stream): - self.log_verbose(f"Module {module_name} is already installed with packages") + # For "present" 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( + module_base, module_name, stream, profile + ): + self.log_verbose( + f"Module {module_name} (stream: {stream}, " + f"profile: {profile}) is already present" + ) return Result.KEPT else: - # Module is enabled but packages are not installed return self._install_module(module_base, module_spec) else: - # Module is not enabled, need to install (which will enable and install packages) + # Module is not enabled, need to install + # (which will enable and install packages) return self._install_module(module_base, module_spec) - elif state == "removed": + elif state == "remove" or state == "absent": if current_state == "removed" or current_state == "disabled": - self.log_verbose(f"Module {module_name} is already removed or disabled") + self.log_verbose( + f"Module {module_name} is already absent or disabled" + ) return Result.KEPT else: return self._remove_module(module_base, module_spec) + elif state == "default" or state == "reset": + return self._reset_module( + module_base, module_name, stream, module_spec + ) def _get_module_state(self, module_base, module_name, stream): """Get the current state of a module using DNF Python API""" @@ -146,14 +204,63 @@ def _get_module_state(self, module_base, module_name, stream): self.log_error(f"Error getting module state for {module_name}: {str(e)}") return "unknown" - def _is_module_installed_with_packages(self, base, module_name, stream): - """Check if the module packages are actually installed on the system""" + def _get_default_stream(self, module_base, module_name): + """Find the default stream for a module""" try: - # Check if packages from the module are installed - # This is a more complex check that requires examining installed packages - # to see if they are from the specified module - return False # Simplified for now - would need more complex logic - except Exception: + module_list, _ = module_base._get_modules(module_name) + for module in module_list: + # DNF API: module.is_default is usually a boolean + if getattr(module, "is_default", False): + return module.stream + return None + except Exception as e: + self.log_debug(f"Error finding default stream for {module_name}: {e}") + return None + + def _get_default_profile(self, module_base, module_name, stream): + """Find the default profile for a module stream""" + try: + module_list, _ = module_base._get_modules(module_name) + for module in module_list: + if stream and module.stream != stream: + continue + + # If finding for specific stream (or default stream found) + for profile in module.profiles: + if getattr(profile, "is_default", False): + return profile.name + return None + except Exception as e: + self.log_debug(f"Error finding default profile for {module_name}: {e}") + return None + + def _is_module_installed_with_packages( + self, module_base, module_name, stream, profile_name + ): + """Check if the module packages/profiles are installed on the system""" + try: + module_list, _ = module_base._get_modules(module_name) + for module in module_list: + if stream and module.stream != stream: + continue + + if module.status != "installed": + continue + + # If no profile is specified, 'installed' status is enough + if not profile_name: + return True + + # If profile is specified, check if it's installed + for profile in module.profiles: + if profile.name == profile_name: + return profile.status == "installed" + + return False + except Exception as e: + self.log_debug( + f"Error checking if module packages are installed: {e}" + ) return False def _enable_module(self, module_base, module_spec): @@ -206,6 +313,34 @@ def _remove_module(self, module_base, module_spec): self.log_error(f"Failed to remove module {module_spec}: {str(e)}") return Result.NOT_KEPT + def _reset_module(self, module_base, module_name, stream, module_spec): + """Reset a module using DNF Python API""" + try: + # First check if anything is enabled/installed for this module + module_list, _ = module_base._get_modules(module_name) + needs_reset = False + for module in module_list: + if stream and module.stream != stream: + continue + if module.status in ("enabled", "disabled", "installed"): + needs_reset = True + break + + if not needs_reset: + self.log_verbose( + f"Module {module_name} is already in default (reset) state" + ) + return Result.KEPT + + module_base.reset([module_spec]) + module_base.base.resolve() + module_base.base.do_transaction() + self.log_info(f"Module {module_spec} reset successfully") + return Result.REPAIRED + except Exception as e: + self.log_error(f"Failed to reset module {module_spec}: {str(e)}") + return Result.NOT_KEPT + if __name__ == "__main__": DnfAppStreamPromiseTypeModule().start() \ No newline at end of file diff --git a/promise-types/dnf_appstream/test_dnf_appstream.py b/promise-types/dnf_appstream/test_dnf_appstream.py index eeb1976..84d8849 100644 --- a/promise-types/dnf_appstream/test_dnf_appstream.py +++ b/promise-types/dnf_appstream/test_dnf_appstream.py @@ -1,9 +1,18 @@ #!/usr/bin/env python3 import os + import sys -# Add the libraries directory to the Python path so we can import cfengine_module_library +from unittest.mock import MagicMock + + + +# Mock dnf module before importing the promise module +mock_dnf = MagicMock() +sys.modules["dnf"] = mock_dnf + +# Add the libraries directory to the Python path sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "libraries", "python")) try: @@ -100,7 +109,10 @@ def test_state_validation(): module = DnfAppStreamPromiseTypeModule() # Test valid states - valid_states = ["enabled", "disabled", "installed", "removed"] + valid_states = [ + "enabled", "disabled", "install", "remove", + "present", "absent", "default", "reset" + ] for state in valid_states: try: module._validate_state(state) @@ -109,7 +121,9 @@ def test_state_validation(): print(f" ✗ Valid state '{state}' failed validation: {e}") # Test invalid states - invalid_states = ["active", "inactive", "present", "absent", "enable", "disable"] + invalid_states = [ + "active", "inactive", "enable", "disable", "installed", "removed" + ] for state in invalid_states: try: module._validate_state(state) From a1d6535705c0305e9e5cd417fafed4ed478bf4a3 Mon Sep 17 00:00:00 2001 From: Nick Anderson Date: Thu, 22 Jan 2026 14:43:31 -0600 Subject: [PATCH 5/5] Improvements after review and testing --- cfbs.json | 9 + promise-types/dnf_appstream/README.md | 30 +- promise-types/dnf_appstream/cfbs.json | 21 - promise-types/dnf_appstream/dnf_appstream.py | 376 ++++++++++-------- promise-types/dnf_appstream/example.cf | 103 ----- .../dnf_appstream/test_dnf_appstream.py | 224 ++++------- 6 files changed, 310 insertions(+), 453 deletions(-) delete mode 100644 promise-types/dnf_appstream/cfbs.json delete mode 100644 promise-types/dnf_appstream/example.cf diff --git a/cfbs.json b/cfbs.json index e05a954..7c2091c 100644 --- a/cfbs.json +++ b/cfbs.json @@ -223,6 +223,15 @@ "append enable.cf services/init.cf" ] }, + "promise-type-dnf_appstream": { + "description": "Promise type to manage dnf appstream modules.", + "subdirectory": "promise-types/dnf_appstream", + "dependencies": ["library-for-promise-types-in-python"], + "steps": [ + "copy dnf_appstream.py modules/promises/", + "append enable.cf services/init.cf" + ] + }, "promise-type-git": { "description": "Promise type to manage git repos.", "subdirectory": "promise-types/git", diff --git a/promise-types/dnf_appstream/README.md b/promise-types/dnf_appstream/README.md index d8566e4..124b677 100644 --- a/promise-types/dnf_appstream/README.md +++ b/promise-types/dnf_appstream/README.md @@ -1,6 +1,6 @@ # DNF AppStream Promise Type -A CFEngine custom promise type for managing DNF AppStream modules on RHEL 8+ and compatible systems. +A CFEngine custom promise type for managing DNF AppStream modules on compatible systems. ## Overview @@ -10,10 +10,6 @@ The `dnf_appstream` promise type allows you to manage DNF AppStream modules, whi - Enable, disable, install, and remove DNF AppStream modules - Support for specifying streams and profiles -- Input validation and sanitization for security -- Proper error handling and logging -- Module state checking to avoid unnecessary operations -- Uses DNF Python API for efficient and secure operations ## Installation @@ -23,7 +19,7 @@ To install this promise type, copy the `dnf_appstream.py` file to your CFEngine promise agent dnf_appstream { interpreter => "/usr/bin/python3"; - path => "$(sys.inputdir)/dnf_appstream.py"; + path => "$(sys.workdir)/modules/promises/dnf_appstream.py"; } ``` @@ -59,20 +55,20 @@ bundle agent main { dnf_appstream: "python36" - state => "present", + state => "installed", stream => "3.6", profile => "minimal"; } ``` -### Ensure a module is absent +### Ensure a module is removed ``` bundle agent main { dnf_appstream: "postgresql" - state => "absent"; + state => "removed"; } ``` @@ -91,19 +87,9 @@ bundle agent main The promise type supports the following attributes: -- `state` (required) - Desired state of the module: `present`, `absent`, `enabled`, `disabled`, or `default` (default: `present`) -- `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. - -## Module States - -- `present` - The module and its packages (profile) are present on the system (implies enabled). Alias: `install`. -- `absent` - The module is not present or is disabled. Alias: `remove`. -- `enabled` - The module is enabled and available for installation. -- `disabled` - The module is explicitly disabled. -- `default` - The module is in its default state (neither enabled nor disabled, no profiles installed). Alias: `reset`. - -Note: The `present` state implies `enabled` because in DNF's module system, installing a module automatically enables it first. +- `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 diff --git a/promise-types/dnf_appstream/cfbs.json b/promise-types/dnf_appstream/cfbs.json deleted file mode 100644 index 021c0cc..0000000 --- a/promise-types/dnf_appstream/cfbs.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "name": "dnf_appstream", - "type": "promise-type", - "description": "A custom promise type to manage DNF AppStream modules", - "tags": ["dnf", "appstream", "modules", "package management", "redhat", "fedora", "centos"], - "files": [ - { - "path": "promise-types/dnf_appstream/dnf_appstream.py", - "type": "source", - "permissions": "644" - }, - { - "path": "promise-types/dnf_appstream/README.md", - "type": "documentation", - "permissions": "644" - } - ], - "dependencies": [], - "test_command": "python3 test_dnf_appstream.py", - "version": "0.0.1" -} \ No newline at end of file diff --git a/promise-types/dnf_appstream/dnf_appstream.py b/promise-types/dnf_appstream/dnf_appstream.py index 480fa73..b42b00e 100644 --- a/promise-types/dnf_appstream/dnf_appstream.py +++ b/promise-types/dnf_appstream/dnf_appstream.py @@ -7,13 +7,13 @@ # promise agent dnf_appstream # { # interpreter => "/usr/bin/python3"; -# path => "$(sys.inputdir)/dnf_appstream.py"; +# path => "$(sys.workdir)/modules/promises/dnf_appstream.py"; # } # bundle agent main # { # dnf_appstream: # "nodejs" -# state => "present", +# state => "installed", # stream => "12"; # # "postgresql" @@ -32,7 +32,7 @@ def __init__(self, **kwargs): ) # Define all expected attributes with their types and validation - self.add_attribute("state", str, required=True, default="enabled", + 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)) @@ -40,12 +40,11 @@ def __init__(self, **kwargs): def _validate_state(self, value): if value not in ( - "enabled", "disabled", "install", "remove", "default", "reset", - "present", "absent" + "enabled", "disabled", "installed", "removed", "default", "reset" ): raise ValidationError( - "State attribute must be 'enabled', 'disabled', 'install', " - "'remove', 'default', 'reset', 'present', or 'absent'" + "State attribute must be 'enabled', 'disabled', 'installed', " + "'removed', 'default', or 'reset'" ) def _validate_module_name(self, name): @@ -77,12 +76,11 @@ def evaluate_promise(self, promiser, attributes, meta): stream = attributes.get("stream", None) profile = attributes.get("profile", None) - # Construct the module specification - module_spec = module_name - if stream: - module_spec += ":" + stream - if profile: - module_spec += "/" + profile + try: + from libdnf.module import ModulePackageContainer + except ImportError: + self.log_error("DNF module support not found, please install 'python3-dnf-plugin-modules-core' or similar.") + return Result.NOT_KEPT # Create a DNF base object base = dnf.Base() @@ -96,15 +94,16 @@ def evaluate_promise(self, promiser, attributes, meta): # Fill the sack (package database) base.fill_sack(load_system_repo='auto') - # Access the module base - module_base = base.module_base - if module_base is None: - self.log_error("DNF modules are not available") + # Get ModulePackageContainer from sack + 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(module_base, module_name) + stream = self._get_default_stream(mpc, module_name) if not stream: self.log_error( f"No default stream found for module {module_name}" @@ -118,9 +117,9 @@ def evaluate_promise(self, promiser, attributes, meta): # 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(module_base, module_name) - - profile = self._get_default_profile(module_base, module_name, 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}" @@ -136,27 +135,41 @@ def evaluate_promise(self, promiser, attributes, meta): module_spec += "/" + profile # Check current state of the module - current_state = self._get_module_state(module_base, module_name, stream) + 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": - self.log_verbose(f"Module {module_name} is already enabled") - return Result.KEPT + # 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(module_base, module_spec) + 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(module_base, module_spec) - elif state == "install" or state == "present": + return self._disable_module(mpc, base, module_name) + elif state == "installed": if current_state in ["installed", "enabled"]: - # For "present" state, if it's already installed or 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( - module_base, module_name, stream, profile + mpc, module_name, stream, profile ): self.log_verbose( f"Module {module_name} (stream: {stream}, " @@ -164,183 +177,232 @@ def evaluate_promise(self, promiser, attributes, meta): ) return Result.KEPT else: - return self._install_module(module_base, module_spec) + 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(module_base, module_spec) - elif state == "remove" or state == "absent": + 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(module_base, module_spec) + return self._remove_module(mpc, base, module_name, stream, profile) elif state == "default" or state == "reset": return self._reset_module( - module_base, module_name, stream, module_spec + mpc, base, module_name ) - def _get_module_state(self, module_base, module_name, stream): + def _get_module_state(self, mpc, module_name): """Get the current state of a module using DNF Python API""" - try: - # List all modules to check the current state - module_list, _ = module_base._get_modules(module_name) - - for module in module_list: - # Check if this is the stream we're looking for (if specified) - if stream and module.stream != stream: - continue - - # Check the module state - if module.status in ("enabled", "disabled", "installed"): - return module.status - - # If we get here, module is not found or not in the specified stream - return "removed" - - except Exception as e: - self.log_error(f"Error getting module state for {module_name}: {str(e)}") - return "unknown" - - def _get_default_stream(self, module_base, module_name): + 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""" - try: - module_list, _ = module_base._get_modules(module_name) - for module in module_list: - # DNF API: module.is_default is usually a boolean - if getattr(module, "is_default", False): - return module.stream - return None - except Exception as e: - self.log_debug(f"Error finding default stream for {module_name}: {e}") - return None + return mpc.getDefaultStream(module_name) - def _get_default_profile(self, module_base, module_name, stream): + def _get_default_profile(self, mpc, module_name, stream): """Find the default profile for a module stream""" - try: - module_list, _ = module_base._get_modules(module_name) - for module in module_list: - if stream and module.stream != stream: - continue - - # If finding for specific stream (or default stream found) - for profile in module.profiles: - if getattr(profile, "is_default", False): - return profile.name - return None - except Exception as e: - self.log_debug(f"Error finding default profile for {module_name}: {e}") - return None + profiles = mpc.getDefaultProfiles(module_name, stream) + if profiles: + return profiles[0] + return None def _is_module_installed_with_packages( - self, module_base, module_name, stream, profile_name + self, mpc, module_name, stream, profile_name ): """Check if the module packages/profiles are installed on the system""" + # Check stream try: - module_list, _ = module_base._get_modules(module_name) - for module in module_list: - if stream and module.stream != stream: - continue - - if module.status != "installed": - continue - - # If no profile is specified, 'installed' status is enough - if not profile_name: - return True - - # If profile is specified, check if it's installed - for profile in module.profiles: - if profile.name == profile_name: - return profile.status == "installed" - - return False - except Exception as e: - self.log_debug( - f"Error checking if module packages are installed: {e}" - ) - return False - - def _enable_module(self, module_base, module_spec): + enabled_stream = mpc.getEnabledStream(module_name) + if stream and enabled_stream != stream: + return False + + target_stream = stream if stream else enabled_stream + if not target_stream: + return False + # RuntimeError is raised by libdnf if the module is unknown + except RuntimeError: + pass + + # Check profile + target_profile = profile_name + if not target_profile: + target_profile = self._get_default_profile(mpc, module_name, target_stream) + + if target_profile: + try: + installed_profiles = mpc.getInstalledProfiles(module_name) + return target_profile in installed_profiles + # RuntimeError is raised by libdnf if the module is unknown + except RuntimeError: + return False + + return True + + def _enable_module(self, mpc, base, module_name, stream): """Enable a module using DNF Python API""" try: - module_base.enable([module_spec]) - module_base.base.resolve() - module_base.base.do_transaction() - self.log_info(f"Module {module_spec} enabled successfully") - return Result.REPAIRED + target_stream = stream + if not target_stream: + target_stream = 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 except Exception as e: - self.log_error(f"Failed to enable module {module_spec}: {str(e)}") + self.log_error(f"Error enabling module {module_name}:{stream}: {e}") return Result.NOT_KEPT - def _disable_module(self, module_base, module_spec): + def _disable_module(self, mpc, base, module_name): """Disable a module using DNF Python API""" try: - module_base.disable([module_spec]) - module_base.base.resolve() - module_base.base.do_transaction() - self.log_info(f"Module {module_spec} disabled successfully") - return Result.REPAIRED + 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 except Exception as e: - self.log_error(f"Failed to disable module {module_spec}: {str(e)}") + self.log_error(f"Error disabling module {module_name}: {e}") return Result.NOT_KEPT - def _install_module(self, module_base, module_spec): - """Install a module (enable + install default packages) using DNF Python API""" + 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""" try: - # Enable and install the module - module_base.install([module_spec]) - module_base.base.resolve() - module_base.base.do_transaction() - self.log_info(f"Module {module_spec} installed successfully") + if not stream: + stream = mpc.getEnabledStream(module_name) + if not stream: + stream = self._get_default_stream(mpc, module_name) + + # If profile is not specified, use default + 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) + if packages: + for pkg in packages: + try: + base.install(pkg) + # dnf.exceptions.Error catches package not found, conflicts, etc. + except dnf.exceptions.Error as e: + self.log_verbose(f"Failed to install package {pkg}: {e}") + + base.resolve() + base.do_transaction() + self.log_info(f"Module {module_name}:{stream}/{profile} installed successfully") return Result.REPAIRED except Exception as e: - self.log_error(f"Failed to install module {module_spec}: {str(e)}") + self.log_error(f"Error installing module {module_name}:{stream}: {e}") return Result.NOT_KEPT - def _remove_module(self, module_base, module_spec): + def _remove_module(self, mpc, base, module_name, stream, profile): """Remove a module using DNF Python API""" try: - # Get list of packages from the module to remove - module_base.remove([module_spec]) - module_base.base.resolve() - module_base.base.do_transaction() - self.log_info(f"Module {module_spec} removed successfully") - return Result.REPAIRED + target_stream = stream + if not target_stream: + target_stream = mpc.getEnabledStream(module_name) + + if not target_stream: + self.log_verbose(f"No active stream for {module_name}, nothing to remove") + return Result.KEPT + + if profile: + mpc.uninstall(module_name, target_stream, profile) + # Explicitly remove packages + pkgs = self._get_profile_packages(mpc, module_name, target_stream, profile) + for pkg in pkgs: + try: + base.remove(pkg) + # dnf.exceptions.Error catches package not installed, etc. + except dnf.exceptions.Error as e: + self.log_verbose(f"Failed to remove package {pkg}: {e}") + else: + profiles = mpc.getInstalledProfiles(module_name) + for p in profiles: + mpc.uninstall(module_name, target_stream, p) + # Explicitly remove packages + pkgs = self._get_profile_packages(mpc, module_name, target_stream, p) + for pkg in pkgs: + try: + base.remove(pkg) + # dnf.exceptions.Error catches package not installed, etc. + except dnf.exceptions.Error as e: + self.log_verbose(f"Failed to remove package {pkg}: {e}") + + mpc.save() + base.resolve(allow_erasing=True) + base.do_transaction() + self.log_info(f"Module {module_name} removed successfully") + return Result.REPAIRED except Exception as e: - self.log_error(f"Failed to remove module {module_spec}: {str(e)}") + self.log_error(f"Error removing module {module_name}: {e}") return Result.NOT_KEPT - def _reset_module(self, module_base, module_name, stream, module_spec): + def _reset_module(self, mpc, base, module_name): """Reset a module using DNF Python API""" try: - # First check if anything is enabled/installed for this module - module_list, _ = module_base._get_modules(module_name) - needs_reset = False - for module in module_list: - if stream and module.stream != stream: - continue - if module.status in ("enabled", "disabled", "installed"): - needs_reset = True - break - - if not needs_reset: - self.log_verbose( - f"Module {module_name} is already in default (reset) state" - ) + if mpc.getModuleState(module_name) == mpc.ModuleState_DEFAULT: + self.log_verbose(f"Module {module_name} is already in default (reset) state") return Result.KEPT - module_base.reset([module_spec]) - module_base.base.resolve() - module_base.base.do_transaction() - self.log_info(f"Module {module_spec} reset successfully") + mpc.reset(module_name) + mpc.save() + base.resolve() + base.do_transaction() + self.log_info(f"Module {module_name} reset successfully") return Result.REPAIRED except Exception as e: - self.log_error(f"Failed to reset module {module_spec}: {str(e)}") + self.log_error(f"Error resetting module {module_name}: {e}") return Result.NOT_KEPT if __name__ == "__main__": - DnfAppStreamPromiseTypeModule().start() \ No newline at end of file + DnfAppStreamPromiseTypeModule().start() diff --git a/promise-types/dnf_appstream/example.cf b/promise-types/dnf_appstream/example.cf deleted file mode 100644 index 57c5414..0000000 --- a/promise-types/dnf_appstream/example.cf +++ /dev/null @@ -1,103 +0,0 @@ -# Example policy using the dnf_appstream promise type and packages with AppStream info - -promise agent dnf_appstream -{ - interpreter => "/usr/bin/python3"; - path => "$(sys.inputdir)/promise-types/dnf_appstream/dnf_appstream.py"; -} - -promise agent rpm_repo -{ - interpreter => "/usr/bin/python3"; - path => "$(sys.inputdir)/promise-types/rpm_repo/rpm_repo.py"; -} - -body package_method dnf_with_modules -{ - package_module => "dnf"; - package_policy => "present"; -} - -bundle agent main -{ - # Configure repositories first - rpm_repo: - "epel" - name => "Extra Packages for Enterprise Linux", - baseurl => "https://download.fedoraproject.org/pub/epel/$releasever/$basearch/", - enabled => "1", - gpgcheck => "1", - gpgkey => "https://download.fedoraproject.org/pub/epel/RPM-GPG-KEY-EPEL-$releasever"; - - # Configure AppStream modules before installing packages - dnf_appstream: - "nodejs" - state => "enabled", - stream => "14"; - - "python36" - state => "installed", - stream => "3.6", - profile => "minimal"; - - "postgresql" - state => "enabled", - stream => "12"; - - # Install packages that are part of AppStream modules - packages: - # Node.js packages from the enabled stream - "nodejs" - package_method => dnf_with_modules; - - # Python 3.6 packages from the installed stream - "python36" - package_method => dnf_with_modules; - - # PostgreSQL packages from the enabled stream - "postgresql-server" - package_method => dnf_with_modules; - - # Other packages from standard repositories - "nginx" - package_method => dnf_with_modules; - "git" - package_method => dnf_with_modules; -} - -bundle agent setup_development_environment -{ - # Enable development-related modules - dnf_appstream: - "nodejs" - state => "enabled", - stream => "16"; - - "python36" - state => "installed", - stream => "3.6", - profile => "development"; - - "maven" - state => "enabled", - stream => "3.6"; - - packages: - # Install packages from the enabled modules - "nodejs" - package_method => dnf_with_modules; - - "python36" - package_method => dnf_with_modules; - - "maven" - package_method => dnf_with_modules; - - # Additional development tools - "gcc" - package_method => dnf_with_modules; - "make" - package_method => dnf_with_modules; - "vim-enhanced" - package_method => dnf_with_modules; -} diff --git a/promise-types/dnf_appstream/test_dnf_appstream.py b/promise-types/dnf_appstream/test_dnf_appstream.py index 84d8849..acbc1c8 100644 --- a/promise-types/dnf_appstream/test_dnf_appstream.py +++ b/promise-types/dnf_appstream/test_dnf_appstream.py @@ -1,160 +1,84 @@ -#!/usr/bin/env python3 - -import os - import sys - +import os +import pytest from unittest.mock import MagicMock - - # Mock dnf module before importing the promise module mock_dnf = MagicMock() sys.modules["dnf"] = mock_dnf -# Add the libraries directory to the Python path +# Add library path sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "libraries", "python")) - -try: - from dnf_appstream import DnfAppStreamPromiseTypeModule - from cfengine_module_library import ValidationError -except ImportError as e: - print(f"Import error: {e}") - print("Make sure cfengine_module_library.py is in the correct location") - sys.exit(1) - -def test_validation(): - """Test validation of module attributes""" - print("Testing validation...") - - module = DnfAppStreamPromiseTypeModule() - - # Test valid attributes - try: - module.validate_promise("nodejs", { - "state": "enabled", - "stream": "12" - }, {}) - print(" ✓ Valid attributes validation passed") - except Exception as e: - print(f" ✗ Valid attributes validation failed: {e}") - - # Test invalid module name - try: - module.validate_promise("nodejs; rm -rf /", { +# Add module path +sys.path.insert(0, os.path.dirname(__file__)) + +from dnf_appstream import DnfAppStreamPromiseTypeModule, ValidationError + +@pytest.fixture +def module(): + return DnfAppStreamPromiseTypeModule() + +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" }, {}) - print(" ✗ Invalid module name validation failed - should have caught injection") - except ValidationError as e: - print(f" ✓ Invalid module name validation passed: {e}") - except Exception as e: - print(f" ? Unexpected exception for invalid module name: {e}") - - # Note: Stream and State validation have been moved to attribute validators - # which are handled by the library, not inside validate_promise directly. - # Therefore we don't test them here via validate_promise, but in their specific test functions below. - -def test_module_name_validation(): - """Test module name validation""" - print("\nTesting module name validation...") - - module = DnfAppStreamPromiseTypeModule() - - # Test valid names - valid_names = ["nodejs", "python3.6", "python36", "postgresql", "maven", "httpd"] - for name in valid_names: - try: - module._validate_module_name(name) - print(f" ✓ Valid name '{name}' passed validation") - except Exception as e: - print(f" ✗ Valid name '{name}' failed validation: {e}") - - # Test invalid names - invalid_names = ["nodejs;rm", "python36&&", "postgresql|", "maven>", "httpd<"] - for name in invalid_names: - try: - module._validate_module_name(name) - print(f" ✗ Invalid name '{name}' passed validation - should have failed") - except Exception as e: - print(f" ✓ Invalid name '{name}' failed validation as expected: {e}") - -def test_stream_name_validation(): - """Test stream name validation""" - print("\nTesting stream name validation...") - - module = DnfAppStreamPromiseTypeModule() - - # Test valid stream names - valid_streams = ["12", "14", "3.6", "1.14", "latest", "stable"] - for stream in valid_streams: - try: - module._validate_stream_name(stream) - print(f" ✓ Valid stream '{stream}' passed validation") - except Exception as e: - print(f" ✗ Valid stream '{stream}' failed validation: {e}") - - # Test invalid stream names - invalid_streams = ["12;rm", "14&&", "3.6|", "latest>", "stable<"] - for stream in invalid_streams: - try: - module._validate_stream_name(stream) - print(f" ✗ Invalid stream '{stream}' passed validation - should have failed") - except Exception as e: - print(f" ✓ Invalid stream '{stream}' failed validation as expected: {e}") - -def test_state_validation(): - """Test state validation""" - print("\nTesting state validation...") - - module = DnfAppStreamPromiseTypeModule() - - # Test valid states - valid_states = [ - "enabled", "disabled", "install", "remove", - "present", "absent", "default", "reset" - ] - for state in valid_states: - try: - module._validate_state(state) - print(f" ✓ Valid state '{state}' passed validation") - except Exception as e: - print(f" ✗ Valid state '{state}' failed validation: {e}") - - # Test invalid states - invalid_states = [ - "active", "inactive", "enable", "disable", "installed", "removed" - ] - for state in invalid_states: - try: - module._validate_state(state) - print(f" ✗ Invalid state '{state}' passed validation - should have failed") - except ValidationError as e: - print(f" ✓ Invalid state '{state}' failed validation as expected: {e}") - except Exception as e: - print(f" ? Unexpected exception for invalid state '{state}': {e}") - -def test_state_parsing(): - """Test parsing of module states from dnf output""" - print("\nTesting state parsing...") - - module = DnfAppStreamPromiseTypeModule() - - # Test that the method exists and can be called - try: - # We can't easily test the actual parsing without mocking dnf, - # but we can at least verify the method exists - hasattr(module, '_get_module_state') - print(" ✓ State parsing method exists") - except Exception as e: - print(f" ✗ State parsing method test failed: {e}") - -if __name__ == "__main__": - print("Running tests for dnf_appstream promise type...") - - test_validation() - test_module_name_validation() - test_stream_name_validation() - test_state_validation() - test_state_parsing() - - print("\nAll tests completed.") + 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')